The nightmare of permissions and OAuth scopes in Drupal
Drupal's role-based access control is one of its strengths. Permissions and roles are well-understood, and the system is mature. But the moment you step outside the standard cookie-based session — say, into OAuth with the authorization code flow — you hit a wall that the core permission model never anticipated.
Super-permissions and their hidden assumptions
Drupal treats administer nodes and bypass node access as super-permissions. If a user has either, NodeAccessControlHandler assumes they can perform any operation on any content type and skips the granular checks entirely. bypass node access is actually more powerful than administer nodes — a quirk of legacy cruft going back to early Drupal versions.
This works fine in a session context because NodeAccessControlHandler::checkAccess() evaluates permissions top-down: bypass node access first, then administer nodes, and only then granular permissions like create article content. An administrator never reaches the granular check — they're allowed at the administer nodes step. Starting from the bottom of that chain is where things break down.
How Simple OAuth handles permissions
Simple OAuth ties scopes to permissions: a scope either maps to a single permission or maps to a role and inherits its permissions. The grant type determines how those scope permissions are evaluated against the request.
For the client credentials grant — a service account with no associated user — permission checks run only against the scope:
public function hasPermission($permission) {
if ($this->token->get('auth_user_id')->isEmpty()) {
return $this->token->hasPermission($permission);
}
return $this->token->hasPermission($permission) && $this->subject->hasPermission($permission);
}
For the authorization code grant, both conditions must be true: the token's scope must grant the permission and the authenticated user must have the permission on their account. This is correct behavior — it prevents a scope from silently escalating a user's access beyond what their role allows.
Where it breaks down
The dual-check logic is sound, but it exposes a fundamental inconsistency in how Drupal's permission system models "administrative" access.
NodeAccessControlHandler doesn't model administer nodes as a permission that implies granular access — it's a hardcoded string check inside checkAccess() that bypasses the granular permissions entirely. There's no formal relationship between administer nodes and create article content in the permission system itself.
Simple OAuth evaluates hasPermission('create article content') literally — it has no awareness of the top-down chain in NodeAccessControlHandler. If your token scope maps to create article content and the user has administer nodes — but not create article content as an explicit role permission — the check fails. The user is locked out of an operation they should be able to perform.
This is the gap: NodeAccessControlHandler only reaches granular permission checks when no super-permission is present. Simple OAuth starts there, and since there's no formal relationship between administer nodes and granular permissions, there's nothing to fall back on.
The workaround: expand administrative permissions explicitly
Before Access Policies, the fix was manual: assign every granular node permission to your administrative roles. That's a maintenance nightmare the moment you add a new content type.
With Access Policies (Drupal 10.3+ and Simple OAuth 6.1.x), you can solve this programmatically. Implement a low-priority AccessPolicyInterface that detects administer nodes in the calculated permission set and expands it to include all granular node permissions.
namespace Drupal\my_module\Access;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Session\AccessPolicyBase;
use Drupal\Core\Session\CalculatedPermissionsItem;
use Drupal\Core\Session\RefinableCalculatedPermissionsInterface;
use Drupal\node\NodePermissions;
class NodePermissionsAccessPolicy extends AccessPolicyBase {
public function __construct(
private readonly NodePermissions $nodePermissions,
) {}
public function alterPermissions(AccountInterface $account, string $scope, RefinableCalculatedPermissionsInterface $calculated_permissions): void {
foreach ($calculated_permissions->getItems() as $item) {
if (in_array('administer nodes', $item->getPermissions())) {
$permissions = [
...$item->getPermissions(),
...array_keys($this->nodePermissions->nodeTypePermissions()),
];
$calculated_permissions->addItem(
item: new CalculatedPermissionsItem(
permissions: $permissions,
isAdmin: $item->isAdmin(),
scope: $item->getScope(),
identifier: $item->getIdentifier(),
),
overwrite: TRUE,
);
}
}
}
public function getPersistentCacheContexts(): array {
return ['user.permissions'];
}
}
This runs after the default permission calculation. If administer nodes is present, it injects all granular node type permissions so the explicit hasPermission check in Simple OAuth has something to match against.
Register the policy in my_module.services.yml:
services:
my_module.node_permissions_access_policy:
class: Drupal\my_module\Access\NodePermissionsAccessPolicy
arguments:
- '@node.permissions'
tags:
- { name: access_policy, priority: -100 }
The priority: -100 ensures this policy runs after the default handlers have built the initial permission set — so you're expanding an already-calculated set, not racing against it. The lower the priority, the later it runs.
Why this belongs in Drupal C/ore or the Entity API module
The root problem is that administer nodes is an implicit contract enforced by handler logic, not an explicit one derivable from the permission system. That works for the session-based request path where NodeAccessControlHandler controls the full flow. It breaks any system — REST, JSON:API, GraphQL, OAuth — that evaluates permissions independently.
The fix belongs in Drupal Core or the Entity API module. Entity API already provides a unified layer for granular permissions on custom entities. A base policy that expands administer {entity_type} into concrete CRUD permissions would make the system predictable across all authorization models — not just session cookies.
Until then, the access policy workaround above bridges the gap. It makes the implied relationship between administer nodes and granular permissions explicit, so any system that evaluates permissions directly — OAuth, REST, JSON:API, GraphQL — gets the right answer without relying on handler-specific logic.
Want more? Sign up for my weekly newsletter