Change record status: 
Project: 
Introduced in branch: 
8.3.x
Introduced in version: 
8.3.0
Description: 

Problem

When trying to read or modify data via Drupal 8 core's REST module (or via contrib modules such as the JSON API module), it's possible that you get a 403 response. Because you are not authorized to perform that particular operation.

#2772413: REST GET fails on entity/taxonomy_vocabulary/{id} 403 Forbidden with error uncovered that it can be very difficult even for a Drupal expert to figure out why you're getting a 403 for a particular REST resource. And … we want Drupal to be adopted as the storage back-end by JavaScript developers — i.e. by non-Drupalists. How are they going to figure this out?

We need to ensure that Drupal 8 HTTP API consumers are able to be successful without having to know every detail of Drupal, and without needing to resort to a PHP debugger to step through the request flow to figure out why a 403 is being sent, only to figure out which configuration to change (which permission to grant for example).

Our 403 responses for REST routes need to list a reason for this 403.

Solution

A new \Drupal\Core\Access\AccessResultReasonInterface was added which extends AccessResultInterface. It is implemented by both AccessResultNeutral and AccessResultForbidden, which allow those value objects to carry the reason for not being allowed access.

AccessResult::allowedIfHasPermission() and AccessResult::allowedIfHasPermissions() now automatically set such reasons. This alone gets us about 80% of the way there: most access logic in Drupal is permission-based. For custom access logic, it's up to the author of that logic to provide a reason.

Recommendations for access control logic

Rather than

return AccessResultNeutral()->addCacheContexts(…);
…
return AccessResultForbidden()->addCacheContexts(…);

do:

return AccessResultNeutral('Only llamas are allowed to graze here.')->addCacheContexts(…);
…
return AccessResultForbidden('Kittens are never allowed on these premises.')->addCacheContexts(…);

i.e. you can provide a reason directly in the constructors of AccessResultNeutral and AccessResultForbidden.

And for more complex cases, don't do:

return AccessResult::allowedIf($account->hasPermission('access comments') && $entity->isPublished())
  ->cachePerPermissions()
  ->addCacheableDependency($entity);

but do:

$access = AccessResult::allowedIf($account->hasPermission('access comments') && $entity->isPublished())
  ->cachePerPermissions()
  ->addCacheableDependency($entity);
if ($access instanceof AccessResultReasonInterface) {
  $access->setReason("The 'access comments' permission is required and the comment must be published.");
}
return $access;

Recommendations for code throwing AccessDeniedHttpException

Rather than

    if (!$entity_access->isAllowed()) {
      throw new AccessDeniedHttpException();
    }

do:

    if (!$entity_access->isAllowed()) {
      throw new AccessDeniedHttpException($entity_access->getReason());
    }
Impacts: 
Module developers
Themers