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());
}