Access Policy API

January 26, 2024 - 6 min read

Drupal's robust architecture for role-based access control is highly effective, yet it does come with some limitations. Although several contributed modules such as Content Access, Field permissions, Permissions by Term or Flexible Permissions exist to address these limitations, from 10.3 we can utilize the built-in functionality of Drupal core to achieve the same outcome.

TL;DR; - Jump to the 'How to' section

What are Access Policies?

Access Policies are tagged services that can add or remove permissions for users, based on globally available contexts. This functionality is closely tied to Drupal core's existing system known as Cache Context, which access policies actively depend on granting us endless possibilities to alter the behaviour of our site.

How does it work?

The API introduces 2 new concepts, from which the first is the build and alter phase. During the build phase, policies are processed resulting in a set of permissions that can be altered before transforming into immutable objects (preventing unauthorized tweaking through code during runtime), providing the capability to adjust the access policies of other modules, including core. These objects are then stored in the variation cache (and the user.permissions cache context), meaning you can have a different set of active permissions depending on the time of day, the domain, or other contextual elements.

The other new concept the API is coming with is scopes and identifiers which allows you to handle more complex client requests, like different content editing permissions based on domain, or the limitation of node creation to office hours. Every permission that is being generated by the new Access Policy API applies to a given scope and identifier. The default for Drupal is AccessPolicyInterface::SCOPE_DRUPAL for both. If you want to create your own scope and identifier, you would need to define those in the processAccessPolicies() or getItem() call.

You can read more about the system in this change record.

What's under the hood

The main thing behind the scenes is a service collector called AccessPolicyProcessor, which is responsible for gathering all services that are tagged with access_policy.

First, a decision is made whether the access policy applies to the defined scope, then an initial and an "end-of-process" (or final) evaluation is made to determine whether the current policy at hand should be applied to the system, based on the given cache context. This process is required to store the set of permissions in the variation cache.

During the build phase, the RefinableCalculatedPermissions object holds the information of all permissions for all scopes along with the cacheable metadata. When the build phase ends, this is turned into an immutable CalculatedPermissions object.

To add new items to the refinable calculated permissions object during the build phase, we can add the permissions using the CalculatedPermissionsItem object, which we will see later in the example.

How to define a custom access policy

Defining a custom access policy should be as simple as creating

  • a cache context that we will assign to our policy
  • and a designated service tagged with access_policy.

In our example, we will restrict the content team's ability to work on weekends. Before access policies, achieving this outcome could be quite challenging using the existing permission system. However, with the introduction of the new API, all you would need is a service and a cache context to make it happen:

modules/custom/your_module/src/Cache/Context/IsWeekendCacheContext:

<?php

namespace Drupal\your_module\Cache\Context;

use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Cache\Context\CacheContextInterface;

/**
 * Defines a cache context that determines whether today is a weekend.
 *
 * Cache context ID: 'is_weekend'.
 *
 * @CacheContext(
 *   id = "is_weekend",
 *   label = @Translation("Is Weekend?"),
 *   cacheTags = {},
 *   dependencies = {},
 * )
 */
class IsWeekendCacheContext implements CacheContextInterface {

  /**
   * {@inheritdoc}
   */
  public static function getLabel() {
    return t('Is Weekend?');
  }

  /**
   * {@inheritdoc}
   */
  public function getContext() {
    $result = static::isWeekend() ? 'yes' : 'no';

    return "is_weekend.{$result}";
  }

  /**
   * Returns whether it is a weekend.
   * 
   * @return bool
   *   Returns TRUE if it is a weekend today
   */
  public static function isWeekend() {
    return date('w', time()) % 6 === 0;
  }

  /**
   * {@inheritdoc}
   */
  public function getCacheableMetadata() {
    return new CacheableMetadata();
  }

}

Since we are creating a cache context, don't forget to tag it as one in your_module.services.yml file:

modules/custom/your_module/your_module.services.yml:

services:
  your_module.cache_context.is_weekend:
    class: Drupal\your_module\Cache\Context\IsWeekendCacheContext
    tags:
      - { name: cache.context }

Now the remaining task is to develop the actual access policy service, which will leverage the cache context we established above so it can handle the permissions properly:

modules/custom/your_module/Access/WeekendEditingAccessPolicy.php:

<?php

declare(strict_types = 1);

namespace Drupal\your_module\Access;

use Drupal\your_module\Cache\Context\IsWeekendCacheContext;
use Drupal\Core\Session\AccessPolicyBase;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Session\CalculatedPermissionsInterface;
use Drupal\Core\Session\CalculatedPermissionsItem;

/**
 * Allows content editors to create articles when it's a weekday.
 */
class WeekendEditingAccessPolicy extends AccessPolicyBase {

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

    if (IsWeekendCacheContext::isWeekend() || !in_array('content_editor', $account->getRoles())) {
      return $calculated_permissions;
    }

    return $calculated_permissions->addItem(new CalculatedPermissionsItem([
      'create article content',
      'edit own article content',
    ]));
  }

  /**
   * {@inheritdoc}
   */
  public function getPersistentCacheContexts(string $scope): array {
    return ['is_weekend'];
  }

}

Again, access policies are tagged services, which will be only processed if we tag it as an access_policy. Therefore the updated services.yml would look like:

modules/custom/your_module/your_module.services.yml:

services:
  your_module.cache_context.is_weekend:
    class: Drupal\your_module\Cache\Context\IsWeekendCacheContext
    tags:
      - { name: cache.context }
  your_module.access_policy.weekend_editing:
    class: Drupal\your_module\Access\WeekendEditingAccessPolicy
    tags:
      - { name: access_policy }

Now that we've got everything sorted out, let's dig into the Access Policy service and see what's happening there. If you take a close look, the calculatePermissions() method doesn't do anything if it's a weekend or if we're not dealing with an account designated as a content editor. On weekdays, things get interesting as the policy adds two new permissions to the overall set (using the CalculatedPermissionsItem, which adds the permissions to the refinable calculated permissions object during build phase) allowing content editors to freely create and edit their articles.

Thoughts

Building a site's permissions following policies is a really nice improvement for Drupal. While the above could be done without this feature, the new approach simplifies the process and is far more efficient in my opinion. This opens up opportunities for clients, and it is always better to explain that their goal is easily achievable than delving into complexities and added costs.


Profile picture

Written by Balint Pekker, a software engineer being really good at turning caffeine into code.