Commit c37973bb authored by catch's avatar catch

Issue #1793520 by disasm, klausi, tnightingale, Crell: Add access control...

Issue #1793520 by disasm, klausi, tnightingale, Crell: Add access control mechanism for new router system.
parent cf860477
<?php
/**
* @file
* Contains Drupal\Core\Access\AccessCheckInterface.
*/
namespace Drupal\Core\Access;
use Symfony\Component\Routing\Route;
use Symfony\Component\HttpFoundation\Request;
/**
* An access check service determines access rules for particular routes.
*/
interface AccessCheckInterface {
/**
* Declares whether the access check applies to a specific route or not.
*
* @param \Symfony\Component\Routing\Route $route
* The route to consider attaching to.
*
* @return bool
* TRUE if the check applies to the passed route, FALSE otherwise.
*/
public function applies(Route $route);
/**
* Checks for access to route.
*
* @param \Symfony\Component\Routing\Route $route
* The route to check against.
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object.
*
* @return mixed
* TRUE if access is allowed.
* FALSE if not.
* NULL if no opinion.
*/
public function access(Route $route, Request $request);
}
<?php
/**
* @file
* Contains Drupal\Core\Access\AccessManager.
*/
namespace Drupal\Core\Access;
use Symfony\Component\Routing\RouteCollection;
use Symfony\Component\Routing\Route;
use Symfony\Component\DependencyInjection\ContainerAware;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* Attaches access check services to routes and runs them on request.
*/
class AccessManager extends ContainerAware {
/**
* Array of registered access check service ids.
*
* @var array
*/
protected $checkIds;
/**
* Array of access check objects keyed by service id.
*
* @var array
*/
protected $checks;
/**
* The request object.
*
* @var \Symfony\Component\HttpFoundation\Request
*/
protected $request;
/**
* Constructs a new AccessManager.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object.
*/
public function __construct(Request $request) {
$this->request = $request;
}
/**
* Registers a new AccessCheck by service ID.
*
* @param string $service_id
* The ID of the service in the Container that provides a check.
*/
public function addCheckService($service_id) {
$this->checkIds[] = $service_id;
}
/**
* For each route, saves a list of applicable access checks to the route.
*
* @param \Symfony\Component\Routing\RouteCollection $routes
* A collection of routes to apply checks to.
*/
public function setChecks(RouteCollection $routes) {
foreach ($routes as $route) {
$checks = $this->applies($route);
if (!empty($checks)) {
$route->setOption('_access_checks', $checks);
}
}
}
/**
* Determine which registered access checks apply to a route.
*
* @param \Symfony\Component\Routing\Route $route
* The route to get list of access checks for.
*
* @return array
* An array of service ids for the access checks that apply to passed
* route.
*/
protected function applies(Route $route) {
$checks = array();
foreach ($this->checkIds as $service_id) {
if (empty($this->checks[$service_id])) {
$this->loadCheck($service_id);
}
if ($this->checks[$service_id]->applies($route)) {
$checks[] = $service_id;
}
}
return $checks;
}
/**
* Checks a route against applicable access check services.
*
* Determines whether the route is accessible or not.
*
* @param \Symfony\Component\Routing\Route $route
* The route to check access to.
*
* @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
* If any access check denies access or none explicitly approve.
*/
public function check(Route $route) {
$access = FALSE;
$checks = $route->getOption('_access_checks') ?: array();
// No checks == deny by default.
foreach ($checks as $service_id) {
if (empty($this->checks[$service_id])) {
$this->loadCheck($service_id);
}
$access = $this->checks[$service_id]->access($route, $this->request);
if ($access === FALSE) {
// A check has denied access, no need to continue checking.
break;
}
}
// Access has been denied or not explicily approved.
if (!$access) {
throw new AccessDeniedHttpException();
}
}
/**
* Lazy-loads access check services.
*
* @param string $service_id
* The service id of the access check service to load.
*/
protected function loadCheck($service_id) {
if (!in_array($service_id, $this->checkIds)) {
throw new \InvalidArgumentException(sprintf('No check has been registered for %s', $service_id));
}
$this->checks[$service_id] = $this->container->get($service_id);
}
}
<?php
/**
* @file
* Contains Drupal\Core\Access\DefaultAccessCheck.
*/
namespace Drupal\Core\Access;
use Symfony\Component\Routing\Route;
use Symfony\Component\HttpFoundation\Request;
/**
* Allows access to routes to be controlled by an '_access' boolean parameter.
*/
class DefaultAccessCheck implements AccessCheckInterface {
/**
* Implements AccessCheckInterface::applies().
*/
public function applies(Route $route) {
return array_key_exists('_access', $route->getRequirements());
}
/**
* Implements AccessCheckInterface::access().
*/
public function access(Route $route, Request $request) {
return $route->getRequirement('_access');
}
}
<?php
/**
* @file
* Contains Drupal\Core\Access\PermissionAccessCheck.
*/
namespace Drupal\Core\Access;
use Symfony\Component\Routing\Route;
use Symfony\Component\HttpFoundation\Request;
/**
* Determines access to routes based on permissions defined via hook_permission().
*/
class PermissionAccessCheck implements AccessCheckInterface {
/**
* Implements AccessCheckInterface::applies().
*/
public function applies(Route $route) {
return array_key_exists('_permission', $route->getRequirements());
}
/**
* Implements AccessCheckInterface::access().
*/
public function access(Route $route, Request $request) {
$permission = $route->getRequirement('_permission');
// @todo Replace user_access() with a correctly injected and session-using
// alternative.
// If user_access() fails, return NULL to give other checks a chance.
return user_access($permission) ? TRUE : NULL;
}
}
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
namespace Drupal\Core; namespace Drupal\Core;
use Drupal\Core\DependencyInjection\Compiler\RegisterKernelListenersPass; use Drupal\Core\DependencyInjection\Compiler\RegisterKernelListenersPass;
use Drupal\Core\DependencyInjection\Compiler\RegisterAccessChecksPass;
use Drupal\Core\DependencyInjection\Compiler\RegisterMatchersPass; use Drupal\Core\DependencyInjection\Compiler\RegisterMatchersPass;
use Drupal\Core\DependencyInjection\Compiler\RegisterNestedMatchersPass; use Drupal\Core\DependencyInjection\Compiler\RegisterNestedMatchersPass;
use Drupal\Core\DependencyInjection\Compiler\RegisterSerializationClassesPass; use Drupal\Core\DependencyInjection\Compiler\RegisterSerializationClassesPass;
...@@ -179,8 +180,18 @@ public function build(ContainerBuilder $container) { ...@@ -179,8 +180,18 @@ public function build(ContainerBuilder $container) {
$container->register('view_subscriber', 'Drupal\Core\EventSubscriber\ViewSubscriber') $container->register('view_subscriber', 'Drupal\Core\EventSubscriber\ViewSubscriber')
->addArgument(new Reference('content_negotiation')) ->addArgument(new Reference('content_negotiation'))
->addTag('event_subscriber'); ->addTag('event_subscriber');
$container->register('legacy_access_subscriber', 'Drupal\Core\EventSubscriber\LegacyAccessSubscriber')
->addTag('event_subscriber');
$container->register('access_manager', 'Drupal\Core\Access\AccessManager')
->addArgument(new Reference('request'))
->addMethodCall('setContainer', array(new Reference('service_container')));
$container->register('access_subscriber', 'Drupal\Core\EventSubscriber\AccessSubscriber') $container->register('access_subscriber', 'Drupal\Core\EventSubscriber\AccessSubscriber')
->addArgument(new Reference('access_manager'))
->addTag('event_subscriber'); ->addTag('event_subscriber');
$container->register('access_check.default', 'Drupal\Core\Access\DefaultAccessCheck')
->addTag('access_check');
$container->register('access_check.permission', 'Drupal\Core\Access\PermissionAccessCheck')
->addTag('access_check');
$container->register('maintenance_mode_subscriber', 'Drupal\Core\EventSubscriber\MaintenanceModeSubscriber') $container->register('maintenance_mode_subscriber', 'Drupal\Core\EventSubscriber\MaintenanceModeSubscriber')
->addTag('event_subscriber'); ->addTag('event_subscriber');
$container->register('path_subscriber', 'Drupal\Core\EventSubscriber\PathSubscriber') $container->register('path_subscriber', 'Drupal\Core\EventSubscriber\PathSubscriber')
...@@ -221,6 +232,9 @@ public function build(ContainerBuilder $container) { ...@@ -221,6 +232,9 @@ public function build(ContainerBuilder $container) {
$container->addCompilerPass(new RegisterKernelListenersPass(), PassConfig::TYPE_AFTER_REMOVING); $container->addCompilerPass(new RegisterKernelListenersPass(), PassConfig::TYPE_AFTER_REMOVING);
// Add a compiler pass for adding Normalizers and Encoders to Serializer. // Add a compiler pass for adding Normalizers and Encoders to Serializer.
$container->addCompilerPass(new RegisterSerializationClassesPass()); $container->addCompilerPass(new RegisterSerializationClassesPass());
// Add a compiler pass for registering event subscribers.
$container->addCompilerPass(new RegisterKernelListenersPass(), PassConfig::TYPE_AFTER_REMOVING);
$container->addCompilerPass(new RegisterAccessChecksPass());
} }
} }
<?php
/**
* @file
* Contains Drupal\Core\DependencyInjection\Compiler\RegisterAccessChecksPass.
*/
namespace Drupal\Core\DependencyInjection\Compiler;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
/**
* Adds services tagged 'access_check' to the access_manager service.
*/
class RegisterAccessChecksPass implements CompilerPassInterface {
/**
* Implements CompilerPassInterface::process().
*
* Adds services tagged 'access_check' to the access_manager service.
*/
public function process(ContainerBuilder $container) {
if (!$container->hasDefinition('access_manager')) {
return;
}
$access_manager = $container->getDefinition('access_manager');
foreach ($container->findTaggedServiceIds('access_check') as $id => $attributes) {
$access_manager->addMethodCall('addCheckService', array($id));
}
}
}
...@@ -2,15 +2,17 @@ ...@@ -2,15 +2,17 @@
/** /**
* @file * @file
* Definition of Drupal\Core\EventSubscriber\AccessSubscriber. * Contains Drupal\Core\EventSubscriber\AccessSubscriber.
*/ */
namespace Drupal\Core\EventSubscriber; namespace Drupal\Core\EventSubscriber;
use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\HttpKernel\Event\GetResponseEvent; use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Drupal\Core\Routing\RoutingEvents;
use Drupal\Core\Access\AccessManager;
use Drupal\Core\Routing\RouteBuildEvent;
/** /**
* Access subscriber for controller requests. * Access subscriber for controller requests.
...@@ -18,21 +20,41 @@ ...@@ -18,21 +20,41 @@
class AccessSubscriber implements EventSubscriberInterface { class AccessSubscriber implements EventSubscriberInterface {
/** /**
* Verifies that the current user can access the requested path. * Constructs a new AccessSubscriber.
* *
* @todo This is a total hack to keep our current access system working. It * @param \Drupal\Core\Access\AccessManager $access_manager
* should be replaced with something robust and injected at some point. * The access check manager that will be responsible for applying
* AccessCheckers against routes.
*/
public function __construct(AccessManager $access_manager) {
$this->accessManager = $access_manager;
}
/**
* Verifies that the current user can access the requested path.
* *
* @param Symfony\Component\HttpKernel\Event\GetResponseEvent $event * @param \Symfony\Component\HttpKernel\Event\GetResponseEvent $event
* The Event to process. * The Event to process.
*/ */
public function onKernelRequestAccessCheck(GetResponseEvent $event) { public function onKernelRequestAccessCheck(GetResponseEvent $event) {
$request = $event->getRequest();
if (!$request->attributes->has('_route')) {
// If no Route is available it is likely a static resource and access is
// handled elsewhere.
return;
}
$router_item = $event->getRequest()->attributes->get('drupal_menu_item'); $this->accessManager->check($request->attributes->get('_route'));
if (isset($router_item['access']) && !$router_item['access']) {
throw new AccessDeniedHttpException();
} }
/**
* Apply access checks to routes.
*
* @param \Drupal\Core\Routing\RouteBuildEvent $event
* The event to process.
*/
public function onRoutingRouteAlterSetAccessCheck(RouteBuildEvent $event) {
$this->accessManager->setChecks($event->getRouteCollection());
} }
/** /**
...@@ -43,6 +65,8 @@ public function onKernelRequestAccessCheck(GetResponseEvent $event) { ...@@ -43,6 +65,8 @@ public function onKernelRequestAccessCheck(GetResponseEvent $event) {
*/ */
static function getSubscribedEvents() { static function getSubscribedEvents() {
$events[KernelEvents::REQUEST][] = array('onKernelRequestAccessCheck', 30); $events[KernelEvents::REQUEST][] = array('onKernelRequestAccessCheck', 30);
// Setting very low priority to ensure access checks are run after alters.
$events[RoutingEvents::ALTER][] = array('onRoutingRouteAlterSetAccessCheck', 0);
return $events; return $events;
} }
......
<?php
/**
* @file
* Contains Drupal\Core\EventSubscriber\LegacyAccessSubscriber.
*/
namespace Drupal\Core\EventSubscriber;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Access subscriber for legacy controller requests.
*/
class LegacyAccessSubscriber implements EventSubscriberInterface {
/**
* Verifies that the current user can access the requested path.
*
* @todo This is a total hack to keep our current access system working. It
* should be replaced with something robust and injected at some point.
*
* @param Symfony\Component\HttpKernel\Event\GetResponseEvent $event
* The Event to process.
*/
public function onKernelRequestAccessCheck(GetResponseEvent $event) {
$router_item = $event->getRequest()->attributes->get('drupal_menu_item');
if (isset($router_item['access']) && !$router_item['access']) {
throw new AccessDeniedHttpException();
}
}
/**
* Registers the methods in this class that should be listeners.
*
* @return array
* An array of event listener definitions.
*/
static function getSubscribedEvents() {
$events[KernelEvents::REQUEST][] = array('onKernelRequestAccessCheck', 30);
return $events;
}
}
...@@ -53,7 +53,8 @@ public function matchRequest(Request $request) { ...@@ -53,7 +53,8 @@ public function matchRequest(Request $request) {
preg_match($compiled->getRegex(), $path, $matches); preg_match($compiled->getRegex(), $path, $matches);
return array_merge($this->mergeDefaults($matches, $route->getDefaults()), array('_route' => $name)); $route->setOption('_name', $name);
return array_merge($this->mergeDefaults($matches, $route->getDefaults()), array('_route' => $route));
} }
} }
......
...@@ -62,6 +62,7 @@ public function dynamicRoutes(RouteBuildEvent $event) { ...@@ -62,6 +62,7 @@ public function dynamicRoutes(RouteBuildEvent $event) {
// @todo Switch to ->addCollection() once http://drupal.org/node/1819018 is resolved. // @todo Switch to ->addCollection() once http://drupal.org/node/1819018 is resolved.
foreach ($plugin->routes() as $name => $route) { foreach ($plugin->routes() as $name => $route) {
$route->setRequirement('_access', 'TRUE');
$collection->add("rest.$name", $route); $collection->add("rest.$name", $route);
} }
} }
......
<?php
/**
* @file
* Contains Drupal\system\Access\CronAccessCheck.
*/
namespace Drupal\system\Access;
use Drupal\Core\Access\AccessCheckInterface;
use Symfony\Component\Routing\Route;
use Symfony\Component\HttpFoundation\Request;
/**
* Access check for cron routes.
*/
class CronAccessCheck implements AccessCheckInterface {
/**
* Implements AccessCheckInterface::applies().
*/
public function applies(Route $route) {
return array_key_exists('_access_system_cron', $route->getRequirements());
}
/**
* Implements AccessCheckInterface::access().
*/
public function access(Route $route, Request $request) {
$key = $request->attributes->get('key');
if ($key != state()->get('system.cron_key')) {
watchdog('cron', 'Cron could not run because an invalid key was used.', array(), WATCHDOG_NOTICE);
return FALSE;
}
elseif (config('system.maintenance')->get('enabled')) {
watchdog('cron', 'Cron could not run because the site is in maintenance mode.', array(), WATCHDOG_NOTICE);
return FALSE;
}
return TRUE;
}
}
...@@ -8,10 +8,9 @@ ...@@ -8,10 +8,9 @@
namespace Drupal\system; namespace Drupal\system;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/** /**
* Controllers for Cron handling. * Controller for Cron handling.
*/ */
class CronController { class CronController {
...@@ -21,35 +20,11 @@ class CronController { ...@@ -21,35 +20,11 @@ class CronController {
* @return Symfony\Component\HttpFoundation\Response * @return Symfony\Component\HttpFoundation\Response
* A Symfony response object. * A Symfony response object.
*/ */
public function run($key) { public function run() {
if (!$this->access($key)) {
throw new AccessDeniedHttpException();
}
// @todo Make this an injected object. // @todo Make this an injected object.
drupal_cron_run(); drupal_cron_run();
// HTTP 204 is "No content", meaning "I did what you asked and we're done." // HTTP 204 is "No content", meaning "I did what you asked and we're done."
return new Response('', 204); return new Response('', 204);
} }
/**
* Determines if the user has access to run cron.
*
* @todo Eliminate this method in favor of a new-style access checker once
* http://drupal.org/node/1793520 gets in.
*/
function access($key) {
if ($key != state()->get('system.cron_key')) {
watchdog('cron', 'Cron could not run because an invalid key was used.', array(), WATCHDOG_NOTICE);
return FALSE;
}
elseif (config('system.maintenance')->get('enabled')) {
watchdog('cron', 'Cron could not run because the site is in maintenance mode.', array(), WATCHDOG_NOTICE);
return FALSE;
}
return TRUE;
}
} }
<?php
/**
* @file
* Contains Drupal\system\SystemBundle.
*/
namespace Drupal\system;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Bundle\Bundle;
/**
* System dependency injection container.
*/
class SystemBundle extends Bundle {
/**
* Overrides Bundle::build().
*/
public function build(ContainerBuilder $container) {
$container->register('access_check.cron', 'Drupal\system\Access\CronAccessCheck')
->addTag('access_check');
}
}
...@@ -61,7 +61,7 @@ public function testFinalMatcherStatic() { ...@@ -61,7 +61,7 @@ public function testFinalMatcherStatic() {
$matcher->setCollection($collection); $matcher->setCollection($collection);
$attributes = $matcher->matchRequest($request); $attributes = $matcher->matchRequest($request);
$this->assertEqual($attributes['_route'], 'route_a', 'The correct matching route was found.'); $this->assertEqual($attributes['_route']->getOption('_name'), 'route_a', 'The correct matching route was found.');
$this->assertEqual($attributes['_controller'], 'foo', 'The correct controller was found.'); $this->assertEqual($attributes['_controller'], 'foo', 'The correct controller was found.');
} }
...@@ -82,7 +82,7 @@ public function testFinalMatcherPattern() { ...@@ -82,7 +82,7 @@ public function testFinalMatcherPattern() {
$matcher->setCollection($collection); $matcher->setCollection($collection);
$attributes = $matcher->matchRequest($request); $attributes = $matcher->matchRequest($request);
$this->assertEqual($attributes['_route'], 'route_a', 'The correct matching route was found.'); $this->assertEqual($attributes['_route']->getOption('_name'), 'route_a', 'The correct matching route was found.');
$this->assertEqual($attributes['_controller'], 'foo', 'The correct controller was found.'); $this->assertEqual($attributes['_controller'], 'foo', 'The correct controller was found.');
$this->assertEqual($attributes['value'], 'narf', 'Required placeholder value found.'); $this->assertEqual($attributes['value'], 'narf', 'Required placeholder value found.');
} }
...@@ -105,7 +105,7 @@ public function testFinalMatcherPatternDefalts() { ...@@ -105,7 +105,7 @@ public function testFinalMatcherPatternDefalts() {
$matcher->setCollection($collection); $matcher->setCollection($collection);
$attributes = $matcher->matchRequest($request); $attributes = $matcher->matchRequest($request);
$this->assertEqual($attributes['_route'], 'route_a', 'The correct matching route was found.'); $this->assertEqual($attributes['_route']->getOption('_name'), 'route_a', 'The correct matching route was found.');
$this->assertEqual($attributes['_controller'], 'foo', 'The correct controller was found.'); $this->assertEqual($attributes['_controller'], 'foo', 'The correct controller was found.');
$this->assertEqual($attributes['value'], 'poink', 'Optional placeholder value used default.'); $this->assertEqual($attributes['value'], 'poink', 'Optional placeholder value used default.');
} }
......
...@@ -78,7 +78,7 @@ public function testNestedMatcher() { ...@@ -78,7 +78,7 @@ public function testNestedMatcher() {
$attributes = $matcher->matchRequest($request); $attributes = $matcher->matchRequest($request);
$this->assertEqual($attributes['_route'], 'route_a', 'The correct matching route was found.'); $this->assertEqual($attributes['_route']->getOption('_name'), 'route_a', 'The correct matching route was found.');
} }
/** /**
...