Commit 0f28b515 authored by catch's avatar catch
Browse files

Issue #2287071 by Wim Leers, alexpott, chx: Add cacheability metadata to access checks.

parent 6f0b27bf
<?php
/**
* @file
* Contains \Drupal\Core\Access\AccessInterface.
*/
namespace Drupal\Core\Access;
/**
* Provides access check results.
*/
interface AccessInterface {
/**
* Grant access.
*
* A checker should return this value to indicate that it grants access.
*/
const ALLOW = 'ALLOW';
/**
* Deny access.
*
* A checker should return this value to indicate it does not grant access.
*/
const DENY = 'DENY';
/**
* Block access.
*
* A checker should return this value to indicate that it wants to completely
* block access, regardless of any other access checkers. Most checkers
* should prefer AccessInterface::DENY.
*/
const KILL = 'KILL';
}
......@@ -9,6 +9,7 @@
use Drupal\Core\ParamConverter\ParamConverterManagerInterface;
use Drupal\Core\ParamConverter\ParamNotConvertedException;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\Core\Routing\RequestHelper;
use Drupal\Core\Routing\RouteProviderInterface;
use Drupal\Core\Session\AccountInterface;
......@@ -42,7 +43,7 @@ class AccessManager implements ContainerAwareInterface, AccessManagerInterface {
/**
* Array of access check objects keyed by service id.
*
* @var array
* @var \Drupal\Core\Routing\Access\AccessInterface[]
*/
protected $checks;
......@@ -192,7 +193,7 @@ protected function applies(Route $route) {
/**
* {@inheritdoc}
*/
public function checkNamedRoute($route_name, array $parameters = array(), AccountInterface $account = NULL, Request $route_request = NULL) {
public function checkNamedRoute($route_name, array $parameters = array(), AccountInterface $account = NULL, Request $route_request = NULL, $return_as_object = FALSE) {
try {
$route = $this->routeProvider->getRouteByName($route_name, $parameters);
if (empty($route_request)) {
......@@ -207,20 +208,25 @@ public function checkNamedRoute($route_name, array $parameters = array(), Accoun
$parameters[RouteObjectInterface::ROUTE_OBJECT] = $route;
$route_request->attributes->add($this->paramConverterManager->convert($parameters));
}
return $this->check($route, $route_request, $account);
return $this->check($route, $route_request, $account, $return_as_object);
}
catch (RouteNotFoundException $e) {
return FALSE;
// Cacheable until extensions change.
$result = AccessResult::forbidden()->addCacheTags(array('extension' => TRUE));
return $return_as_object ? $result : $result->isAllowed();
}
catch (ParamNotConvertedException $e) {
return FALSE;
// Uncacheable because conversion of the parameter may not have been
// possible due to dynamic circumstances.
$result = AccessResult::forbidden()->setCacheable(FALSE);
return $return_as_object ? $result : $result->isAllowed();
}
}
/**
* {@inheritdoc}
*/
public function check(Route $route, Request $request, AccountInterface $account = NULL) {
public function check(Route $route, Request $request, AccountInterface $account = NULL, $return_as_object = FALSE) {
if (!isset($account)) {
$account = $this->currentUser;
}
......@@ -228,11 +234,12 @@ public function check(Route $route, Request $request, AccountInterface $account
$conjunction = $route->getOption('_access_mode') ?: static::ACCESS_MODE_ALL;
if ($conjunction == static::ACCESS_MODE_ALL) {
return $this->checkAll($checks, $route, $request, $account);
$result = $this->checkAll($checks, $route, $request, $account);
}
else {
return $this->checkAny($checks, $route, $request, $account);
$result = $this->checkAny($checks, $route, $request, $account);
}
return $return_as_object ? $result : $result->isAllowed();
}
/**
......@@ -247,30 +254,39 @@ public function check(Route $route, Request $request, AccountInterface $account
* @param \Drupal\Core\Session\AccountInterface $account
* The current user.
*
* @return bool
* Returns TRUE if the user has access to the route, else FALSE.
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*
* @see \Drupal\Core\Access\AccessResultInterface::andIf()
*/
protected function checkAll(array $checks, Route $route, Request $request, AccountInterface $account) {
$access = FALSE;
$results = array();
foreach ($checks as $service_id) {
if (empty($this->checks[$service_id])) {
$this->loadCheck($service_id);
}
$service_access = $this->performCheck($service_id, $route, $request, $account);
$result = $this->performCheck($service_id, $route, $request, $account);
$results[] = $result;
if ($service_access === AccessInterface::ALLOW) {
$access = TRUE;
}
else {
// On both KILL and DENY stop.
$access = FALSE;
// Stop as soon as the first non-allowed check is encountered.
if (!$result->isAllowed()) {
break;
}
}
return $access;
if (empty($results)) {
// No opinion.
return AccessResult::create();
}
else {
/** @var \Drupal\Core\Access\AccessResultInterface $result */
$result = array_shift($results);
foreach ($results as $other) {
$result->andIf($other);
}
return $result;
}
}
/**
......@@ -285,29 +301,23 @@ protected function checkAll(array $checks, Route $route, Request $request, Accou
* @param \Drupal\Core\Session\AccountInterface $account
* The current user.
*
* @return bool
* Returns TRUE if the user has access to the route, else FALSE.
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*
* @see \Drupal\Core\Access\AccessResultInterface::orIf()
*/
protected function checkAny(array $checks, $route, $request, AccountInterface $account) {
// No checks == deny by default.
$access = FALSE;
// No opinion by default.
$result = AccessResult::create();
foreach ($checks as $service_id) {
if (empty($this->checks[$service_id])) {
$this->loadCheck($service_id);
}
$service_access = $this->performCheck($service_id, $route, $request, $account);
if ($service_access === AccessInterface::ALLOW) {
$access = TRUE;
}
if ($service_access === AccessInterface::KILL) {
return FALSE;
}
$result = $result->orIf($this->performCheck($service_id, $route, $request, $account));
}
return $access;
return $result;
}
/**
......@@ -325,16 +335,17 @@ protected function checkAny(array $checks, $route, $request, AccountInterface $a
* @throws \Drupal\Core\Access\AccessException
* Thrown when the access check returns an invalid value.
*
* @return string
* A \Drupal\Core\Access\AccessInterface constant value.
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*/
protected function performCheck($service_id, $route, $request, $account) {
$callable = array($this->checks[$service_id], $this->checkMethods[$service_id]);
$arguments = $this->argumentsResolver->getArguments($callable, $route, $request, $account);
/** @var \Drupal\Core\Access\AccessResultInterface $service_access **/
$service_access = call_user_func_array($callable, $arguments);
if (!in_array($service_access, array(AccessInterface::ALLOW, AccessInterface::DENY, AccessInterface::KILL), TRUE)) {
throw new AccessException("Access error in $service_id. Access services can only return AccessInterface::ALLOW, AccessInterface::DENY, or AccessInterface::KILL constants.");
if (!$service_access instanceof AccessResultInterface) {
throw new AccessException("Access error in $service_id. Access services must return an object that implements AccessResultInterface.");
}
return $service_access;
......
......@@ -18,20 +18,17 @@
interface AccessManagerInterface {
/**
* All access checkers have to return AccessInterface::ALLOW.
* All access checkers must return an AccessResultInterface object where
* ::isAllowed() is TRUE.
*
* self::ACCESS_MODE_ALL is the default behavior.
*
* @see \Drupal\Core\Access\AccessInterface::ALLOW
*/
const ACCESS_MODE_ALL = 'ALL';
/**
* At least one access checker has to return AccessInterface::ALLOW
* and none should return AccessInterface::KILL.
*
* @see \Drupal\Core\Access\AccessInterface::ALLOW
* @see \Drupal\Core\Access\AccessInterface::KILL
* At least one access checker must return an AccessResultInterface object
* where ::isAllowed() is TRUE and none may return one where ::isForbidden()
* is TRUE.
*/
const ACCESS_MODE_ANY = 'ANY';
......@@ -43,18 +40,24 @@ interface AccessManagerInterface {
* @param string $route_name
* The route to check access to.
* @param array $parameters
* Optional array of values to substitute into the route path patern.
* Optional array of values to substitute into the route path pattern.
* @param \Drupal\Core\Session\AccountInterface $account
* (optional) Run access checks for this account. Defaults to the current
* user.
* @param \Symfony\Component\HttpFoundation\Request $route_request
* Optional incoming request object. If not provided, one will be built
* using the route information and the current request from the container.
* @param bool $return_as_object
* (optional) Defaults to FALSE.
*
* @return bool
* Returns TRUE if the user has access to the route, otherwise FALSE.
* @return bool|\Drupal\Core\Access\AccessResultInterface
* The access result. Returns a boolean if $return_as_object is FALSE (this
* is the default) and otherwise an AccessResultInterface object.
* When a boolean is returned, the result of AccessInterface::isAllowed() is
* returned, i.e. TRUE means access is explicitly allowed, FALSE means
* access is either explicitly forbidden or "no opinion".
*/
public function checkNamedRoute($route_name, array $parameters = array(), AccountInterface $account = NULL, Request $route_request = NULL);
public function checkNamedRoute($route_name, array $parameters = array(), AccountInterface $account = NULL, Request $route_request = NULL, $return_as_object = FALSE);
/**
* For each route, saves a list of applicable access checks to the route.
......@@ -89,10 +92,16 @@ public function addCheckService($service_id, $service_method, array $applies_che
* @param \Drupal\Core\Session\AccountInterface $account
* (optional) Run access checks for this account. Defaults to the current
* user.
* @param bool $return_as_object
* (optional) Defaults to FALSE.
*
* @return bool
* Returns TRUE if the user has access to the route, otherwise FALSE.
* @return bool|\Drupal\Core\Access\AccessResultInterface
* The access result. Returns a boolean if $return_as_object is FALSE (this
* is the default) and otherwise an AccessResultInterface object.
* When a boolean is returned, the result of AccessInterface::isAllowed() is
* returned, i.e. TRUE means access is explicitly allowed, FALSE means
* access is either explicitly forbidden or "no opinion".
*/
public function check(Route $route, Request $request, AccountInterface $account = NULL);
public function check(Route $route, Request $request, AccountInterface $account = NULL, $return_as_object = FALSE);
}
<?php
/**
* @file
* Contains \Drupal\Core\Access\AccessResult.
*/
namespace Drupal\Core\Access;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheableInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Session\AccountInterface;
/**
* Value object for passing an access result with cacheability metadata.
*
* When using ::orIf() and ::andIf(), cacheability metadata will be merged
* accordingly as well.
*/
class AccessResult implements AccessResultInterface, CacheableInterface {
/**
* The value that explicitly allows access.
*/
const ALLOW = 'ALLOW';
/**
* The value that neither explicitly allows nor explicitly forbids access.
*/
const DENY = 'DENY';
/**
* The value that explicitly forbids access.
*/
const KILL = 'KILL';
/**
* The access result value.
*
* A \Drupal\Core\Access\AccessResultInterface constant value.
*
* @var string
*/
protected $value;
/**
* Whether the access result is cacheable.
*
* @var bool
*/
protected $isCacheable;
/**
* The cache context IDs (to vary a cache item ID based on active contexts).
*
* @see \Drupal\Core\Cache\CacheContextInterface
* @see \Drupal\Core\Cache\CacheContexts::convertTokensToKeys()
*
* @var string[]
*/
protected $contexts;
/**
* The cache tags.
*
* @var array
*/
protected $tags;
/**
* The maximum caching time in seconds.
*
* @var int
*/
protected $maxAge;
/**
* Constructs a new AccessResult object.
*/
public function __construct() {
$this->resetAccess();
$this->setCacheable(TRUE)
->resetCacheContexts()
->resetCacheTags()
// Typically, cache items are invalidated via associated cache tags, not
// via a maximum age.
->setCacheMaxAge(Cache::PERMANENT);
}
/**
* Instantiates a new AccessResult object.
*
* This factory method exists to improve DX; it allows developers to fluently
* create access results.
*
* Defaults to a cacheable access result that neither explicitly allows nor
* explicitly forbids access.
*
* @return \Drupal\Core\Access\AccessResult
*/
public static function create() {
return new static();
}
/**
* Convenience method, creates an AccessResult object and calls allow().
*
* @return \Drupal\Core\Access\AccessResult
*/
public static function allowed() {
return static::create()->allow();
}
/**
* Convenience method, creates an AccessResult object and calls forbid().
*
* @return \Drupal\Core\Access\AccessResult
*/
public static function forbidden() {
return static::create()->forbid();
}
/**
* Convenience method, creates an AccessResult object and calls allowIf().
*
* @param bool $condition
* The condition to evaluate. If TRUE, ::allow() will be called.
*
* @return \Drupal\Core\Access\AccessResult
*/
public static function allowedIf($condition) {
return static::create()->allowIf($condition);
}
/**
* Convenience method, creates an AccessResult object and calls forbiddenIf().
*
* @param bool $condition
* The condition to evaluate. If TRUE, ::forbid() will be called.
*
* @return \Drupal\Core\Access\AccessResult
*/
public static function forbiddenIf($condition) {
return static::create()->forbidIf($condition);
}
/**
* Convenience method, creates an AccessResult object and calls allowIfHasPermission().
*
* @param \Drupal\Core\Session\AccountInterface $account
* The account for which to check a permission.
* @param string $permission
* The permission to check for.
*
* @return \Drupal\Core\Access\AccessResult
*/
public static function allowedIfHasPermission(AccountInterface $account, $permission) {
return static::create()->allowIfHasPermission($account, $permission);
}
/**
* {@inheritdoc}
*/
public function isAllowed() {
return $this->value === static::ALLOW;
}
/**
* {@inheritdoc}
*/
public function isForbidden() {
return $this->value === static::KILL;
}
/**
* Explicitly allows access.
*
* @return $this
*/
public function allow() {
$this->value = static::ALLOW;
return $this;
}
/**
* Explicitly forbids access.
*
* @return $this
*/
public function forbid() {
$this->value = static::KILL;
return $this;
}
/**
* Neither explicitly allows nor explicitly forbids access.
*
* @return $this
*/
public function resetAccess() {
$this->value = static::DENY;
return $this;
}
/**
* Conditionally calls ::allow().
*
* @param bool $condition
* The condition to evaluate. If TRUE, ::allow() will be called.
*
* @return $this
*/
public function allowIf($condition) {
if ($condition) {
$this->allow();
}
return $this;
}
/**
* Conditionally calls ::forbid().
*
* @param bool $condition
* The condition to evaluate. If TRUE, ::forbid() will be called.
*
* @return $this
*/
public function forbidIf($condition) {
if ($condition) {
$this->forbid();
}
return $this;
}
/**
* {@inheritdoc}
*
* AccessResult objects solely return cache context tokens, no static strings.
*/
public function getCacheKeys() {
sort($this->contexts);
return $this->contexts;
}
/**
* {@inheritdoc}
*/
public function getCacheTags() {
return $this->tags;
}
/**
* {@inheritdoc}
*
* It's not very useful to cache individual access results, but the interface
* forces us to implement this method, so just use the default cache bin.
*/
public function getCacheBin() {
return 'default';
}
/**
* {@inheritdoc}
*/
public function getCacheMaxAge() {
return $this->maxAge;
}
/**
* {@inheritdoc}
*/
public function isCacheable() {
return $this->isCacheable;
}
/**
* Sets whether this access result is cacheable. It is cacheable by default.
*
* @param bool $is_cacheable
* Whether this access result is cacheable.
*
* @return $this
*/
public function setCacheable($is_cacheable) {
$this->isCacheable = $is_cacheable;
return $this;
}
/**
* Adds cache contexts associated with the access result.
*
* @param string[] $contexts
* An array of cache context IDs, used to generate a cache ID.
*
* @return $this
*/
public function addCacheContexts(array $contexts) {
$this->contexts = array_unique(array_merge($this->contexts, $contexts));
return $this;
}
/**
* Resets cache contexts (to the empty array).
*
* @return $this
*/
public function resetCacheContexts() {
$this->contexts = array();
return $this;
}
/**
* Adds cache tags associated with the access result.
*
* @param array $tags
* An array of cache tags.
*
* @return $this
*/
public function addCacheTags(array $tags) {
foreach ($tags as $namespace => $values) {
if (is_array($values)) {
foreach ($values as $value) {