Commit ff035fc4 authored by alexpott's avatar alexpott
Browse files

Issue #2340507 by chx, Wim Leers: Make the new AccessResult API and implementation even better.

parent 9ff1ef39
......@@ -229,7 +229,7 @@ public function check(RouteMatchInterface $route_match, AccountInterface $accoun
$checks = array_diff($checks, $this->checkNeedsRequest);
}
$result = AccessResult::create();
$result = AccessResult::neutral();
if (!empty($checks)) {
$arguments_resolver = $this->argumentsResolverFactory->getArgumentsResolver($route_match, $account, $request);
if ($conjunction == static::ACCESS_MODE_ALL) {
......@@ -256,34 +256,18 @@ public function check(RouteMatchInterface $route_match, AccountInterface $accoun
* @see \Drupal\Core\Access\AccessResultInterface::andIf()
*/
protected function checkAll(array $checks, ArgumentsResolverInterface $arguments_resolver) {
$results = array();
// Without checks no opinion can be formed.
if (!$checks) {
return AccessResult::neutral();
}
$result = AccessResult::allowed();
foreach ($checks as $service_id) {
if (empty($this->checks[$service_id])) {
$this->loadCheck($service_id);
}
$result = $this->performCheck($service_id, $arguments_resolver);
$results[] = $result;
// Stop as soon as the first non-allowed check is encountered.
if (!$result->isAllowed()) {
break;
}
}
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;
$result = $result->andIf($this->performCheck($service_id, $arguments_resolver));
}
return $result;
}
/**
......@@ -301,7 +285,7 @@ protected function checkAll(array $checks, ArgumentsResolverInterface $arguments
*/
protected function checkAny(array $checks, ArgumentsResolverInterface $arguments_resolver) {
// No opinion by default.
$result = AccessResult::create();
$result = AccessResult::neutral();
foreach ($checks as $service_id) {
if (empty($this->checks[$service_id])) {
......
......@@ -14,34 +14,18 @@
/**
* Value object for passing an access result with cacheability metadata.
*
* The access result itself — excluding the cacheability metadata — is
* immutable. There are subclasses for each of the three possible access results
* themselves:
*
* @see \Drupal\Core\Access\AccessResultAllowed
* @see \Drupal\Core\Access\AccessResultForbidden
* @see \Drupal\Core\Access\AccessResultNeutral
*
* 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;
abstract class AccessResult implements AccessResultInterface, CacheableInterface {
/**
* Whether the access result is cacheable.
......@@ -78,7 +62,6 @@ class AccessResult implements AccessResultInterface, CacheableInterface {
* Constructs a new AccessResult object.
*/
public function __construct() {
$this->resetAccess();
$this->setCacheable(TRUE)
->resetCacheContexts()
->resetCacheTags()
......@@ -88,64 +71,67 @@ public function __construct() {
}
/**
* 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.
* Creates an AccessResultInterface object with isNeutral() === TRUE.
*
* @return \Drupal\Core\Access\AccessResult
* isNeutral() will be TRUE.
*/
public static function create() {
return new static();
public static function neutral() {
return new AccessResultNeutral();
}
/**
* Convenience method, creates an AccessResult object and calls allow().
* Creates an AccessResultInterface object with isAllowed() === TRUE.
*
* @return \Drupal\Core\Access\AccessResult
* isAllowed() will be TRUE.
*/
public static function allowed() {
return static::create()->allow();
return new AccessResultAllowed();
}
/**
* Convenience method, creates an AccessResult object and calls forbid().
* Creates an AccessResultInterface object with isForbidden() === TRUE.
*
* @return \Drupal\Core\Access\AccessResult
* isForbidden() will be TRUE.
*/
public static function forbidden() {
return static::create()->forbid();
return new AccessResultForbidden();
}
/**
* Convenience method, creates an AccessResult object and calls allowIf().
* Creates an allowed or neutral access result.
*
* @param bool $condition
* The condition to evaluate. If TRUE, ::allow() will be called.
* The condition to evaluate.
*
* @return \Drupal\Core\Access\AccessResult
* If $condition is TRUE, isAllowed() will be TRUE, otherwise isNeutral()
* will be TRUE.
*/
public static function allowedIf($condition) {
return static::create()->allowIf($condition);
return $condition ? static::allowed() : static::neutral();
}
/**
* Convenience method, creates an AccessResult object and calls forbiddenIf().
* Creates a forbidden or neutral access result.
*
* @param bool $condition
* The condition to evaluate. If TRUE, ::forbid() will be called.
* The condition to evaluate.
*
* @return \Drupal\Core\Access\AccessResult
* If $condition is TRUE, isForbidden() will be TRUE, otherwise isNeutral()
* will be TRUE.
*/
public static function forbiddenIf($condition) {
return static::create()->forbidIf($condition);
return $condition ? static::forbidden(): static::neutral();
}
/**
* Convenience method, creates an AccessResult object and calls allowIfHasPermission().
* Creates an allowed access result if the permission is present, neutral otherwise.
*
* Convenience method, checks the permission and calls ::cachePerRole().
*
* @param \Drupal\Core\Session\AccountInterface $account
* The account for which to check a permission.
......@@ -153,83 +139,38 @@ public static function forbiddenIf($condition) {
* The permission to check for.
*
* @return \Drupal\Core\Access\AccessResult
* If the account has the permission, isAlowed() will be TRUE, otherwise
* isNeutral() will be TRUE.
*/
public static function allowedIfHasPermission(AccountInterface $account, $permission) {
return static::create()->allowIfHasPermission($account, $permission);
return static::allowedIf($account->hasPermission($permission))->cachePerRole();
}
/**
* {@inheritdoc}
*
* @see \Drupal\Core\Access\AccessResultAllowed
*/
public function isAllowed() {
return $this->value === static::ALLOW;
return FALSE;
}
/**
* {@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
* @see \Drupal\Core\Access\AccessResultForbidden
*/
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;
public function isForbidden() {
return FALSE;
}
/**
* Conditionally calls ::forbid().
*
* @param bool $condition
* The condition to evaluate. If TRUE, ::forbid() will be called.
* {@inheritdoc}
*
* @return $this
* @see \Drupal\Core\Access\AccessResultNeutral
*/
public function forbidIf($condition) {
if ($condition) {
$this->forbid();
}
return $this;
public function isNeutral() {
return FALSE;
}
/**
......@@ -391,77 +332,89 @@ public function cacheUntilEntityChanges(EntityInterface $entity) {
return $this;
}
/**
* Convenience method, checks permission and calls ::cachePerRole().
*
* @param \Drupal\Core\Session\AccountInterface $account
* The account for which to check a permission.
* @param string $permission
* The permission to check for.
*
* @return $this
*/
public function allowIfHasPermission(AccountInterface $account, $permission) {
$this->allowIf($account->hasPermission($permission))->cachePerRole();
return $this;
}
/**
* {@inheritdoc}
*/
public function orIf(AccessResultInterface $other) {
// If this AccessResult already is forbidden, then that already is the
// conclusion. We can completely disregard $other.
if ($this->isForbidden()) {
return $this;
// $other's cacheability metadata is merged if $merge_other gets set to TRUE
// and this happens in two cases:
// 1. $other's access result is the one that determines the combined access
// result.
// 2. This access result is not cacheable and $other's access result is the
// same. i.e. attempt to return a cacheable access result.
$merge_other = FALSE;
if ($this->isForbidden() || $other->isForbidden()) {
$result = static::forbidden();
if (!$this->isForbidden() || (!$this->isCacheable() && $other->isForbidden())) {
$merge_other = TRUE;
}
}
// Otherwise, we make this AccessResult forbidden if the other is, or
// allowed if the other is, and we merge in the cacheability metadata if the
// other access result also has cacheability metadata.
else {
if ($other->isForbidden()) {
$this->forbid();
elseif ($this->isAllowed() || $other->isAllowed()) {
$result = static::allowed();
if (!$this->isAllowed() || (!$this->isCacheable() && $other->isAllowed())) {
$merge_other = TRUE;
}
else if ($other->isAllowed()) {
$this->allow();
}
else {
$result = static::neutral();
if (!$this->isNeutral() || (!$this->isCacheable() && $other->isNeutral())) {
$merge_other = TRUE;
}
$this->mergeCacheabilityMetadata($other);
return $this;
}
$result->inheritCacheability($this);
if ($merge_other) {
$result->inheritCacheability($other);
}
return $result;
}
/**
* {@inheritdoc}
*/
public function andIf(AccessResultInterface $other) {
// If this AccessResult already is forbidden or is merely not explicitly
// allowed, then that already is the conclusion. We can completely disregard
// $other.
if ($this->isForbidden() || !$this->isAllowed()) {
return $this;
// The other access result's cacheability metadata is merged if $merge_other
// gets set to TRUE. It gets set to TRUE in one case: if the other access
// result is used.
$merge_other = FALSE;
if ($this->isForbidden() || $other->isForbidden()) {
$result = static::forbidden();
if (!$this->isForbidden()) {
$merge_other = TRUE;
}
}
elseif ($this->isAllowed() && $other->isAllowed()) {
$result = static::allowed();
$merge_other = TRUE;
}
// Otherwise, we make this AccessResult forbidden if the other is, or not
// explicitly allowed if the other isn't, and we merge in the cacheability
// metadata if the other access result also has cacheability metadata.
else {
if ($other->isForbidden()) {
$this->forbid();
$result = static::neutral();
if (!$this->isNeutral()) {
$merge_other = TRUE;
}
else if (!$other->isAllowed()) {
$this->resetAccess();
}
$result->inheritCacheability($this);
if ($merge_other) {
$result->inheritCacheability($other);
// If this access result is not cacheable, then an AND with another access
// result must also not be cacheable, except if the other access result
// has isForbidden() === TRUE. isForbidden() access results are contagious
// in that they propagate regardless of the other value.
if (!$this->isCacheable() && !$result->isForbidden()) {
$result->setCacheable(FALSE);
}
$this->mergeCacheabilityMetadata($other);
return $this;
}
return $result;
}
/**
* Merges the cacheability metadata of the other access result, if any.
* Inherits the cacheability of the other access result, if any.
*
* @param \Drupal\Core\Access\AccessResultInterface $other
* The other access result, whose cacheability data (if any) to merge.
* The other access result, whose cacheability (if any) to inherit.
*
* @return $this
*/
protected function mergeCacheabilityMetadata(AccessResultInterface $other) {
public function inheritCacheability(AccessResultInterface $other) {
if ($other instanceof CacheableInterface) {
$this->setCacheable($other->isCacheable());
$this->addCacheContexts($other->getCacheKeys());
......@@ -481,6 +434,7 @@ protected function mergeCacheabilityMetadata(AccessResultInterface $other) {
else {
$this->setCacheable(FALSE);
}
return $this;
}
}
<?php
/**
* @file
* Contains \Drupal\Core\Access\AccessResultAllowed.
*/
namespace Drupal\Core\Access;
/**
* Value object indicating an allowed access result, with cacheability metadata.
*/
class AccessResultAllowed extends AccessResult {
/**
* {@inheritdoc}
*/
public function isAllowed() {
return TRUE;
}
}
<?php
/**
* @file
* Contains \Drupal\Core\Access\AccessResultForbidden.
*/
namespace Drupal\Core\Access;
/**
* Value object indicating a forbidden access result, with cacheability metadata.
*/
class AccessResultForbidden extends AccessResult {
/**
* {@inheritdoc}
*/
public function isForbidden() {
return TRUE;
}
}
......@@ -23,12 +23,10 @@
* would never enter the else-statement and hence introduce a critical security
* issue.
*
* Note: you can check whether access is neither explicitly allowed nor
* explicitly forbidden:
*
* @code
* $no_opinion = !$access->isAllowed() && !$access->isForbidden();
* @endcode
* Objects implementing this interface are using Kleene's weak three-valued
* logic with the isAllowed() state being TRUE, the isForbidden() state being
* the intermediate truth value and isNeutral() being FALSE. See
* http://en.wikipedia.org/wiki/Many-valued_logic for more.
*/
interface AccessResultInterface {
......@@ -36,43 +34,56 @@ interface AccessResultInterface {
* Checks whether this access result indicates access is explicitly allowed.
*
* @return bool
* When TRUE then isForbidden() and isNeutral() are FALSE.
*/
public function isAllowed();
/**
* Checks whether this access result indicates access is explicitly forbidden.
*
* This is a kill switch — both orIf() and andIf() will result in
* isForbidden() if either results are isForbidden().
*
* @return bool
* When TRUE then isAllowed() and isNeutral() are FALSE.
*/
public function isForbidden();
/**
* Checks whether this access result indicates access is not yet determined.
*
* @return bool
* When TRUE then isAllowed() and isForbidden() are FALSE.
*/
public function isNeutral();
/**
* Combine this access result with another using OR.
*
* When OR-ing two access results, the result is:
* - isForbidden() in either ⇒ isForbidden()
* - isAllowed() in either ⇒ isAllowed()
* - neither isForbidden() nor isAllowed() => !isAllowed() && !isForbidden()
* - otherwise if isAllowed() in either ⇒ isAllowed()
* - otherwise both must be isNeutral() ⇒ isNeutral()
*
* @param \Drupal\Core\Access\AccessResultInterface $other
* The other access result to OR this one with.
*
* @return $this
* @return static
*/
public function orIf(AccessResultInterface $other);
/**
* Combine this access result with another using AND.
*
* When OR-ing two access results, the result is:
* When AND-ing two access results, the result is:
* - isForbidden() in either ⇒ isForbidden()
* - isAllowed() in both ⇒ isAllowed()
* - neither isForbidden() nor isAllowed() => !isAllowed() && !isForbidden()
* - otherwise, if isAllowed() in both ⇒ isAllowed()
* - otherwise, one of them is isNeutral() ⇒ isNeutral()
*
* @param \Drupal\Core\Access\AccessResultInterface $other
* The other access result to AND this one with.
*
* @return $this
* @return static
*/
public function andIf(AccessResultInterface $other);
......
<?php
/**
* @file
* Contains \Drupal\Core\Access\AccessResultNeutral.
*/
namespace Drupal\Core\Access;
/**
* Value object indicating a neutral access result, with cacheability metadata.
*/
class AccessResultNeutral extends AccessResult {
/**
* {@inheritdoc}
*/
public function isNeutral() {
return TRUE;
}
}
......@@ -49,17 +49,16 @@ public function __construct(CsrfTokenGenerator $csrf_token) {
* The access result.
*/
public function access(Route $route, Request $request) {
// Not cacheable because the CSRF token is highly dynamic.
$access = AccessResult::create()->setCacheable(FALSE);
// @todo Remove dependency on the internal _system_path attribute:
// https://www.drupal.org/node/2293501.
if ($this->csrfToken->validate($request->query->get('token'), $request->attributes->get('_system_path'))) {
$access->allow();
$result = AccessResult::allowed();
}
else {
$access->forbid();
$result = AccessResult::forbidden();
}
return $access;
// Not cacheable because the CSRF token is highly dynamic.
return $result->setCacheable(FALSE);
}
}
......@@ -32,7 +32,7 @@ public function access(Route $route) {
return AccessResult::forbidden();