Skip to main content

The trinary states of Drupal access control: allowed, forbidden, neutral.

Published on

One of my favorite features of Drupal is the user access control experience. Drupal has a robust permission and role (access control list) API that allows for fine-tuned control of what users can and cannot do. Drupal developers end up interacting with Drupal's access system in one way or another. Every project has some request to enhance or alter how normal access works. When this happens, some modules (see Field Permissions) provide no-code solutions for the end user. Other times the developer taps into Drupal hooks and writes code to adjust the access result.

A common use case I have experienced is allowing content from a specific content type to be accessible to privileged users (like paywalled content.) Drupal core doesn't provide granular permissions for viewing the content of specific content types. You need to extend Drupal and use the hook_node_access hook to alter the default user access.

function mymodule_node_access(
  EntityInterface $entity,
  string $operation,
  AccountInterface $account
) {
  // Only apply if viewing our content type.
  if (
    $operation === 'view'
    && $entity->bundle() === 'special_content'
  ) {
    // Allow if the user has permission.
    $has_permission = $account->hasPermission('custom special_content permission');
    return AccessResult::forbiddenIf(
      $has_permission === FALSE
    )->cachePerPermissions();
  }
  // Otherwise, return neutral, so defaults apply.
  return AccessResult::neutral();
}

This example automatically forbids access to the content if the user is missing the custom special_content permission permission. It also returns a neutral access result for all other content types or if someone is editing or deleting the content type so the existing access checks can be performed.

Drupal's access system implements trinary logic over boolean logic. We often think of access checks in boolean logic with allowed (true) or not allowed (false.) Drupal's access system leverages trinary logic to implement a third " neutral state." This neutral state enables implementations to say, "I don't explicitly allow access, nor do I explicitly forbid it."

What they mean: Allowed, Forbidden, Neutral.

Let's dive into each of these access result states before looking at the value objects and some code examples.

  • Allowed: Something has explicitly said that access is allowed. The operation is permitted.
  • Forbidden: Something has explicitly said that access is not allowed. The operation is not permitted, and further access check processing will halt.
  • Neutral: The access result is not explicitly allowed or forbidden. The result is not considered allowed, and the operation is not permitted.

One of the most common items in code audits is improper access result checks. Developers often check for explicitly forbidden access. In reality, you must always check for explicitly allowed access. We are working with trinary states and not boolean conditions. That means your code cannot check for the opposite of what you expect (not forbidden) but what it requires (allowed.)

Here is some pseudo code to show the outcome of some trinary logic in the AccessResult value object.

$access_result = \Drupal\Core\Access\AccessResult::allowedIf(FALSE);

// It's not allowed!
assert($access_result->isAllowed(), FALSE);

// But it isn't forbidden!
assert($access_result->isForbidden(), FALSE);

// It's... neutral!
assert($access_result->isNeutral(), TRUE);

As you can see, the AccessResult value object has methods for checking its state. These are the values you perform boolean logic against. Performing boolean logic against the AccessResult object and not its result will always return true because it is an object.

assert($result instanceof AccessResultInterface);
if ($result) {
  // Do things if allowed.
}

Next, let's dive into how the access results objects work.

How the access result objects work

You might be able to look at the above code and ask why is the access result not forbidden. Why is it neutral? To start, we need to look at the abstract \Drupal\Core\Access\AccessResult class, which is a base class and factory for access result objects. That's what the code above is doing: the allowedIf method will return an access result given the boolean result of a condition. 

  public static function allowedIf(bool $condition) {
    return $condition ? static::allowed() : static::neutral();
  }

The allowedIf method returns AccessResultAllowed if true or AccessResultNeutral if false. Some may expect this to return AccessResultForbidden because the value is false. Remember, we are working with trinary logic. We didn't ask for it to be forbidden, only allowed if our condition was true

The same logic applies if we want to forbid access to the operation:

  public static function forbiddenIf(bool $condition, $reason = NULL) {
    return $condition ? static::forbidden($reason) : static::neutral();
  }

