jsonapi results inconsistency, not respecting grants

Created on 30 November 2023, about 1 year ago

Problem/Motivation

When listing nodes, jsonapi shows inconsistency on the presented results, depending on the presence of any filter parameter in the query or not.

The presence of any filter parameter seems to add an extra status = true filter, although the actual filtering parameter was not the "status" one. As a result, in the results set, the grants system gets bypassed.

The status filtered is added here:
\Drupal\jsonapi\Access\TemporaryQueryGuard::getAccessConditionForKnownSubsets under the block // The "published" subset.

This is the part of the callstack that is related to the problem:

TemporaryQueryGuard.php:363, Drupal\jsonapi\Access\TemporaryQueryGuard::getAccessConditionForKnownSubsets()
TemporaryQueryGuard.php:243, Drupal\jsonapi\Access\TemporaryQueryGuard::getAccessCondition()
TemporaryQueryGuard.php:198, Drupal\jsonapi\Access\TemporaryQueryGuard::applyAccessConditions()
TemporaryQueryGuard.php:144, Drupal\jsonapi\Access\TemporaryQueryGuard::secureQuery()
TemporaryQueryGuard.php:95, Drupal\jsonapi\Access\TemporaryQueryGuard::applyAccessControls()
EntityResource.php:897, Drupal\jsonapi\Controller\EntityResource->getCollectionQuery()

On EntityResource.php:892 there is this block:

    // Compute and apply an entity query condition from the filter parameter.
    if (isset($params[Filter::KEY_NAME]) && $filter = $params[Filter::KEY_NAME]) {
      $query->condition($filter->queryCondition($query));
      TemporaryQueryGuard::setFieldManager($this->fieldManager);
      TemporaryQueryGuard::setModuleHandler(\Drupal::moduleHandler());
      TemporaryQueryGuard::applyAccessControls($filter, $query, $query_cacheability);
    }

The if condition checks whether there is a "filter" parameter in the url's query string. If yes, after applying the actual filter, 3 static functions of the TemporaryQueryGuard are called, the last one being applyAccessControls, which on line 368 adds:

$conditions[] = new EntityCondition($published_field_name, 1);

without explicitly asked to.

The extra condition is applied because of the $access_results['filter_among_published'] which contains an \Drupal\Core\Access\AccessResultAllowed object, that comes as a result of the jsonapi_jsonapi_node_filter_access hook implementation.

The first problem here is bypassing any grants system: If the user is supposed to have view access for the unpublished nodes, this line will remove their access by forcing a filter on status=true.

The second problem here is that the result is inconsistent: If the user (who, as stated, has access to view unpublished nodes through realm-grant) has requested [a] the whole list for this resource (that is: /jsonapi/node/NODETYPE, without any filtering), the unpublished nodes will be included in the result. But if the user has requested [b] the list filtered by any kind of filter (NOT the "status" one, for example /jsonapi/node/NODETYPE?filter[field_name]=some_value) then the extra status=true filter will be added, so the unpublished nodes will not be included in the result.
In a more specific example: for an unpublished node 123, the /jsonapi/node/NODETYPE will include the node, but the /jsonapi/node/NODETYPE?[drupal_internal__nid]=123 will return empty.

