Problem/Motivation
Spawned from https://mglaman.dev/blog/nightmare-permissions-and-oauth-scopes-drupal
NodeAccessControlHandler treats administer nodes and bypass node access as super-permissions. When either is present, checkAccess() short-circuits and skips granular permission checks (create article content, edit any article content, etc.) entirely. There is no formal relationship between these super-permissions and the granular ones in the permission system itself — it is an implicit contract enforced by handler logic.
This works correctly in a session-based request path where NodeAccessControlHandler controls the full flow. It breaks any system that evaluates hasPermission() directly and independently — OAuth (Simple OAuth), REST, JSON:API, GraphQL.
Simple OAuth's hasPermission() implementation for the authorization code grant requires both the token scope and the authenticated user account to return true for the permission being checked:
return $this->token->hasPermission($permission) && $this->subject->hasPermission($permission);
This is correct behavior — it prevents scope escalation. But it evaluates permissions literally. If an OAuth scope maps to create article content and the authenticated user has administer nodes (but not create article content as an explicit role permission), the check fails. The user is denied an operation their account should allow.
The same problem applies to bypass node access. Although #3552027 proposes deprecating administer nodes and replacing it with more granular permissions, bypass node access carries the same structural flaw: it is a hardcoded bypass in handler logic with no formal representation in the permission system. Fixing administer nodes alone does not fix the class of problem.
Steps to reproduce
- Create a role with
administer nodes(orbypass node access) but without explicit granular permissions likecreate article content. - Assign that role to a user.
- Configure a Simple OAuth consumer with a scope that maps to
create article content. - Authenticate via the authorization code grant and attempt to create an article node via JSON:API or REST.
- Observe a 403 despite the user being an administrator.
Proposed resolution
The implicit relationship between administer nodes / bypass node access and granular node permissions needs to be made explicit in the permission system so that any authorization model — not just cookie sessions — gets the right answer.
One approach: ship a base AccessPolicyInterface implementation in the node system (or Entity API) that detects these super-permissions in the calculated permission set and expands them to include all concrete CRUD permissions for the entity type. This runs transparently for all grant types and all access-checking consumers.
A contrib workaround exists (implementing a low-priority access policy that expands administer nodes into NodePermissions::nodeTypePermissions()), but this is something site builders consistently get wrong, and it has to be redone for every new content type. It belongs in core.
Comments
Comment #2
mglamanComment #3
lauriiiThe proposed solution is a solid pragmatic fix, but I wonder if we should think one level deeper here.
The root problem is that the permission system has no concept of one permission implying others. For example,
administer nodes→all granular node permissionsis an implicit contract enforced in the access handler logic, invisible to everything else. The access policy workaround makes that expansion explicit at runtime, but it's still a layer on top rather than a fix to the underlying model.What if the permission system supported declaring that a permission grants other permissions? Something like a permission definition being able to declare
grants: [create article content, edit any article content, ...]or aparentkey on granular permissions pointing back to their super-permission.This would get us several things at once:
hasPermission()without needing a separate expansion policy.administer {entity_type}can wire up the grants relationship in its permission definitions.Thoughts?
Comment #4
dwwThanks for opening this, Matt. I'm pretty sure I've run into something similar in other contexts, although I'm forgetting those details right now.
I kinda like where #3 is headed. However,
grants:in the "parent" permission is a non-starter. Seecore/modules/node/src/NodePermissions.phpand friends. There are a metric crap-ton of permissions that are per node bundle, and those get auto-generated every time you add a new node type. Custom entity types can work the same way. We definitely would need all of these auto-generated permissions to point to their parent(s), not try to have a parent super permission list of all its children.Meanwhile, at least for now, a specific node bundle "leaf" permission like 'create article content' can have multiple "super permissions" that would allow it (both 'bypass node access' and 'administer nodes'). So, the 'parent' permission key would need to be multi-valued. Which means this isn't a permission tree and those aren't leaves, but a directed graph. And we better hope it's acyclical (a DAG). 😅 So it gets pretty complicated, pretty quickly, especially if we try to surface any of this in the UI. 😬
Speaking of complexity, do we prevent "grandparent" permissions? Can/should we enforce that a permission which is acting as a parent cannot have any parents of its own? Or do we somehow allow this to be arbitrarily nested? E.g. "administer article content" is the parent of all the article CRUD permissions, but is itself a child of "administer nodes". Etc.
Maybe if we deprecate 'administer nodes' and do enough research, we can side step this mess, declare that a permission can only have a single parent, and go back to a tree not a DAG. And maybe we declare that this tree can only be 1 level deep, and enforce that parents can't themselves have parents. But it'd require a lot more investigation than I have time for right now.
-Derek