[policy/docs, no patch] Document the new access policy API

Created on 16 August 2023, over 1 year ago
Updated 17 November 2023, about 1 year ago

Using this issue as a temporary place to put the new documentation.

How to write a custom access policy for Drupal core

Understanding how access policies work

Each access policy is a way to grant or revoke permissions based on any given environmental variable. This can range from info about the currently logged in user, the time of day, the active URL, etc. If this sounds familiar, that's because it is! We currently already have a similar system in core called "cache contexts" and it should therefore come as no surprise that access policies actively rely on these.

It's important to note that these access policies are all processed during a build phase, after which they are cached (using cache contexts) and served to any request that matches the same environmental criteria. Once built, permissions are passed around as an immutable object, meaning there is no way for any code to alter someone's permissions while handling the request. This is a necessary safety precaution to make sure that the omnipresent user.permissions cache context behaves as expected.

Step 1: Deciding what your policy varies by

Because the system uses cache contexts internally, it's important that you figure out based on what criteria you will be granting or revoking permissions. For our example, we will be allowing people to talk like a pirate on Pirate Day. This means we need a way to figure out whether it's Pirate Day and inform Drupal about that.

Lucky for us, Drupal's tests already come with a cache context that facilitates this, so let's copy that one because test modules aren't generally turned on during normal site operations.


namespace Drupal\ap_example\Cache;

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

/**
 * Defines the PirateDayCacheContext service that allows to cache the booty.
 *
 * Cache context ID: 'pirate_day'.
 */
class PirateDayCacheContext implements CacheContextInterface {

  /**
   * The length of Pirate Day. It lasts 24 hours.
   *
   * This is a simplified test implementation. In a real life Pirate Day module
   * this data wouldn't be defined in a constant, but calculated in a static
   * method. If it were Pirate Day it should return the number of seconds until
   * midnight, and on all other days it should return the number of seconds
   * until the start of the next Pirate Day.
   */
  const PIRATE_DAY_MAX_AGE = 86400;

  /**
   * {@inheritdoc}
   */
  public static function getLabel() {
    return t('Pirate day');
  }

  /**
   * {@inheritdoc}
   */
  public function getContext() {
    $is_pirate_day = static::isPirateDay() ? 'yarr' : 'nay';
    return "pirate_day." . $is_pirate_day;
  }

  /**
   * Returns whether or not it is Pirate Day.
   *
   * To ease testing this is determined with a global variable rather than using
   * the traditional compass and sextant.
   *
   * @return bool
   *   Returns TRUE if it is Pirate Day today.
   */
  public static function isPirateDay() {
    return !empty($GLOBALS['it_is_pirate_day']);
  }

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

}

Don't forget to declare this cache context as a service in ap_example.services.yml:

services:
  cache_context.pirate_day:
    class: Drupal\ap_example\Cache\PirateDayCacheContext
    tags:
      - { name: cache.context }

Step 2: Writing your access policy

Now that we have a way to tell Drupal that someone's permissions may vary based on whether or not it's Pirate Day, we need to actually start handing out permissions on Pirate Day. So below is the full access policy, which we'll dissect further down.


namespace Drupal\ap_example\Access;

use Drupal\ap_example\Cache\PirateDayCacheContext;
use Drupal\Core\Session\AccessPolicyBase;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Session\CalculatedPermissionsInterface;
use Drupal\Core\Session\CalculatedPermissionsItem;

/**
 * Allows people to talk like a pirate on Pirate Day.
 */
class PirateDayAccessPolicy extends AccessPolicyBase {

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

    if (!PirateDayCacheContext::isPirateDay()) {
      return $calculated_permissions;
    }

    return $calculated_permissions->addItem(new CalculatedPermissionsItem(['talk like a pirate']));
  }

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

}

One thing of note is that this is purely an example. In a well designed system, there would be a dedicated service that returns whether it's Pirate Day and both the cache context and access policy would rely on said service.

Anyways, let's go over the above example, shall we? You can see from the calculatePermissions() method that we don't do anything if it isn't Pirate Day. If it is Pirate Day, however, we add the talk like a pirate permission to the total set of permissions. We'll go over the value objects being used in the API explanation further down.

Then, because we need to inform the system that we are returning different results based on the fact that it is or isn't Pirate Day, we declare our cache context in getPersistentCacheContexts().

Just one final thing, though: We need to make Drupal aware of our access policy, by adding it to our services file. Our final services file now looks like this:

services:
  access_policy_pirate_day:
    class: Drupal\ap_example\Access\PirateDayAccessPolicy
    tags:
      - { name: access_policy }
  cache_context.pirate_day:
    class: Drupal\ap_example\Cache\PirateDayCacheContext
    tags:
      - { name: cache.context }

That's it! It's really that simple to add an access policy like this to Drupal. To see a more elaborate example where the permissions depend on certain data objects and need to add these objects' cache tags to the whole, have a look at the UserRolesAccessPolicy that comes with Drupal core.

The API explained

RefinableCalculatedPermissions and CalculatedPermissions

This value object holds the sum of all permissions for all scopes (see below) along with all the cacheable metadata that was added during calculation of the permissions. After the build phase, the RefinableCalculatedPermissions object is turned into a CalculatedPermissions object, which is immutable for reasons explained at the top of this documentation.

CalculatedPermissionsItem

This value object allows you to add permissions to the RefinableCalculatedPermissions. You can add an array of permissions names or, alternatively, flag that the account should have admin access by setting the second parameter to TRUE. (See SuperUserAccessPolicy in core).

$scope and ::applies()

This concept does not apply to core, but it does make it so that the entire access policy system is resuable in contrib modules such as Group, Domain, Commerce Stores, etc.

Out of the box, all access policies only apply to the "drupal" scope and identifier. You don't see this in the code example above because the base class and interfaces default to this scope in as many places as possible. This is why the above example module looks so clean and most of your access policies will too.

You are free to declare as many scopes as you want and gather permissions for that scope. For instance, the Domain module might want to keep track of who can do what to each individual domain. It would therefore have a scope called "domain" and within said scope have each domain's machine name as an identifier. Then, it could make it so only the domains you are allowed to view or edit show up on the overview of domains.

For a far more complex use case, you could dig into the Group module's code as it has already been using a similar system for a while now and has three dedicated scopes to allow it to do its job.

πŸ“Œ Task
Status

Fixed

Version

11.0 πŸ”₯

Component
DocumentationΒ  β†’

Last updated 1 day ago

No maintainer
Created by

πŸ‡§πŸ‡ͺBelgium kristiaanvandeneynde Antwerp, Belgium

Live updates comments and jobs are added and updated live.
Sign in to follow issues

Comments & Activities

Production build 0.71.5 2024