Note the $reason argument. Both forbidden and neutral access results implement AccessResultReasonInterface, allowing a descriptive reason for why the access result was in this state. In this case, we want to forbid the operation if our condition is true. If not, the access result is neutral and may prevent the operation from running if nothing explicitly allows it.

Most times, you will see usages of allowedIf. The logic is easier to contextualize when you are checking if someone can do something versus cannot. However, you may want to use forbiddenIf for cases where access is usually allowed. The most common will be checking if a user has the required access permission assigned to them from a role.

AccessResult::allowedIf(
  $account->hasPermission('create ' . $entity_bundle . ' content')
)

There is also an allowedIfHasPermission and allowedIfHasPermissions check that simplifies user permission access checks. Usage throughout Drupal core varies between using these methods or calling hasPermission on the user account object.

AccessResult::allowedIfHasPermission(
  $account, 
  'administer content'
);

The following is from the Language module and its hook_entity_field_access implementation. Users can edit entity fields as long as they have overall access to edit the entity itself. This hook allows modules to alter that default behavior, an instance of using forbiddenIf to override the default access checks. The Language module controls access to an entity's language code field if the entity has been configured for translation.

// Check if we are dealing with a langcode field.
$langcode_key = $items->getEntity()->getEntityType()->getKey('langcode');
if ($field_definition->getName() == $langcode_key) {
  // Grant access depending on whether the entity language can be altered.
  $entity = $items->getEntity();
  $config = ContentLanguageSettings::loadByEntityTypeBundle(
    $entity->getEntityTypeId(), 
    $entity->bundle()
  );
  return AccessResult::forbiddenIf(!$config->isLanguageAlterable());
}

You can also chain access results and merge in other access results. The andIf and orIf create a new access result object that combines both access result's outcomes. The following example is from the Block module and can view a block entity. Access is permitted if the block is published or if the user has administrative access.

if ($operation === 'view') {
  $access = AccessResult::allowedIf($entity->isPublished())
    ->orIf(AccessResult::allowedIfHasPermission($account, 'administer blocks'));
}

Of course, you can always return the access result you'd like instead of using the allowedIf or forbiddenIf methods:

  • AccessResult::allowed()
  • AccessResult::forbidden()
  • AccessResult::neutral()

Access result objects allow refinable cacheable metadata

Moving to value objects for access results provided the ability capture cacheable metadata. The change record and details can be found here https://www.drupal.org/node/2337377. The following paragraph summarizes the main concern:

Consequently, anything that uses access checking during rendering (most notably menus) is inherently uncacheable, because we don't know whether e.g. a link "foo" is accessible or not based on user, role, language, or whatnot. If we'd know that the accessibility result (e.g. "Foo is not accessible") is cacheable per role, for example, then we'd know we can cache this per role.

This is extremely useful when leveraging cached output for authenticated users. Drupal's Dynamic Page Cache module enables Drupal sites to be highly performant by caching the rendered output of specific page parts. This includes data that may vary based on individual user accounts or permission sets. Otherwise, this content could not be cacheable due to the uniqueness of the content.

The class AccessResult implements RefinableCacheableDependencyInterface. This interface allows objects to collect cache context, tags, and maximum cache age information, the cacheable metadata. The allowedIfHasPermission method ensures the user.permissions cache context is present. A cache context returns a value to provide cardinality to the cache. The cache may have the same cache ID as an existing cache entry but has different context values, which makes them different entries. For example, you may have a menu that contains links accessible to site administrators but not site content editors. Given the user.permissions cache context, there would be two cache entries. One for each role.

Digging deeper

If you're curious to know more or dive into more specifics, let me know via a comment! Also, you can explore the examples in Drupal core and contributed modules. Here are links to some source code examples.

 

I'm available for one-on-one consulting calls – click here to book a meeting with me 🗓️

#