Drupal Access Policy demystified

Deep dive into the new Access Policy API in Drupal 10.3

# drupal # open source # security
Drupal Access Policy demystified

Access Policy in Core

Drupal 10.3 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:

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:

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

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:

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:

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:

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:

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

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

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:

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 or Taxonomy Access Control 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 Image: User roles configuration section

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

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:

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:

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:

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:

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:

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:

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

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.

Add a restriction to an access term 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.

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:

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:

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 to run the code.

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