 [ ← blog / drupal ](/) Mar 10, 2026  ·  \#drupal  ·  5 min read 

# 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:

```php
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.

```php
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`:

```yaml
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.

 

  ![](/themes/custom/mglaman/images/headshot.webp)Thanks for reading

I write about Drupal, PHP, and the quiet infrastructure behind large sites — caching, config, and testing. New posts land every week or two.

 [Subscribe via RSS →](/rss.xml) [About me →](/about-me) 

 



 

  [← Older

drupalorg-cli 0.8.0: GitLab issue fork and merge request commands

 ](/blog/drupalorg-cli-080-gitlab-issue-fork-and-merge-request-commands) [Newer →

Understanding Drupal: A Complete Guide to Caching Layers is now published!

 ](/blog/understanding-drupal-complete-guide-caching-layers-now-published)