Drupal Access Policy demystified
Tags: drupal, opensource, security
Published: 2024-06-24 10:35:53

---
title: "Drupal Access Policy demystified"
published: true
description: "Deep dive into the new Access Policy API in Drupal 10.3"
tags: drupal, opensource, security
canonical_url: https://tech.sparkfabrik.com/en/blog/drupal-access-policy-demystified/
cover_image: https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gtl2nl76ysg5m2ryi5n5.png
---

## Access Policy in Core

[Drupal 10.3](https://www.drupal.org/blog/drupal-10-3-0) introduces a new way to assign permissions to users, going beyond the traditional roles and permissions system. This new system is called the **Access Policy API**, and in this blog post, we'll try to explain how it works and how to use it.

## The old way

Until Drupal 10.2, the access control system was based on two main concepts: you were either in a **role** that granted you a set of permissions or the **user with UID 1**, and the access checks were bypassed.

For example, you can have this code somewhere:

```php
public function access(AccountInterface $account) {
   return $account->hasPermission('access content');
}
```

The code for `hasPermission` simply checks for the two cases mentioned above: if the user is the one with UID 1 or if the user is in a role that grants that permission:

```php
public function hasPermission(string $permission, AccountInterface $account): bool {
   // User #1 has all privileges.
   if ((int) $account->id() === 1) {
      return TRUE;
   }

   return $this->entityTypeManager
      ->getStorage('user_role')
      ->isPermissionInRoles($permission, $account->getRoles());
} 
```

This implementation was quite simple and worked well when a user has a set of permissions that are valid sitewide and that don't change based on some external factors.

If you need to implement use cases like:

* deny edit permissions on weekends
* allow edit permissions only if the user has 2FA enabled
* allow edit permissions only to contents in a group (or in a domain, or in a Commerce store, etc.)

You're probably going to need *a lot* of custom code (otherwise it's impossible to implement them).

## The new way

