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
- Find the logical part that introduces the bug.
- Decide on a solution.
- Implement the solution.
User interface changes
TBD
API changes
TBD
Data model changes
TBD
Release notes snippet
TBD