Problem/Motivation

This is a sister issue of #2869426: EntityResource should add _entity_access requirement to REST routes, which is for the core rest.module. Unlike the REST module, the JSON API module only operates on a per-bundle basis. So not only can JSON API use _entity_access like the REST module, it can also use _entity_create_access unlike the REST module!

Proposed resolution

Use _entity_access and _entity_create_access, to move access checking for the individual endpoint out of the controller, into the routing system. This benefits performance and scalability.

But … because JSON API has a single "individual" route that does everything: GET, PATCH and DELETE. In other words: there's no way to set _entity_access: node.view for example, because that'd then also be applied for PATCH and DELETE, not just GET!
(Except we can't do GET -> <ENTITY_TYPE.view, because that prevents view label access checking — see #8.)

There are two possible solutions:

  1. Split up the individual route in 3 routes, each with only one HTTP method
  2. We could add a new access check that's like _entity_access_for_method: 'TRUE', which maps GET to 'view', PATCH to 'update' and DELETE to 'delete'.

Remaining tasks

TBD

User interface changes

None.

API changes

None.

Data model changes

None.

Comments

Wim Leers created an issue. See original summary.

wim leers’s picture

It'd look somewhat like this… but it currently can't work!

Because JSON API has a single "individual" route that does everything: GET, PATCH and DELETE. In other words: there's no way to set _entity_access: node.view for example, because that'd then also be applied for PATCH and DELETE, not just GET!

There are two possible solutions:

  1. Split up the individual route in 3 routes, each with only one HTTP method
  2. We could add a new access check that's like _entity_access_for_method: 'TRUE', which maps GET to 'view', PATCH to 'update' and DELETE to 'delete'.

Thoughts?

wim leers’s picture

Status: Active » Needs review
StatusFileSize
new2.27 KB

Forgot patch.

wim leers’s picture

Issue summary: View changes
gabesullice’s picture

+++ b/src/Routing/Routes.php
@@ -172,6 +172,7 @@ class Routes implements ContainerInjectionInterface {
+    $individual_route->setRequirement('_entity_access', "{$entity_type_id}.view"); // what about update/delete?

I'd be unopposed to routes per HTTP method.

wim leers’s picture

Version: 8.x-1.x-dev » 8.x-2.x-dev

Cool.

I think this should be done in the 2.x version though, this is absolutely nice-to-have refactoring, with zero practical difference for end users. And little maintainability improvement. It's just the right thing to do, and we get a nice scalability boost.

wim leers’s picture

Status: Needs review » Needs work

Per #5, splitting up the "individual" route in more routes then is a necessary part of the scope of this issue.

Marking NW.

wim leers’s picture

AFAICT this would interfere with #2843922: Show label of inaccessible entities ('view' access denied) when 'view label' access is allowed: it'd make that downright impossible.

We could still do it for non-GET routes though. But it's on the GET route that we'd get the performance/scalability boost. Therefore, deprioritizing.

wim leers’s picture

Issue summary: View changes
Status: Needs work » Needs review
StatusFileSize
new7.53 KB

Reduced scope per #8.

Actually implemented it now. No interdiff, because the previous patch was just a PoC, and it only handled GET/view, which we no longer can do per #8.

Status: Needs review » Needs work

The last submitted patch, 9: 2973784-9.patch, failed testing. View results

wim leers’s picture

Status: Needs work » Needs review
StatusFileSize
new4.64 KB
new10.99 KB
wim leers’s picture

Issue summary: View changes

Fix mistake in IS introduced in #9.

wim leers’s picture

StatusFileSize
new6.14 KB
new16.11 KB

#11 fixes PATCH and DELETE routes that operate on entities. We still need to handle POST.

That's what this patch does. It uses _entity_create_access.

wim leers’s picture

Status: Needs review » Needs work

The last submitted patch, 13: 2973784-13.patch, failed testing. View results

e0ipso’s picture

Code looks good. However it seems that there is a misalignment between the previous error codes and the current error codes. I'm OK with that as long as:

  1. The new codes still make sense semantically.
  2. The new codes are not against the spec.
wim leers’s picture

Status: Needs work » Needs review
StatusFileSize
new5.11 KB
new18.77 KB
wim leers’s picture

#16: Great! :)

The patches so far actually don't change any status code at all. The assertion for a 403 response is moved in the test logic to run earlier, because access checking is now happening earlier: it's happening during routing instead of during controller execution. And the response message is now more succinct: rather than The current user is not allowed to POST the selected resource. , you now just get . That's literally the only observable change :)

gabesullice’s picture

Status: Needs review » Needs work
  1. +++ b/src/Routing/Routes.php
    @@ -140,14 +140,29 @@ class Routes implements ContainerInjectionInterface {
    +    // Allow anybody access because "view" and "view label" access are checked
    +    // in the controller.
    

    "view" and "view label" can't be checked in the router, right?

  2. +++ b/src/Routing/Routes.php
    @@ -140,14 +140,29 @@ class Routes implements ContainerInjectionInterface {
    +    if ($resource_type->isMutable()) {
    +      $collection_create_route = new Route($collection_route->getPath());
    +      $collection_create_route->setMethods(['POST']);
    +      $collection_create_route->addDefaults(['serialization_class' => JsonApiDocumentTopLevel::class]);
    +      $create_requirement = sprintf("%s:%s", $resource_type->getEntityTypeId(), $resource_type->getBundle());
    +      $collection_create_route->setRequirement('_entity_create_access', $create_requirement);
    +      $collection_create_route->setRequirement('_csrf_request_header_token', 'TRUE');
    +    }
    

    Now that it's more feasible, let's just move this route definition into the "individual" method in Routes.php. It was odd that it was in the collection method to begin with since it's dealing with individual entities.

  3. +++ b/src/Routing/Routes.php
    @@ -201,13 +216,24 @@ class Routes implements ContainerInjectionInterface {
    +      $individual_update_route = new Route($individual_route->getPath());
    

    I think this is where the "create" route belongs.

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new2.72 KB
new18.45 KB

Now that it's more feasible, let's just move this route definition into the "individual" method in Routes.php. It was odd that it was in the collection method to begin with since it's dealing with individual entities.

Oh boy, was I ever more wrong? It is not easy to move that to the other section and make the code flow nicely. I hope no one burnt time on that.

+++ b/src/Routing/Routes.php
@@ -140,14 +140,29 @@ class Routes implements ContainerInjectionInterface {
+    // The 'collection' route must always exist. So if the resource type is not
+    // locatable, assign the "create" route definition to it.
+    if ($resource_type->isLocatable()) {
+      $routes->add(static::getRouteName($resource_type, 'collection'), $collection_route);
+      if ($resource_type->isMutable()) {
+        $routes->add(static::getRouteName($resource_type, 'collection.post'), $collection_create_route);
+      }
+    }
+    if (!$resource_type->isLocatable() && $resource_type->isMutable()) {
+      $routes->add(static::getRouteName($resource_type, 'collection'), $collection_create_route);
+    }

This was really confusing to me. I tried to move things around and comment it in a way that seemed clearer.

Otherwise, this looks good to go.

wim leers’s picture

StatusFileSize
new6.03 KB
new19.79 KB
  1. They can't. At least not until we add a new access check. \Drupal\Core\Entity\EntityAccessCheck (_entity_access), \Drupal\Core\Entity\EntityCreateAccessCheck (_entity_create_access) is what we use here, but \Drupal\Core\Entity\EntityDeleteMultipleAccessCheck (_entity_delete_multiple_access) also exists. So we could add _entity_view_label_or_view_access … but then how would the controller know which of the two applies?
    So, really, even if we added that (which we shouldn't, core should), then it's not clear how the controller would be informed of which one it is, which is a security risk of itself, which is why I think we won't be seeing this in core.
  2. ✔️ 👍 — tricky:
      protected static function getIndividualRoutesForResourceType(ResourceType $resource_type) {
        if (!$resource_type->isLocatable()) {
          return new RouteCollection();
        }
    

    I had to remove this early return because of that.

  3. ✔️ 👍

The complexity in points 2 and 3 lies in this:

    // The 'collection' route must always exist. So if the resource type is not
    // locatable, assign the "create" route definition to it.
…

I implemented what you asked in points 2 and 3, but it made this quite a bit more complex.

wim leers’s picture

Oh heh, two competing implementations now :P

I don't have a strong preference for either, I'll let @gabesullice choose. (I completely agree that #20 is the much better, much clearer incarnation of #17!)

Status: Needs review » Needs work

The last submitted patch, 21: 2973784-20.patch, failed testing. View results

wim leers’s picture

Status: Needs work » Needs review
StatusFileSize
new6.37 KB
new22.66 KB

One more logical change is necessary for the patch in #21. (And a test assertion update.)

gabesullice’s picture

Assigned: Unassigned » gabesullice
+++ b/src/Routing/Routes.php
@@ -190,47 +195,65 @@ class Routes implements ContainerInjectionInterface {
+    if ($resource_type->isMutable()) {
...
+      $individual_update_route->setMethods(['PATCH']);
+      $individual_update_route->addDefaults(['serialization_class' => JsonApiDocumentTopLevel::class]);
+      $individual_update_route->setRequirement('_entity_access', "{$entity_type_id}.update");
+      $individual_update_route->setRequirement('_csrf_request_header_token', 'TRUE');
+      $routes->add(static::getRouteName($resource_type, 'individual.patch'), $individual_update_route);
+      $individual_remove_route = new Route("/{$path}/{{$entity_type_id}}");
+      $individual_remove_route->setMethods(['DELETE']);
+      $individual_remove_route->setRequirement('_entity_access', "{$entity_type_id}.delete");
+      $individual_remove_route->setRequirement('_csrf_request_header_token', 'TRUE');
+      $routes->add(static::getRouteName($resource_type, 'individual.delete'), $individual_remove_route);
+    }

It's nice to see that we essentially ended up in the same place. This is almost exactly what I had. There's one thing that #24 gets wrong though... To be "updatable" or "deletable" it must also be "locatable". That forces one to wrap these in yet another isLocatable() condition and that makes it messy.

I think we should go with something close to #20. I have one final concern about it:

+++ b/src/Routing/Routes.php
@@ -139,15 +139,29 @@ class Routes implements ContainerInjectionInterface {
+      $collection_create_route_name = $resource_type->isLocatable() ? 'collection.post' : 'collection';
+      $routes->add(static::getRouteName($resource_type, $collection_create_route_name), $collection_create_route);
...
     // `/jsonapi/node/article/{uuid}/relationships/uid`.

This will probably lead to a major headache for someone trying to do Url::fromRoute('jsonapi.$resource_type.collection.post') because somewhat mysteriously and apparently randomly, that route will cease to exist.

Perhaps we should just fix our tests rather than add this strange logic.

I'll try that.

gabesullice’s picture

StatusFileSize
new6.17 KB
new22.61 KB

IMO, this is ready unless tests fail (meaning I probably missed a usage of Url::fromRoute('jsonapi.$resource_type.collection')).

Note that the interdiff is against #20.

Status: Needs review » Needs work

The last submitted patch, 26: 2973784-26.patch, failed testing. View results

gabesullice’s picture

Assigned: gabesullice » Unassigned
Status: Needs work » Needs review
StatusFileSize
new1.06 KB

Test coverage++

I did miss a special case of Url::fromRoute('jsonapi.$resource_type.collection') in the entry point. Now that's more robust and won't provide links to collection URLs that don't exist :)

gabesullice’s picture

StatusFileSize
new23.67 KB

Darnit 🤗. Hit save too soon, patch attached.

wim leers’s picture

+++ b/tests/src/Functional/MessageTest.php
@@ -164,7 +164,7 @@ class MessageTest extends ResourceTestBase {
-    $collection_url = Url::fromRoute(sprintf('jsonapi.%s.collection', static::$resourceTypeName))->setAbsolute(TRUE);
+    $collection_url = Url::fromUri('base:/jsonapi/contact_message/camelids')->setAbsolute(TRUE);

Why not use collection.post?

Other than that one nit, this is RTBC.

  • gabesullice committed 9985593 on 8.x-2.x
    Issue #2973784 by Wim Leers, gabesullice, e0ipso: JSON API should check...
gabesullice’s picture

Status: Needs review » Fixed
StatusFileSize
new767 bytes

Fixed the nit on commit, since @Wim Leers said "this is RTBC" and this is blocks #2953346: Define related/relationship routes per field, not dynamically (with route parameters that need validating).

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.