Drupal 10.3 introduced a new [Access Policy API](https://www.drupal.org/node/3385551) that allows the definition of a set of policies that can be applied based on a context.

If you want to know something about the genesis of this new system, you can read this blog post: [Policy-Based Access in Core by Kristiaan Van den Eynde](https://www.thedroptimes.com/40119/kristiaan-van-den-eynde-talks-about-policy-based-access-in-core).

The API is quite simple; you define a policy class that extends the `\Drupal\Core\Session\AccessPolicyBase` and provide, at least, the implementation for the methods:

* `calculatePermissions(AccountInterface $account, string $scope): RefinableCalculatedPermissionsInterface`: Calculates the permissions for an account within a given scope
* `getPersistentCacheContexts(): array`: Gets the initial cache context this policy varies by

A policy is then registered in the service container with the tag `access_policy.`

Drupal 10.3 mimics the old behavior by providing two default policies:

* `\Drupal\Core\Session\Access\UserRolesAccessPolicy`: Grants permissions based on a user's roles
* `\Drupal\Core\Session\Access\SuperUserAccessPolicy`: Bypass permissions checks for the user with UID equal to 1

The `\Drupal\Core\Session\Access\UserRolesAccessPolicy`, for example, is implemented as follows:

```php
final class UserRolesAccessPolicy extends AccessPolicyBase {

   public function __construct(protected EntityTypeManagerInterface $entityTypeManager) {}

   public function calculatePermissions(AccountInterface $account, string $scope): RefinableCalculatedPermissionsInterface {
      $calculated_permissions = parent::calculatePermissions($account, $scope);
      $user_roles = $this->entityTypeManager->getStorage('user_role')->loadMultiple($account->getRoles());

      foreach ($user_roles as $user_role) {
         $calculated_permissions
            ->addItem(new CalculatedPermissionsItem($user_role->getPermissions(), $user_role->isAdmin()))
            ->addCacheableDependency($user_role);
      }

      return $calculated_permissions;
   }

   public function getPersistentCacheContexts(): array {
      return ['user.roles'];
   }
}
```

The previous code retrieves the user's roles and adds a `CalculatedPermissionsItem` with the permissions granted by each role. Then, it adds a cacheable dependency on the role entity so that if the role's permissions change, the cache is invalidated. Finally, the method `getPersistentCacheContexts` returns the initial cache context that the policy varies by.

We will discuss the meaning of `(Refinable)CalculatedPermissions`, `scope`, and `initial cache context` shortly.

The critical thing to understand here is that this new system does not aim to grant access to something, like editing a node or viewing a page. It's designed to calculate a user's permissions in a given context. The access check is still done in the old way, which checks if the user has specific permission to perform a task.

> An access policy converts a context into a set of permissions

Access policies are services, allowing us to replace or decorate the implementation provided by Core. Indeed, the Core itself allows for the policy for the super user to be disabled to increase site security (https://www.drupal.org/node/2910500). With the super user policy disabled, we may want to define a new one that grants admin permissions based on other user characteristics. For instance, we can specify that the site admins are users with a specific email domain or who have logged in through a particular authentication system.

Now, let's dive into the details of the new system using some examples.

### Example 1 (alter permissions provided by Core)

Let's say we want to add a new policy that grants permission to `access promotional banners` only if the current language is English.

The `LanguageAccessPolicy.php` class may look like this:

```php
class LanguageAccessPolicy extends AccessPolicyBase {

   public function alterPermissions(
      AccountInterface $account,
      string $scope,
      RefinableCalculatedPermissionsInterface $calculated_permissions
   ): void {
      if (\Drupal::languageManager()->getCurrentLanguage()->getId() == 'en') {
         $calculated_permissions->addItem(
            item: new CalculatedPermissionsItem(
               permissions: ['access promotional banners'],
               isAdmin: FALSE
            ),
            overwrite: FALSE
         );
      }
   }

   public function getPersistentCacheContexts(): array {
      return ['languages'];
   }
}
```

To register the policy in the service container, you need to add the following to your `access_policy_demo.services.yml`:

```yaml
services:
   access_policy_demo.access_policy.language:
      class: Drupal\access_policy_demo\Access\LanguageAccessPolicy
      tags:
         - { name: access_policy }
```

You can now have this render array sonewhere in your code:

```php
$build['content'] = [
   '#markup' => $this->currentUser()->hasPermission('access promotional banners') ? 'Some promotional banner' : '',
   '#cache' => [
      'contexts' => ['languages'],
   ],
];
```

The previous code will show the promotional banner only if the current language is English.

Revoke permission is possible by setting the `overwrite` parameter of the `addItem` method to `TRUE`, like this:

```php
$new_permissions = array_diff(
   $calculated_permissions->getItem()->getPermissions(),
   ['view page revisions']
);

$calculated_permissions->addItem(
   item: new CalculatedPermissionsItem(
      permissions: $new_permissions,
      isAdmin: FALSE
   ),
   overwrite: TRUE
);
```

Altering permissions is possible because, during the access policy calculation, the object that holds the calculated permissions is an instance of the `RefinableCalculatedPermissionsInterface` that allows the addition or removal of permissions.
When the build and alter phases are complete, the calculated permissions are converted to an immutable object of type `CalculatedPermissionsInterface`. Using an immutable object guarantees that the computed permissions are not altered after the access policy calculation.

> Drupal has moved from an RBAC (Role-Based Access Control) to a PBAC (Policy-Based Access Control) system where permissions are calculated based on a set of policies that are applied in a given context.

Access policies are tagged services, so you can define the priority with which they are applied:

```yaml
services:
   access_policy_demo.access_policy.language:
      class: Drupal\access_policy_demo\Access\LanguageAccessPolicy
      tags:
         - { name: access_policy, priority: 100 }
```

The priority is a positive or negative integer that defaults to 0. The higher the number, the earlier the tagged service will be located in the service collection.

Now it's time to talk about **scopes**.

### Example 2 (split the site into sections)

Until now, we have ignored the `scope` parameter, but look at how the `hasPermission()` method is implemented in Drupal 10.3:

```php
public function hasPermission(string $permission, AccountInterface $account): bool {
   $item = $this->processor->processAccessPolicies($account)->getItem();
   return $item && $item->hasPermission($permission);
}
```

The `processAccessPolicies()` has a second, non-mandatory parameter: the `scope`.
The `getItem()` method has two non-mandatory parameters: the `scope` and the `identifier`.

Within Core, both `scope` and `identifier` default to `AccessPolicyInterface::SCOPE_DRUPAL`, and you probably don't have to deal with them in most cases.

But what are they used for?

The `scope` is a string that identifies the context in which the policy is applied, like a group, a domain, a commerce store, etc. The `identifier` is a string that identifies the specific value within the scope (like the group ID, the domain ID, etc).

The `AccessPolicyInterface` defines the `applies(string $scope): bool` method, which determines whether the policy should be applied in a given scope.

Let's try to implement (a very simplified) version of modules like [Permissions by Term](https://www.drupal.org/project/permissions_by_term) or [Taxonomy Access Control Lite](https://www.drupal.org/project/tac_lite) using the new system.

Suppose we have a vocabulary `access`, which terms represent a group of content that can be accessed only by a specific set of users. Content types and users are tagged with terms of this vocabulary.
The permissions a user has are calculated based on the standard roles mechanism of Drupal. But on nodes tagged with a term of the `access` vocabulary, if the user is tagged with the same term, the user has the permissions granted by an additional role.

We have the `Content editor` role that grants standard permissions like `Article: Create new content` or `View own unpublished content`, and the `Content editor in term` role that grants permissions like `Article: Edit any content` or `Article: Delete any content`. An editor always has the permissions granted by the `Content editor` role. Still, on nodes tagged with a term of the `access` vocabulary, if the user is tagged with the same term, the user has the permissions granted by the `Content editor in term` role too.

![User roles configuration section](https://tech.sparkfabrik.com/images/content/drupal-access-policy-demistified/user_roles.png)
*Image: User roles configuration section*

The code for the `TermAccessPolicy.php` class may look like this:

```php
class TermAccessPolicy extends AccessPolicyBase {
   public const SCOPE_TERM = 'term';

   public function applies(string $scope): bool {
      return $scope === self::SCOPE_TERM;
   }

   public function calculatePermissions(AccountInterface $account, string $scope): RefinableCalculatedPermissionsInterface {
      $calculated_permissions = parent::calculatePermissions($account, $scope);

      if ($scope != self::SCOPE_TERM) {
         return $calculated_permissions;
      }

      $user = User::load($account->id());
      $user_terms = $user->get('field_access')->referencedEntities();
      foreach ($user_terms as $user_term) {
         $cacheability = new CacheableMetadata();
         $cacheability->addCacheableDependency($user_term);

         $calculated_permissions
            ->addItem(
               new CalculatedPermissionsItem(
                  permissions: $permissions,
                  isAdmin: FALSE,
                  scope: self::SCOPE_TERM,
                  identifier: $user_term->id()
               )
            )
            ->addCacheableDependency($cacheability);
      }

      return $calculated_permissions;
   }

   private function getPermissions(AccountInterface $account): array {
      $extra_roles = User::load($account->id())
         ->get('field_extra_role')
         ->referencedEntities();

      if (count($extra_roles) === 0) {
         return [];
      }

      $extra_role = reset($extra_roles);

      return $extra_role->getPermissions();
   }

   public function getPersistentCacheContexts(): array {
      return ['user.terms'];
   }
}
```

In the previous code, we've defined a new scope `term` and we've implemented the `applies` method to return `TRUE` only if the scope is `term`.
Then, we calculate the permissions based on the terms the user is tagged with. We add a cacheable dependency on the term entity to invalidate the cache if the term changes.

Note that we've passed two more arguments to the `addItem` method: the `scope` and the `identifier`. The `scope` is the string `term`, and the `identifier` is the term ID.

We can register the policy in the service container with the following code:

```yaml
access_policy_demo.access_policy.term:
   class: Drupal\access_policy_demo\Access\TermAccessPolicy
   tags:
      - { name: access_policy }
```

The `getPersistentCacheContexts()` uses a custom cache context, so we've to define it, too:

```php
class UserTermsCacheContext implements CalculatedCacheContextInterface {
   public function __construct(
      protected readonly AccountInterface $account,
   ) {}

   public static function getLabel(): string {
      return t("User's terms");
   }

   public function getContext($term = NULL): string {
      $user = User::load($this->account->id());
      $user_terms = array_map(
         fn($loaded_term) => $loaded_term->id(),
         $user->get('field_access')->referencedEntities()
      );

      if ($term === NULL) {
         return implode(',', $user_terms);
      } else {
         return (in_array($term, $user_terms) ? 'true' : 'false');
      }
   }

   public function getCacheableMetadata($term = NULL): CacheableMetadata {
      return (new CacheableMetadata())->setCacheTags(['user:' . $this->account->id()]);
   }
}
```

A cache context needs to be registered in the service container, like:

```yaml
cache_context.user.terms:
   class: Drupal\access_policy_demo\Access\UserTermsCacheContext
   arguments:
      - '@current_user'
   tags:
      - { name: cache.context }
```

Finally, we can use the new scope to check the permissions:

```php
function access_policy_demo_node_access(NodeInterface $node, $operation, AccountInterface $account): AccessResultInterface {
   $access = FALSE;

   // This node is not under access control.
   if (!$node->hasField('field_access')) {
      return AccessResult::allowed();
   }

   // Always allow access to view the node.
   if ($operation == 'view') {
      return AccessResult::allowed();
   }

   // Check if the user has access to the node.
   $terms = $node->get('field_access')->referencedEntities();
   $type = $node->bundle();
   foreach ($terms as $term) {
      $item = \Drupal::service('access_policy_processor')
         ->processAccessPolicies($account, TermAccessPolicy::SCOPE_TERM)
         ->getItem(TermAccessPolicy::SCOPE_TERM, $term->id());

      if (!$item) {
         continue;
      }

      switch ($operation) {
         case 'update':
            $access = $item->hasPermission('edit any ' . $type . ' content');
            if (!$access && $item->hasPermission('edit own ' . $type . ' content')) {
               $access = $account->id() == $node->getOwnerId();
            }
            break;

         case 'delete':
            $access = $item->hasPermission('delete any ' . $type . ' content');
            if (!$access && $item->hasPermission('delete own ' . $type . ' content')) {
               $access = $account->id() == $node->getOwnerId();
            }
            break;

         default:
            $access = TRUE;
      }

      if ($access) {
         break;
      }
   }

   return $access ? AccessResult::allowed() : AccessResult::forbidden();
}
```

The previous code is just a rough example. Still, the critical thing to note is that we've used the `TermAccessPolicy::SCOPE_TERM` and the term ID to retrieve a `CalculatedPermissionsItem` that contains the permissions granted by the term to the user.

What a long journey! But we're not done yet.

One of the new system's most important features is that access policies are cached by context, but context can be dynamic and change during permission calculation; this is where the `initial cache context` comes into play.

### Variation cache

Access policies usually vary by some context, like the user roles, the time of day, the domain, etc. Drupal has a concept of cache contexts that allows you to vary the cache based on some context, but until Drupal 10.2, this can be used only to add cache contexts to render arrays.

Now, all caches can use cache contexts thanks to the Variation cache.

Variation cache is not a new type of cache but a wrapper around the cache backends that already exist in Drupal. It has two interesting features.
The first one is that it allows varying a cache by context:

```yml
cache.access_policy:
   class: Drupal\Core\Cache\CacheBackendInterface
   tags:
      - { name: cache.bin }
   factory: ['@cache_factory', 'get']
   arguments: [access_policy]

variation_cache.access_policy:
   class: Drupal\Core\Cache\VariationCacheInterface
   factory: ['@variation_cache_factory', 'get']
   arguments: [access_policy]
```

In the previous example, `variation_cache.access_policy` is a wrapper around `cache.access_policy`. When I do something like:

```php
$cache = \Drupal::service('variation_cache.access_policy');
$cache->set(['key1', 'key2'], 'value', ['user.roles', 'languages:language_interface'], ['user.roles']);
```

I'm saving `value` at the `['key1', 'key2']` of the `access_policy` cache, and I'm telling the variation cache that the cache will vary by the `user.roles` and `languages:language_interface` contexts.

Having not specified anything specific in the `tags` section of the `cache.access_policy` service, I get the default cache backend, typically the database one. I could have written:

```yml
tags:
   - { name: cache.bin.memory, default_backend: cache.backend.memory.memory }
```

To have a cache in memory.

Variation cache uses cache contexts to build cache IDs. For example, the cid for the contexts `user.roles` and `languages:language_interface` when the user has roles 3 and 4, and the language is English could be something like: `key1:key2:[languages:language_interface]=en:[user.roles]=3,4`. (Contexts are sorted in alphabetical order by name.)

The second feature comes from the fact that when I save data in the variation cache, I can specify two sets of contexts: the actual ones to vary the cache on (the third argument of the `set` method) and the "initial" ones (the fourth argument of the `set` method).

But what are these initial cache contexts? They are the ones that our data varies for sure, but they could not be the only ones. If, during the building of the data to cache, someone else adds one or more specific contexts, the cache system may not be aware of it.

When the set of final cache contexts is more specific than the initial ones, the variation cache stores a cache with an ID built using the initial cache contexts. That cache will not store the data but a redirect that contains the final cache contexts to use to find the actual data. This chain of redirects can span more than one level.

Let's add more complexity to our previous example about the `TermAccessPolicy`. Suppose that terms in the `access` vocabulary have a select field named `is_restricted`, with two values: `Weekend` and `Weekdays`. We want to grant the permissions not only if the node is tagged with a term but also based on the day of the week.

![User roles configuration section](https://tech.sparkfabrik.com/images/content/drupal-access-policy-demistified/term_edit.png)
*Image: Add a restriction to an access term*

If no restrictions are set, the permissions are granted as usual. If a restriction is set, the permissions are only granted if the current day matches the restriction.

```php
foreach ($user_terms as $user_term) {
   $cacheability = new CacheableMetadata();
   $cacheability->addCacheableDependency($user_term);

   $restricted = $this->isRestricted($user_term);
   if ($restricted) {
      $cacheability->addCacheContexts(['is_restricted']);
      $permissions = [];
   } else {
      $permissions = $this->getPermissions($account);
   }

   $calculated_permissions
      ->addItem(
         new CalculatedPermissionsItem(
            permissions: $permissions,
            isAdmin: FALSE,
            scope: self::SCOPE_TERM,
            identifier: $user_term->id()
         )
      )
      ->addCacheableDependency($cacheability);
}

return $calculated_permissions;
```

The `isRestricted` method can be implemented as follows:

```php
private function isRestricted(TermInterface $user_term): bool {
   $restriction = $user_term->get('field_restriction')->getValue();

   if (count($restriction) == 0 || count($restriction) == 2) {
      return FALSE;
   }

   $field_value = $restriction[0]['value'];

   if ($field_value === 'weekend' && IsWeekendCacheContext::isWeekend()) {
      return FALSE;
   }

   if ($field_value === 'weekdays' && !IsWeekendCacheContext::isWeekend()) {
      return FALSE;
   }

   return TRUE;
}
```

The `RestrictedCacheContext` class can be like this:

```php
class RestrictedCacheContext implements CalculatedCacheContextInterface {
   public static function getLabel(): string {
      return t('Is Weekend?');
   }

   public function getContext($parameter = NULL): string {
      $result = static::isWeekend() ? 'weekend' : 'weekday';

      return "is_restricted.{$result}";
   }

   public static function isWeekend(): bool {
      return date('w', time()) % 6 === 0;
   }

   public function getCacheableMetadata($parameter = NULL): CacheableMetadata {
      return (new CacheableMetadata());
   }
}
```

Now suppose to have two terms:

* `Section1` (tid=1) with the restriction set to `Weekend`
* `Section2` (tid=2) with no restrictions

And two users:

* `User1` tagged with `Section1`
* `User2` tagged with `Section2`

And we're on Sunday.

When permissions are calculated for `User1`, the initial cache context will be `user.terms`, but then we'll add the `is_restricted` cache context because the `Section1` term is restricted.
When permissions are calculated for `User2` the initial cache context will be `user.terms`, and no other cache context will be added.

The cache will be something like:

`access_policies:term:[user.terms]=2` => `Drupal\Core\Session\RefinableCalculatedPermissions`
`access_policies:term:[user.terms]=1` => `Drupal\Core\Cache\CacheRedirect` (`cacheContexts: ["user.terms", "is_restricted"]`)
`access_policies:term:[is_restricted]=is_restricted.weekend:[user.terms]=1` => `Drupal\Core\Session\RefinableCalculatedPermissions`

The Variation cache stores the data for access policies that vary only by `user.terms` directly.
For the access policies that also vary by `is_restricted,` it stores a redirect (along with information about the final cache contexts to look for: `user.terms` and `is_restricted`).
To access a cache with more final cache contexts than the initial ones, the variation cache will need to follow a chain of redirects.

## Conclusion

The new Access Policy API is a powerful tool that allows the implementation of complex access control systems in Drupal.
It's a big step forward from the old system based on only roles and permissions.

In the future, we'll see more and more contrib modules that will use this new system to convert custom logic to the new system. At SparkFabrik, we've already started using it in custom modules for our customers.

## Resources:

I've set up a GitHub repository with the code used in this blog post: https://github.com/lussoluca/access_policy_demo. You can clone it and use [DDEV](https://ddev.com/) to run the code.

This blog post would not have been possible without the help of the following resources:

* https://www.thedroptimes.com/40119/kristiaan-van-den-eynde-talks-about-policy-based-access-in-core
* https://www.drupal.org/node/3365546
* https://www.drupal.org/node/3385551
* https://www.drupal.org/node/2910500
* https://www.drupal.org/node/3411485
* https://www.drupal.org/docs/develop/drupal-apis/access-policy-api
* https://bpekker.dev/access-policy-api/
* https://www.youtube.com/watch?app=desktop&v=pbAUselOJy0