diff --git a/jsonapi.services.yml b/jsonapi.services.yml index 2804940..126ffc1 100644 --- a/jsonapi.services.yml +++ b/jsonapi.services.yml @@ -51,6 +51,11 @@ services: arguments: ['@current_user'] tags: - { name: normalizer, priority: 2 } + serializer.normalizer.entity_access_denied_exception.jsonapi: + class: Drupal\jsonapi\Normalizer\EntityAccessDeniedHttpExceptionNormalizer + arguments: ['@current_user'] + tags: + - { name: normalizer, priority: 2 } serializer.encoder.jsonapi: class: Drupal\jsonapi\Encoder\JsonEncoder tags: diff --git a/src/Controller/EntityResource.php b/src/Controller/EntityResource.php index eab187e..c2a80ed 100644 --- a/src/Controller/EntityResource.php +++ b/src/Controller/EntityResource.php @@ -4,6 +4,7 @@ namespace Drupal\jsonapi\Controller; use Drupal\Component\Serialization\Json; use Drupal\Core\Access\AccessibleInterface; +use Drupal\Core\Access\AccessResultReasonInterface; use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Config\Entity\ConfigEntityInterface; use Drupal\Core\Entity\ContentEntityInterface; @@ -15,6 +16,7 @@ use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Field\EntityReferenceFieldItemListInterface; use Drupal\Core\Field\FieldTypePluginManagerInterface; use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem; +use Drupal\jsonapi\Error\EntityAccessDeniedHttpException; use Drupal\jsonapi\Resource\EntityCollection; use Drupal\jsonapi\Resource\JsonApiDocumentTopLevel; use Drupal\jsonapi\ResourceType\ResourceType; @@ -117,7 +119,15 @@ class EntityResource { public function getIndividual(EntityInterface $entity, Request $request, $response_code = 200) { $entity_access = $entity->access('view', NULL, TRUE); if (!$entity_access->isAllowed()) { - throw new SerializableHttpException(403, 'The current user is not allowed to GET the selected resource.'); + $exception = new EntityAccessDeniedHttpException(403, 'The current user is not allowed to GET the selected resource.'); + if ($entity_access instanceof AccessResultReasonInterface) { + $exception->addError($entity, '/data', $entity_access->getReason()); + } + else { + $exception->addError($entity, '/data'); + } + + throw $exception; } $response = $this->buildWrappedResponse($entity, $response_code); return $response; @@ -169,7 +179,15 @@ class EntityResource { $entity_access = $entity->access('create', NULL, TRUE); if (!$entity_access->isAllowed()) { - throw new SerializableHttpException(403, 'The current user is not allowed to POST the selected resource.'); + $exception = new EntityAccessDeniedHttpException(403, 'The current user is not allowed to POST the selected resource.'); + if ($entity_access instanceof AccessResultReasonInterface) { + $exception->addError($entity, '/data', $entity_access->getReason()); + } + else { + $exception->addError($entity, '/data'); + } + + throw $exception; } $this->validate($entity); $entity->save(); @@ -192,7 +210,15 @@ class EntityResource { public function patchIndividual(EntityInterface $entity, EntityInterface $parsed_entity, Request $request) { $entity_access = $entity->access('update', NULL, TRUE); if (!$entity_access->isAllowed()) { - throw new SerializableHttpException(403, 'The current user is not allowed to PATCH the selected resource.'); + $exception = new EntityAccessDeniedHttpException(403, 'The current user is not allowed to PATCH the selected resource.'); + if ($entity_access instanceof AccessResultReasonInterface) { + $exception->addError($entity, '/data', $entity_access->getReason()); + } + else { + $exception->addError($entity, '/data'); + } + + throw $exception; } $body = Json::decode($request->getContent()); $data = $body['data']; @@ -386,7 +412,16 @@ class EntityResource { $field_access = $field_list->access('edit', NULL, TRUE); if (!$field_access->isAllowed()) { - throw new SerializableHttpException(403, sprintf('The current user is not allowed to PATCH the selected field (%s).', $field_list->getName())); + $field_name = $field_list->getName(); + $exception = new EntityAccessDeniedHttpException(403, sprintf('The current user is not allowed to PATCH the selected field (%s).', $field_name)); + if ($field_access instanceof AccessResultReasonInterface) { + $exception->addError($entity, '/data/attributes/' . $field_name, $field_access->getReason()); + } + else { + $exception->addError($entity, '/data/attributes/' . $field_name); + } + + throw $exception; } // Time to save the relationship. foreach ($parsed_field_list as $field_item) { @@ -501,7 +536,15 @@ class EntityResource { $field_name = $parsed_field_list->getName(); $field_access = $parsed_field_list->access('edit', NULL, TRUE); if (!$field_access->isAllowed()) { - throw new SerializableHttpException(403, sprintf('The current user is not allowed to PATCH the selected field (%s).', $field_name)); + $exception = new EntityAccessDeniedHttpException(403, sprintf('The current user is not allowed to PATCH the selected field (%s).', $field_name)); + if ($field_access instanceof AccessResultReasonInterface) { + $exception->addError($entity, '/data/attributes/' . $field_name, $field_access->getReason()); + } + else { + $exception->addError($entity, '/data/attributes/' . $field_name); + } + + throw $exception; } /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field_list */ $field_list = $entity->{$related_field}; @@ -623,7 +666,14 @@ class EntityResource { /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $parsed_field_list */ $entity_access = $entity->access('update', NULL, TRUE); if (!$entity_access->isAllowed()) { - throw new SerializableHttpException(403, 'The current user is not allowed to update the selected resource.'); + $exception = new EntityAccessDeniedHttpException(403, 'The current user is not allowed to update the selected resource.'); + if ($entity_access instanceof AccessResultReasonInterface) { + $exception->addError($entity, $related_field, $entity_access->getReason()); + } + else { + $exception->addError($entity, $related_field); + } + throw $exception; } if (!($field_list = $entity->get($related_field)) || !$this->isRelationshipField($field_list)) { throw new SerializableHttpException(404, sprintf('The relationship %s is not present in this resource.', $related_field)); @@ -652,7 +702,15 @@ class EntityResource { if ($destination_field_list->getValue() != $origin_field_list->getValue()) { $field_access = $destination_field_list->access('edit', NULL, TRUE); if (!$field_access->isAllowed()) { - throw new SerializableHttpException(403, sprintf('The current user is not allowed to PATCH the selected field (%s).', $destination_field_list->getName())); + $exception = new EntityAccessDeniedHttpException(403, sprintf('The current user is not allowed to PATCH the selected field (%s).', $destination_field_list->getName())); + if ($field_access instanceof AccessResultReasonInterface) { + $exception->addError($destination, '/data/attributes/' . $field_name, $field_access->getReason()); + } + else { + $exception->addError($destination, '/data/attributes/' . $field_name); + } + + throw $exception; } $destination->{$field_name} = $origin->get($field_name); } diff --git a/src/Error/EntityAccessDeniedHttpException.php b/src/Error/EntityAccessDeniedHttpException.php new file mode 100644 index 0000000..1816cc3 --- /dev/null +++ b/src/Error/EntityAccessDeniedHttpException.php @@ -0,0 +1,63 @@ +entities[] = $entity; + $this->pointers[] = $pointer; + $this->reasons[] = $reason; + + return $this; + } + + public function getErrors() { + return array_map(function ($index) { + return [ + $this->entities[$index], + $this->pointers[$index], + $this->reasons[$index], + ]; + }, array_keys($this->entities)); + } + +} diff --git a/src/Normalizer/EntityAccessDeniedHttpExceptionNormalizer.php b/src/Normalizer/EntityAccessDeniedHttpExceptionNormalizer.php new file mode 100644 index 0000000..e04967a --- /dev/null +++ b/src/Normalizer/EntityAccessDeniedHttpExceptionNormalizer.php @@ -0,0 +1,47 @@ +getErrors() as list($entity, $field_name, $reason)) { + /** @var \Drupal\Core\Entity\EntityInterface $entity */ + $error['id'] = $entity->uuid(); + + // @todo Figure out how to support relationships. + $error['source']['pointer'] = $field_name ? '/data/relationships/' . $field_name : '/data'; + + if ($reason) { + $error['detail']['message'] = $reason; + } + + $errors[] = $error; + } + + return $errors; + } + +} diff --git a/tests/src/Functional/JsonApiFunctionalTest.php b/tests/src/Functional/JsonApiFunctionalTest.php index 87fdadb..0deb97c 100644 --- a/tests/src/Functional/JsonApiFunctionalTest.php +++ b/tests/src/Functional/JsonApiFunctionalTest.php @@ -482,6 +482,8 @@ class JsonApiFunctionalTest extends BrowserTestBase { $created_response = Json::decode($response->getBody()->__toString()); $this->assertEquals(403, $response->getStatusCode()); $this->assertNotEmpty($created_response['errors']); + $this->assertEmpty($created_response['errors'][0]['id']); + $this->assertEquals(403, $created_response['errors'][0]['status']); $this->assertEquals('Forbidden', $created_response['errors'][0]['title']); // 3. Missing Content-Type error. $response = $this->request('POST', $collection_url, [ @@ -564,6 +566,10 @@ class JsonApiFunctionalTest extends BrowserTestBase { $updated_response = Json::decode($response->getBody()->__toString()); $this->assertEquals(403, $response->getStatusCode()); $this->assertEquals('The current user is not allowed to PATCH the selected field (status).', $updated_response['errors'][0]['detail']); + $this->assertEquals($uuid, $created_response['errors'][0]['id']); + $this->assertEquals(['pointer' => '/data'], $created_response['errors'][0]['source']); + $this->assertEquals(403, $created_response['errors'][0]['status']); + $node = \Drupal::entityManager()->loadEntityByUuid('node', $uuid); $this->assertEquals(1, $node->get('status')->value, 'Node status was not changed.'); // 9. Successful POST to related endpoint.