diff --git a/jsonapi.services.yml b/jsonapi.services.yml index 9700e00..80f6782 100644 --- a/jsonapi.services.yml +++ b/jsonapi.services.yml @@ -143,6 +143,9 @@ services: jsonapi.entity_access_checker: class: Drupal\jsonapi\Access\EntityAccessChecker public: false + calls: + - [setNodeRevisionAccessCheck, ['@?access_check.node.revision']] # This is only injected when the service is available. + - [setMediaRevisionAccessCheck, ['@?access_check.media.revision']] # This is only injected when the service is available. access_check.jsonapi.relationship_field_access: class: Drupal\jsonapi\Access\RelationshipFieldAccess tags: diff --git a/src/Access/EntityAccessChecker.php b/src/Access/EntityAccessChecker.php index 527966a..23867f7 100644 --- a/src/Access/EntityAccessChecker.php +++ b/src/Access/EntityAccessChecker.php @@ -2,10 +2,19 @@ namespace Drupal\jsonapi\Access; +use Drupal\block_content\BlockContentInterface; +use Drupal\block_content\Entity\BlockContent; use Drupal\Core\Access\AccessResult; +use Drupal\Core\Access\AccessResultReasonInterface; use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\RevisionableInterface; +use Drupal\Core\Session\AccountInterface; use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException; use Drupal\jsonapi\LabelOnlyEntity; +use Drupal\media\Access\MediaRevisionAccessCheck; +use Drupal\media\MediaInterface; +use Drupal\node\Access\NodeRevisionAccessCheck; +use Drupal\node\NodeInterface; /** * Checks access to entities. @@ -18,24 +27,80 @@ use Drupal\jsonapi\LabelOnlyEntity; */ final class EntityAccessChecker { + /** + * The node revision access check service. + * + * This will be NULL unless the node module is enabled. + * + * @var \Drupal\node\Access\NodeRevisionAccessCheck|null + */ + protected $nodeRevisionAccessCheck = NULL; + + /** + * The media revision access check service. + * + * This will be NULL unless the media module is enabled. + * + * @var \Drupal\media\Access\MediaRevisionAccessCheck|null + */ + protected $mediaRevisionAccessCheck = NULL; + + /** + * Sets the node revision access check service. + * + * This is only called when node module is enabled. + * + * @param \Drupal\node\Access\NodeRevisionAccessCheck $node_revision_access_check + * The node revision access check service. + */ + public function setNodeRevisionAccessCheck(NodeRevisionAccessCheck $node_revision_access_check) { + $this->nodeRevisionAccessCheck = $node_revision_access_check; + } + + /** + * Sets the media revision access check service. + * + * This is only called when media module is enabled. + * + * @param \Drupal\media\Access\MediaRevisionAccessCheck $media_revision_access_check + * The media revision access check service. + */ + public function setMediaRevisionAccessCheck(MediaRevisionAccessCheck $media_revision_access_check) { + $this->mediaRevisionAccessCheck = $media_revision_access_check; + } + /** * Get the object to normalize and the access based on the provided entity. * * @param \Drupal\Core\Entity\EntityInterface $entity * The entity to test access for. + * @param \Drupal\Core\Session\AccountInterface $account + * (optional) The account with which access should be checked. Defaults to + * the current user. * * @return \Drupal\Core\Entity\EntityInterface|\Drupal\jsonapi\LabelOnlyEntity|\Drupal\jsonapi\Exception\EntityAccessDeniedHttpException * The loaded entity, a label only version of that entity or an * EntityAccessDeniedHttpException object if neither is accessible. All * three possible return values carry the access result cacheability. */ - public function getAccessCheckedEntity(EntityInterface $entity) { - /* @var \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository */ + public function getAccessCheckedEntity(EntityInterface $entity, AccountInterface $account = NULL) { + $account = $account ?: \Drupal::currentUser(); + /** @var \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository */ $entity_repository = \Drupal::service('entity.repository'); $entity = $entity_repository->getTranslationFromContext($entity, NULL, ['operation' => 'entity_upcast']); + $access = $entity->access('view', NULL, TRUE); // Ensure that access is respected for different entity revisions. - $access = AccessResult::neutral()->addCacheContexts(['url.query_args:resource_version']); - $access = $access->orIf($entity->access('view', NULL, TRUE)); + if ($entity instanceof RevisionableInterface) { + $access = AccessResult::neutral()->addCacheContexts(['url.query_args:resource_version'])->orIf($access); + if (!$entity->isDefaultRevision()) { + $revision_access = $this->checkRevisionAccess($entity, $account, 'view'); + $access = $access->andIf($revision_access); + // The revision access reason should trump the primary access reason. + if ($access instanceof AccessResultReasonInterface) { + $access->setReason($revision_access->getReason()); + } + } + } $entity->addCacheableDependency($access); if (!$access->isAllowed()) { $label_access = $entity->access('view label', NULL, TRUE); @@ -52,4 +117,59 @@ final class EntityAccessChecker { return $entity; } + /** + * Checks access to the given revision entity. + * + * This should only be called for non-default revisions. + * + * There is no standardized API for revision access checking in Drupal core + * and this method shims that missing API. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The revised entity for which to check access. + * + * @return \Drupal\Core\Access\AccessResultReasonInterface + * The access check result. + * + * @todo: remove this when a generic revision access API exists in Drupal core. + */ + protected function checkRevisionAccess(EntityInterface $entity, AccountInterface $account, $operation) { + assert($entity instanceof RevisionableInterface); + assert(!$entity->isDefaultRevision(), 'It is not necessary to check revision access when the entity is the default revision.'); + assert($operation === 'view', 'JSON API does not yet support mutable operations on revisions.'); + switch ($entity->getEntityTypeId()) { + case 'node': + assert($entity instanceof NodeInterface); + $access = AccessResult::allowedIf($this->nodeRevisionAccessCheck->checkAccess($entity, $account, $operation))->cachePerPermissions()->addCacheableDependency($entity); + break; + + case 'media': + assert($entity instanceof MediaInterface); + $access = AccessResult::allowedIf($this->mediaRevisionAccessCheck->checkAccess($entity, $account, $operation))->cachePerPermissions()->addCacheableDependency($entity); + break; + + case 'block_content': + assert($entity instanceof BlockContentInterface); + // There is no existing core mechanism to determine block content + // access. Therefore, emulate the rules that node and media share. + $access = AccessResult::allowedIfHasPermission($account, 'administer blocks'); + $access = $access->andIf($entity->access('view', $account, TRUE)); + $access = $access->andIf(BlockContent::load($entity->id())->access('view', $account, TRUE)); + break; + + default: + $reason = 'Revisions for non-core entity types are not yet supported by JSON API.'; + $access = AccessResult::forbidden($reason); + assert(FALSE, $reason); + } + if (!$access->isAllowed()) { + $reason = 'The user does not have access to the requested version.'; + if (($previous = $access->getReason()) && !empty($previous)) { + $reason .= " $previous"; + } + $access->setReason($reason); + } + return $access; + } + } diff --git a/tests/src/Functional/ResourceTestBase.php b/tests/src/Functional/ResourceTestBase.php index 8204fc9..cd945fd 100644 --- a/tests/src/Functional/ResourceTestBase.php +++ b/tests/src/Functional/ResourceTestBase.php @@ -2666,6 +2666,10 @@ abstract class ResourceTestBase extends BrowserTestBase { // Ensure that targeting a revision does not bypass access. $actual_response = $this->request('GET', $original_revision_id_url, $request_options); $expected_cacheability = $this->getExpectedUnauthorizedAccessCacheability(); + $message = 'The current user is not allowed to GET the selected resource. The user does not have access to the requested version.'; + if ($result instanceof AccessResultReasonInterface && ($reason = $result->getReason()) && !empty($reason)) { + $message .= ' ' . $reason; + } $this->assertResourceErrorResponse(403, $message, $url, $actual_response, '/data', $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS'); $this->setUpAuthorization('GET');