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.
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.
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 toWeekend
-
Section2
(tid=2) with no restrictions
And two users:
-
User1
tagged withSection1
-
User2
tagged withSection2
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:
- 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
Top comments (3)
When i declare
tags:
- { name: access_policy }
in services.yml i got this error
Hi, is "LanguageAccessPolicy" extending "AccessPolicyBase"?
You can find the complete implementation here: github.com/lussoluca/access_policy...
Thank you bro