HomeServiceContact
Drupal
min read
September 10, 2024

Introduction to the Policy Based Access Checking in Drupal 10

Introduction to the Policy Based Access Checking in Drupal 10
Table of contents

Traditionally, access control in Drupal was primarily 'role-based.' The site owner would define various roles and assign specific permissions to each. These roles would then be assigned to users. If a user’s role included the necessary permission to perform a certain task, access would be granted; otherwise, it would be denied.

Access control in Drupal

Despite being simple and straightforward, this way of access checking had a lot of limitations.

  • Permissions were always linked with ‘User Roles’. There was no way to dynamically add/revoke permissions based on context such as the time of the day.
  • Implementing use cases such as allowing edit permissions only if the user has 2FA enabled or only during office hours required writing a lot of code.
  • User 1 had full access which might not be desirable in some cases

The  Policy Based Access Checking was introduced in Drupal 10.3 to overcome such limitations of the traditional access control.

“Policy Based Access Control is a type of system where people gain or lose access based on certain predetermined scenarios or policies”
-Kristiaan Van den Eynde

How it works

Policy Based access control

The Access Policy API is the core of the Policy Based Access Checking(PBAC).Access policy is a tagged service that can add or remove permissions for a particular user, based on globally available context data such as the domain, time of day, current user's field values, etc. So, to create an access policy, create a service that extends the class \Drupal\Core\Session\AccessPolicyBase, and then add the ‘access_policy’ tag to the service.

The access policy calculates the permissions in 2 phases: The Build phase and the Alter phase.

Build phase

  • During the Build phase, the core access policy processor invokes all the access policies defined in the codebase, based on priority.
  • Each of these access policies update the permission based on context and return an instance of \Drupal\Core\Session\RefinableCalculatedPermissionsInterface that holds the permissions and the relevant cache metadata.

The following example illustrates the build phase of an access policy that gives additional permissions to the user based on the user’s timezone.


services:
  access_check.test_access_policy.user_timezone:
    class: Drupal\test_access_policy\Access\UserTimeZoneAccessPolicy
    tags:
      - { name: access_policy }




/**
 * Access policy based on timezone.
 */
class UserTimeZoneAccessPolicy extends AccessPolicyBase {

  /**
   * {@inheritdoc}
   */
  public function calculatePermissions(AccountInterface $account, string $scope): RefinableCalculatedPermissionsInterface {
    $calculated_permissions = parent::calculatePermissions($account, $scope);

    $user_timezone = $account->getTimeZone();
    // Grant create and edit permissions only if the user's timezone is
    // 'Asia/Kolkata'.
    if ($user_timezone === "Asia/Kolkata") {
      $req_permissions = [
        'create article content',
        'create page content',
        'create recipe content',
        'edit any article content',
        'edit any page content',
        'edit any recipe content',
      ];
      $calculated_permissions->addItem(
        item: new CalculatedPermissionsItem(
          permissions: $req_permissions,
        ),
        overwrite: FALSE
      );
    }

    return $calculated_permissions;
  }

  /**
   * {@inheritdoc}
   */
  public function getPersistentCacheContexts(): array {
    return ['timezone'];
  }

}

The calculatePermissions() method returns an object of type RefinableCalculatedPermissionsInterface. Since the access varies based on the user’s timezone, the getPersistentCacheContexts() method returns the ‘timezone’ context.

Alter phase

  • The accesspolicy processor combines(Merge or Overwrite based on the overwrite parameter) all the permissions and cache metadata from all such access policies and generates a Drupal\Core\Session\RefinableCalculatedPermissions object.
  • The ‘Alter phase’ allows other policies to alter these fully built permissions.
  • This allows other modules to alter core’s (or any other module’s) access policies.
  • The “RefinableCalculatedPermissions” object is converted to an immutable Drupal\Core\Session\CalculatedPermissions object after this phase.Properties of the CalculatedPermissions object cannot be changed later.
  • Finally, the permission data is cached using the variation cache.

The following example illustrates how to revoke certain permissions from a user based on the user’s email domain, during the ‘alter’ phase.


 access_check.test_access_policy.user_mail:
    class: Drupal\test_access_policy\Access\UserEmailDomainAccessPolicy
    tags:
      - { name: access_policy }




/**
 * Access policy based on user email domain.
 */