Steps to reproduce

  • Install Drupal 11.x (applies also to 10.x)
  • Create two "article" nodes: Set node 1 as published, and node 2 as unpublished
  • Create a role "moderator"
  • Create a user "joe" and assign them the role "moderator"
  • Create a custom module "custom_access", and in the .module file implement these 2 hooks:
    function custom_access_node_access_records($node) {
      $grants = [];
      $grants[] = [
        'realm' => 'custom_access',
        'gid' => 1,
        'grant_view' => 1,
        'grant_update' => 0,
        'grant_delete' => 0,
        'priority' => 0,
      ];
      return $grants;
    }
    
    function custom_access_node_grants($account, $op) {
      $grants = [];
      if ($op == 'view' && in_array("moderator", $account->getRoles())) {
        $grants['custom_access'] = [1];
      }
      return $grants;
    }
    
  • Enable the module and rebuild the Node Access Permissions through the status page. You should see 2 lines in the node_access table that create the custom_access realm
  • Log in as the user "joe" and try to visit the two nodes: node/1 and node/2. The user should have access to view both of them as expected
  • Ask jsonapi (again, as user "joe") for the unfiltered list of articles: /jsonapi/node/article : Under "data" you can see both nodes, as expected, since the user has view access to the unpublished ones
  • Now ask, as user "joe", jsonapi to list all the nodes, by adding a filter for nid=2 : /jsonapi/node/article?filter[drupal_internal__nid]=2 : This returns empty set, although the user has view access to the unpublished node 2
  • Now ask, as user "joe", jsonapi to list all the english nodes (supposing that you have installed Drupal with english language, as per default): /jsonapi/node/article?filter[langcode]=en : This will return only the node 1, although the node 2 is also english
  • Now ask, as user "joe", jsonapi to list all the nodes that were created after the unix timestamp 100: /jsonapi/node/article?filter[a-label][condition][path]=created&filter[a-label][condition][operator]=>&filter[a-label][condition][value]=100 : This will return only the node 1, although the node 2 is also created after the timestamp 100
  • Now ask, as user "joe", jsonapi to fetch the resource itself: /jsonapi/node/article/UUID-FOR-NODE-2 : as expected this returns the node, since the user does have view access to the unpublished nodes

As seen above, although I found the part of the code that introduces the bug, I am not sure yet about the logical part of the cause.

Proposed resolution

TBD

Remaining tasks

  1. Find the logical part that introduces the bug.
  2. Decide on a solution.
  3. Implement the solution.

User interface changes

TBD

API changes

TBD

Data model changes

TBD

Release notes snippet

TBD

🐛 Bug report
Status

Active

Version

11.0 🔥

Component
JSON API 

Last updated 6 days ago

Created by

🇳🇴Norway efpapado

Live updates comments and jobs are added and updated live.
Sign in to follow issues

Comments & Activities

  • Issue created by @efpapado
  • 🇳🇴Norway efpapado

    Rephrased title.

  • 🇧🇪Belgium Nick Dewitte

    For future reference, this behavior can be (conditionally) bypassed by implementing the following hook:
    hook_jsonapi_entity_filter_access()

  • 🇵🇱Poland tivi22

    hey, I have a very similar issue, but in my case, I cannot see published translation of article, when its original translation is unpublished. I'm using JSON:API together with the View Unpublished module.

    This module provides view any unpublished content and view any unpublished {node_type} content permissions.

    I'm basically using only the first one, which is assigned to couple of authenticated user roles (i.e. Content editor). Anonymous users have access to published content only.

    I'm using Drupal as a content repository, in a decoupled approach, and the front-end is consuming data from Drupal via JSON:API by sending requests to Drupal without any authentication (as anonymous user).

    I have two languages configured in Drupal and when I create i.e. an article in English (without publishing this translation) and then translate it to Danish (with publishing it)... when I go to /da/jsonapi/node/article I cannot see this published translation. Same is when using filters /da/jsonapi/node/article?filter[drupal_internal__nid]={nid}... but when I go directly to the article here /da/jsonapi/node/article/{uuid} then I get all the data, without any problems.

    If I do it other way around, so I publish original English translation and unpublish additional Danish translation, then everything works as expected (English translations are visible on resource list, filters are working, Danish translations are not accessible, etc).

    Long story short... if I don't publish original translation then any other published translation is not visible on the resource list (/da/jsonapi/node/article) - with or without using filters, but is visible when asking directly for it (/da/jsonapi/node/article/{uuid}).

    Is there any workaround for this?

    I was trying to use the hook mentioned in #7, but it didn't help, even in this form:

    function mymodule_jsonapi_entity_filter_access($entity_type, $account): array {
      return ([
        JSONAPI_FILTER_AMONG_ALL => AccessResult::allowed(),
      ]);
    }
    
  • 🇵🇱Poland tivi22

    Any progress?

Production build 0.71.5 2024