class UserEmailDomainAccessPolicy extends AccessPolicyBase {

  /**
   * {@inheritdoc}
   */
  public function alterPermissions( AccountInterface $account, string $scope, RefinableCalculatedPermissionsInterface $calculated_permissions): void {
    // Give only 'Authenticated user' permissions to the user if the email
    // domain is 'example.com', regardless of the user's roles.
    if (IsUserMailValidCacheContext::isUserMailValid($account) == 'No') {
      $new_permissions = Role::load(RoleInterface::AUTHENTICATED_ID)->getPermissions();
      $calculated_permissions->addItem(
        item: new CalculatedPermissionsItem(
          permissions: $new_permissions,
          isAdmin: FALSE
        ),
        // Set this to 'TRUE' to override the permissions.
        overwrite: TRUE
      );
    }
  }

  /**
   * {@inheritdoc}
   */
  public function getPersistentCacheContexts(): array {
    return ['is_user_mail_valid'];
  }

}

This policy checks the email domain of  the user and grants only authenticated user permissions, if the email domain is ‘example.com’. Note that the ‘overwrite’ parameter is set to ‘FALSE’ to fully change the permissions. The getPersistentCacheContexts() returns a custom cache context that depends on the user’s email domain.

Scopes and identifiers

Both ‘Scopes’ and ‘Identifiers’ help to increase the specificity of access policies.

  • 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).
  • Within Core, both scope and identifier default to AccessPolicyInterface::SCOPE_DRUPAL

Consider a simple scenario where the user should have access only to the ‘English’ translations of ‘Recipe’ contents. The scope can be defined as ‘recipe’ and the identifier as ‘en’.


 access_check.test_access_policy.recipe:
    class: Drupal\test_access_policy\Access\RecipeAccessPolicy
    tags:
      - { name: access_policy }




/**
 * RecipeAccessPolicy class.
 */
class RecipeAccessPolicy extends AccessPolicyBase {

  public function applies(string $scope): bool {
    return $scope === 'recipe';
  }

  /**
   * {@inheritdoc}
   */
  public function calculatePermissions(AccountInterface $account, string $scope): RefinableCalculatedPermissionsInterface {
    $calculated_permissions = parent::calculatePermissions($account, $scope);

    $req_permissions = [
      'create recipe content',
      'edit any recipe content',
    ];
    $calculated_permissions->addItem(
      item: new CalculatedPermissionsItem(
        permissions: $req_permissions,
        scope: 'recipe',
        identifier: 'en'
      ),
      overwrite: FALSE
    );

    return $calculated_permissions;
  }

  /**
   * {@inheritdoc}
   */
  public function getPersistentCacheContexts(): array {
    return ['languages'];
  }

}

The applies() method ensures that the above access policy is only applicable in the ‘recipe’ scope. The ‘scope’ and ‘identifier’ values are also passed along with the permissions to edit and create ‘recipe’ contents. This policy can be then invoked in the following way.

/**
 * Implements hook_node_access().
 */
function test_access_policy_node_access(NodeInterface $node, $operation, AccountInterface $account): AccessResultInterface {
  if ($node->getType() == 'recipe') {
    // Get the access policy for the given scope and identifier.
    $item = \Drupal::service('access_policy_processor')
      ->processAccessPolicies($account, 'recipe') // Gets all access policies in the 'recipe' scope.
      ->getItem('recipe', $node->language()->getId()); // Scope = 'recipe', Identifier = language of the node.

    if ($item && $item->hasPermission('edit any recipe content')) {
      return AccessResult::allowed();
    }
  }
  return AccessResult::forbidden();
}

In this way, The ‘edit any recipe content’ permission would be available only if the node’s language is ‘en’ so that non English recipe contents won’t be editable by the user.

Conclusion

The new access policy is definitely a worthy addition to the ever-evolving Drupal core. It’s more robust and efficient than the existing systems, and I hope this blog has given you a better understanding of it. Here is a quick summary of all topics explored in this blog.

  • Role Based Access Checking and it’s limitations.
  • Access Policy API and how it works.
  • How to use Build Phase and Access Phase to dynamically update the permissions.
  • Scopes and Identifiers and how to use them.

The code used in this blog can be found at: Github Link

Written by
Editor
No art workers.
We'd love to talk about your business objectives