Access Control and Permissions in Code
|
This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production. This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages. |
Every decision about "who may do what" in Drupal is eventually a question asked in code: may this account reach this route,
see this entity, edit this field, or hold this permission at all? This page is for module developers. It shows how to
declare permissions, how to enforce them at each layer (routes, entities, fields, listings, node grants) and how the
Access Policy API, introduced in Drupal 10.3, changes where permissions come from. All the examples belong to the
meridian_collection module of the fictitious Meridian Museum site, where curators manage the museum’s collection,
partner museums see only the exhibitions shared with them, and a few extra rights depend on the season.
It was checked on 2026-10-30 against Drupal 11.4.x, PHP 8.3+ and Drush 13/14. Drupal 10 reaches end of life on 2026-12-09, so new code targets Drupal 11. Drupal 12.0.0 is scheduled for the week of 2026-12-07; the APIs on this page are part of the supported 11.x API and the code uses no deprecated call. The site-builder view of roles, the permission grid, user 1 and the Access Policy API is in Users, Roles and Permissions. Routes and the full list of core requirement keys are in Routing and Controllers, the entity type that most examples protect is built in Custom Entity Types, and the hook classes used below are explained in Hooks and OOP Hooks.
The layers of access control
Drupal has no single access check. A request passes through several independent layers, each answering a narrower question, and a feature is only as secure as the weakest layer that protects it.
Four ideas organise the rest of the page:
-
Permissions are the vocabulary. A permission is a string such as
edit collection objects. Site builders grant it to roles in the UI, so code that checks a permission, rather than a role id, keeps working when a site restructures its roles. -
Access results are the grammar. Every check returns an
AccessResultobject (allowed, forbidden or neutral) carrying cacheability metadata. Booleans are only a convenience for the final yes or no. -
Handlers and hooks are the sentences. Routes, entities and fields ask handlers for results; other modules can contribute opinions through hooks.
-
Access policies decide what a permission means for an account. Since 10.3 the set of permissions an account holds is computed by tagged services, with role permissions only one of the sources.
Declaring permissions
A module declares permissions in MODULE.permissions.yml at the module root. Core discovers the file with a YAML
discovery, so no registration is needed beyond clearing the cache. Each top-level key is the permission’s machine name,
and the value is a map.
| Key | Meaning |
|---|---|
|
Required. The name shown on the People > Permissions screen. It is translatable, so write it in English. |
|
Optional extra text shown under the title. Use it to say what the permission exposes. |
|
Optional boolean, |
|
Optional. The configuration or modules the permission depends on (for example |
|
Optional. The module that owns the permission. It defaults to the module whose file declares it, so it is normally omitted. |
The permissions of the collection entity (view, edit, delete, revisions) were declared in Custom Entity Types. The module also needs permissions that are not tied to one entity operation: one for the curator-only routes, one for the seasonal loan screens, one for a sensitive field and one for objects under embargo.
# meridian_collection.permissions.yml (additions to the entity permissions)
curate meridian collection:
title: 'Curate the Meridian collection'
description: 'Mark objects as on display, reorder galleries and start synchronisation runs.'
manage object loans:
title: 'Manage object loans'
description: 'Create and edit loan agreements. Normally granted to curators only during the loan season.'
restrict access: true
view object valuation:
title: 'View the insurance valuation of collection objects'
description: 'Shows the valuation field, which is commercially sensitive.'
restrict access: true
view embargoed collection objects:
title: 'View embargoed collection objects'
description: 'Objects acquired under an embargo are hidden from visitors until the embargo date.'
administer meridian collection settings:
title: 'Administer Meridian collection settings'
restrict access: true
permission_callbacks:
- Drupal\meridian_collection\FeaturedPermissions::permissions
Source: API: PermissionHandler
Conventions that keep a permission list maintainable:
-
Name permissions after the ability, not the screen:
view object valuation, notaccess valuation tab. Use the imperative, lower case and the plural noun style core uses (edit own exhibition content). -
One permission, one ability. A permission that opens two unrelated things forces site builders to over-grant.
-
Never check for a role. A site can rename
curatortoconservator; it cannot rename a permission without breaking the module. Keep_roleandin_array('curator', $account->getRoles())out of module code. -
Permissions are not configuration, but the roles holding them are. The grant (role to permission) is exported with the role; the declaration lives in code. Renaming or removing a permission therefore needs an update function that cleans the roles, see Update API and Post-Updates.
-
A permission nobody checks is a trap. Declare only what the code enforces, and cover it with a test.
Dynamic permissions
When the set of permissions depends on site data, such as one permission per content type, list a callback under the
reserved key permission_callbacks. Core calls each callable when it builds the permission list and merges the arrays
they return with the static ones. Static YAML entries win on a name clash.
The museum’s kiosk shows "featured" content. Which content types editors may feature should be configurable per type, so
the module generates one permission for each content type (exhibition, event, news and any type added later).
Content types are configuration entities, which makes them the right dependency: a permission generated for a deleted
type must disappear with it. The BundlePermissionHandlerTrait (available since Drupal 9.3) adds exactly that dependency
to every permission it builds.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection;
use Drupal\Core\DependencyInjection\AutowireTrait;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\BundlePermissionHandlerTrait;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\node\NodeTypeInterface;
/**
* Generates one "feature on the kiosk" permission per content type.
*/
final class FeaturedPermissions implements ContainerInjectionInterface {
use AutowireTrait;
use BundlePermissionHandlerTrait;
use StringTranslationTrait;
public function __construct(
private readonly EntityTypeManagerInterface $entityTypeManager,
) {}
/**
* Returns the permissions, keyed by machine name.
*
* @return array<string, array<string, mixed>>
*/
public function permissions(): array {
$types = $this->entityTypeManager->getStorage('node_type')->loadMultiple();
return $this->generatePermissions($types, $this->buildPermissions(...));
}
/**
* Builds the permissions of one content type.
*/
protected function buildPermissions(NodeTypeInterface $type): array {
$id = $type->id();
$args = ['%type' => $type->label()];
return [
"feature $id content on kiosk" => [
'title' => $this->t('%type: Feature content on the gallery kiosk', $args),
'description' => $this->t('Lets editors pin %type content to the kiosk home screen.', $args),
],
];
}
}
The points to take from the example:
-
The callback is resolved through the class resolver, so a class that implements
ContainerInjectionInterfacegets its services injected.AutowireTraitprovides thecreate()method for it. Do not call\Drupal::entityTypeManager(). -
Every permission produced by
generatePermissions()carriesdependencies: config: [node.type.<id>]. Without a dependency, a role keeps a grant to a permission whose type was deleted, anddrush config:importof an environment that lacks the type fails validation. -
The titles use placeholders and
$this->t(), never concatenation. -
Dynamic permissions are collected when the permission list is built and cached, so a callback that reads data other than configuration entities needs a cache rebuild (or its own invalidation) before a change shows up on the permission page.
-
Do not generate permissions from content entities such as taxonomy terms. A permission per term would change whenever an editor adds a term, is not exported with configuration, and cannot declare a stable dependency. Use a configuration entity or a settings value, or model the rule as an access policy or an entity access check.
Entities with bundles can also get their permissions from the entity type definition. Setting permission_granularity
to bundle (the default is entity_type) together with the permission_provider handler makes core generate the
standard view, create, update and delete permissions for every bundle, which is how node permissions such as
edit any exhibition content exist. Hand-written permissions remain the right choice for a simple type.
Checking permissions in code
The primitive operation is asking whether an account holds a permission.
// Anywhere you have an account object.
if ($account->hasPermission('view object valuation')) {
// ...
}
Behind that call, the account delegates to the permission_checker service, which asks the access policy processor for
the account’s calculated permissions and tests the permission against them. Three rules apply:
-
Use the account you are given. A controller method receives or injects the current user (
AccountProxyInterface), a hook receives the$accountbeing checked, and a service that must work for another user receives anAccountInterface. Checking the current user while deciding about a different one is the classic escalation bug. Static calls such as\Drupal::currentUser()belong only in procedural code and tests; in classes injectAccountProxyInterfacethrough the constructor. -
A boolean loses cacheability.
hasPermission()is correct for a decision made during a single request, such as hiding a button in a form builder, but a block, controller or access callback whose output is cached must also declare that the output varies by permissions. PreferAccessResult::allowedIfHasPermission(), which adds the cache context for you. -
Permissions are not roles. Administrator roles (
is_admin) and user 1 pass everyhasPermission()check unless the site disabled super-user access with thesecurity.enable_super_usercontainer parameter (10.3). Never writehasPermission()into a security decision and then forget to test the administrator path.
In render arrays the standard way to gate a single element is #access, which accepts a boolean or an AccessResult
object; the renderer merges the result’s cacheability into the parent. In a form builder:
$form['valuation'] = [
'#type' => 'number',
'#title' => $this->t('Insurance valuation'),
// An AccessResult keeps the cache metadata; a plain boolean does not.
'#access' => AccessResult::allowedIfHasPermission($this->currentUser, 'view object valuation'),
];
Source: Cacheability of render arrays
#access only hides output. It never replaces the server-side check of the submitted value: a form submit handler or
route that changes the valuation must itself verify the permission, because a crafted request can post a field that was
never rendered. Field access (below) is the layer that enforces this for entity fields.
Route access
Routes declare who may reach them in the requirements section of MODULE.routing.yml; the full table of core keys is
in Routing and Controllers. This page adds the points that matter for
correctness.
# meridian_collection.routing.yml
meridian_collection.curator_dashboard:
path: '/admin/content/collection/curate'
defaults:
_controller: 'Drupal\meridian_collection\Controller\CuratorController::dashboard'
_title: 'Curate the collection'
requirements:
_permission: 'curate meridian collection'
meridian_collection.loans:
path: '/admin/content/collection/loans'
defaults:
_controller: 'Drupal\meridian_collection\Controller\LoanController::list'
_title: 'Object loans'
requirements:
# A comma means AND, a plus means OR.
_permission: 'manage object loans, curate meridian collection'
meridian_collection.object_embargoed:
path: '/collection/{collection_object}/embargo'
defaults:
_controller: 'Drupal\meridian_collection\Controller\CollectionController::embargo'
_title: 'Embargo notice'
requirements:
_entity_access: 'collection_object.view'
_meridian_embargo: 'TRUE'
options:
parameters:
collection_object:
type: entity:collection_object
How the checks combine and fail:
-
Default deny. A route with no requirement that any checker recognises is denied. A public route needs
_access: 'TRUE'(a string, not a YAML boolean) or_permission: 'access content'. -
All requirements must pass. With the default
_access_mode: ALL,meridian_collection.object_embargoedneeds both_entity_accessand_meridian_embargoto allow. Setting_access_mode: ANYunderoptionsrequires only one. Inside one_permissionstring, the comma and plus operators choose AND or OR for that requirement. -
403 versus 404. Upcasting runs first, so a missing object is a 404 for every visitor, including those who could not have seen it. If the existence of an object is itself sensitive, make the entity access handler deny
viewand accept the 403, or return a 404 deliberately from a controller. -
State-changing routes need CSRF protection. A route that changes data from a link (toggling "on display", for instance) adds
_csrf_token: 'TRUE', and links to it are generated withUrl::fromRoute(), which attaches the token. AGETroute with side effects and without it can be triggered from any web page the curator visits. -
Menu links, tabs and
Url::access()use the same checks. That is the benefit of the declarative form; anAccessDeniedHttpExceptionthrown inside the controller body is invisible to them.
A custom access checker
A requirement key that does not exist yet is added with a service tagged access_check. The applies_to attribute names
the key, and the class implements the marker interface AccessInterface and exposes an access() method whose arguments
are resolved by type and name (Route, RouteMatchInterface, AccountInterface, the request and the upcast parameters).
The museum embargoes some objects until a date. Visitors may not see an embargoed object, staff with the permission may, and the answer changes by itself at midnight on the embargo date. The result therefore needs a cache max-age, which is the part developers most often forget.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Access;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Access\AccessInterface;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\meridian_collection\Entity\CollectionObjectInterface;
/**
* Hides embargoed collection objects from accounts without the permission.
*/
final class EmbargoAccessCheck implements AccessInterface {
public function __construct(private readonly TimeInterface $time) {}
/**
* Checks access for the _meridian_embargo requirement.
*/
public function access(AccountInterface $account, CollectionObjectInterface $collection_object): AccessResultInterface {
$until = $collection_object->getEmbargoUntil();
$now = $this->time->getRequestTime();
if ($until === NULL || $until <= $now) {
// No embargo, or it has expired: no opinion about the object itself.
$result = AccessResult::allowed();
}
else {
$result = AccessResult::allowedIfHasPermission($account, 'view embargoed collection objects');
// The answer flips when the embargo ends, even if nothing is saved.
$result->setCacheMaxAge($until - $now);
}
// Saving the object (a new embargo date) invalidates the cached answer.
return $result->addCacheableDependency($collection_object);
}
}
# meridian_collection.services.yml
services:
meridian_collection.access.embargo:
class: Drupal\meridian_collection\Access\EmbargoAccessCheck
autowire: true
tags:
- { name: access_check, applies_to: _meridian_embargo }
Source: Advanced route access checking
The example assumes the collection_object entity gained an embargo_until timestamp base field and a matching
getEmbargoUntil() method on its interface; adding a base field to an existing entity type is an update function, see
Update API and Post-Updates. Details that matter:
-
An access checker is a service, instantiated once. It must not keep per-request state in properties.
-
A checker returns an
AccessResultInterfaceobject (allowed, neutral or forbidden), never a plain boolean, so that its cacheability metadata is kept. -
Use
AccessResult::forbidden()only when the answer is final. If another checker may still grant access, return neutral (AccessResult::allowedIf(...)returns neutral when its condition is false). -
The upcast parameter keeps its name from the route (
$collection_object), not the class name.
Checking access outside a request
Code that must know whether a route is reachable, such as a block that lists links, uses the access manager or the Url
object instead of duplicating the rule:
$url = Url::fromRoute('meridian_collection.curator_dashboard');
// Returns a boolean by default; pass TRUE as the second argument for the AccessResult.
$result = $url->access($this->currentUser, TRUE);
$build['link'] = Link::fromTextAndUrl($this->t('Curate'), $url)->toRenderable();
$build['link']['#access'] = $result;
Source: API: Url
Using the access result as #access carries its cache metadata into the block; a plain boolean would let the first
visitor’s decision leak into the cached render of the next.
AccessResult and cacheability
All access APIs return an object implementing AccessResultInterface. It has three states, and the distinction between
the last two is the most important thing to understand about Drupal access control.
| State | Meaning |
|---|---|
Allowed |
This checker positively grants access. |
Neutral |
This checker has no opinion. It neither grants nor denies; other checkers decide. If every checker is neutral the final answer is "no access". |
Forbidden |
This checker denies access and no other checker can override it. |
Results are created with static factories and combined with orIf() and andIf():
| Factory or method | Behaviour |
|---|---|
|
Create a result of that state. The optional reason is for developers, not translated, and available through
|
|
Allowed when the condition is true, otherwise neutral. |
|
Forbidden when the condition is true, otherwise neutral. |
|
Allowed when the account has the permission (or the list, combined with |
|
Forbidden if either is forbidden; otherwise allowed if either is allowed; otherwise neutral. Used to add grants. |
|
Forbidden if either is forbidden; allowed only if both are allowed; otherwise neutral. Used to require several conditions. |
|
Test the state. Only |
Source: API: AccessResult
The common mistake is to use forbiddenIf(!$condition) where allowedIf($condition) was meant, or the reverse. A rule
"curators may edit unless the object is archived" is allowedIfHasPermission(...)->andIf(AccessResult::allowedIf(!$archived)),
whereas "nobody, not even other modules, may delete an object on loan" is forbiddenIf($onLoan) because only forbidden
beats the grants other modules add.
Why cacheability is part of the result
Access decisions are cached: inside the render cache of the block or page that displays a link, in the dynamic page cache
and in the entity access handler’s per-request static cache. A cached decision is correct only for the situations it was
computed in, so each result carries three kinds of metadata, merged automatically by orIf() and andIf():
| Metadata | Use it when | Example |
|---|---|---|
Cache contexts |
The answer depends on the request or the user. |
|
Cache tags |
The answer depends on stored data. |
|
Max-age |
The answer depends on time. |
|
Rules of thumb:
-
If the result depends on it, declare it. Reading a field of the entity, a configuration value or the current time, without declaring the matching tag, context or max-age, produces access that is correct for the first visitor and wrong for later ones. This is the origin of reports such as "the curator sees the button only after a cache clear".
-
Declare it on every branch. Return paths that exit early, such as a neutral result when the entity is of another bundle, still depend on whatever was read to decide that.
-
Contexts widen, they do not narrow.
useris more specific thanuser.permissions; asking foruseron a frequently shown element multiplies cache variations. Choose the narrowest context that is true. -
allowedIfHasPermission()is not enough when other data matters. The curator access example in the routing page adds the entity as a cacheable dependency for exactly that reason. -
Never use
user.permissionsfrom inside an access policy. The context is computed from the permissions the policy is building, which would recurse. Useuser.rolesor another context instead (see below). -
Do not disable caching to make a bug go away.
setCacheMaxAge(0)on an access result makes every page that contains it uncacheable in the dynamic page cache. Debug the missing metadata instead. The caching system itself is in Performance and Caching and Render API and Cache Metadata.
Source: Cacheability of render arrays
Entity access
Entity access answers "may this account perform this operation on this entity?" for the whole system: the
_entity_access route requirement, the operation links of listings, Views, the REST and JSON:API resources, the entity
forms and $entity->access('update') calls in code. The entity type definition names the handler class, and the handler
usually extends EntityAccessControlHandler. The complete handler for the collection object is in
Custom Entity Types; this section covers how its answer is assembled and how
other code takes part.
The order has consequences:
-
Hooks run first and their results are combined with
orIf(): any module can grant an operation by being allowed and any module can veto it by being forbidden. A neutral hook result changes nothing. -
The handler’s
checkAccess()is added withorIf()only when no hook forbade the operation, so a veto from another module cannot be overturned by your handler. -
The handler’s default
checkAccess()grants everything to holders of the entity type’sadmin_permission, forbidsdeleteon entities that are not yet saved, and otherwise returns neutral. Always callparent::checkAccess()first and return its result when it allows, or an administrator loses access you did not mean to remove. -
Creation is separate:
createAccess()callshook_entity_create_accessandhook_ENTITY_TYPE_create_access, thencheckCreateAccess(), because there is no entity yet to ask about. -
Revisionable entities add operations (
view all revisions,view revision,revert revision,delete revision) and the handler forbids reverting or deleting the default revision on its own.
Implementing entity access hooks as classes
Hooks let another module change who may work with an entity it does not own. Since Drupal 11.1 they are methods on a
class in src/Hook, marked with the #[Hook] attribute; see
Hooks and OOP Hooks. The museum rule: an exhibition that is published
cannot be deleted by editors, only unpublished first. Administrators keep the right. The same class also hides the
sensitive valuation field.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Hook;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Session\AccountInterface;
use Drupal\node\NodeInterface;
/**
* Access rules contributed by the Meridian collection module.
*/
final class AccessHooks {
/**
* Published exhibitions must be unpublished before they can be deleted.
*/
#[Hook('node_access')]
public function nodeAccess(NodeInterface $node, string $op, AccountInterface $account): AccessResultInterface {
if ($node->bundle() !== 'exhibition' || $op !== 'delete') {
return AccessResult::neutral();
}
$blocked = $node->isPublished() && !$account->hasPermission('administer nodes');
return AccessResult::forbiddenIf($blocked, 'Unpublish the exhibition before deleting it.')
->addCacheableDependency($node)
->cachePerPermissions();
}
/**
* Only holders of the valuation permission may see or edit the valuation.
*/
#[Hook('entity_field_access')]
public function fieldAccess(string $operation, FieldDefinitionInterface $field_definition, AccountInterface $account, ?FieldItemListInterface $items = NULL): AccessResultInterface {
if ($field_definition->getTargetEntityTypeId() !== 'collection_object' || $field_definition->getName() !== 'valuation') {
return AccessResult::neutral();
}
// Fields are allowed by default, so withholding needs an explicit veto.
return AccessResult::forbiddenIf(!$account->hasPermission('view object valuation'))
->cachePerPermissions();
}
}
Notes on the example:
-
The hook names are the old function names without the
hook_prefix:node_access,entity_field_access,collection_object_accessfor the entity-specific variant of the museum’s own type. No.modulecode is needed and#[LegacyHook]is not required on Drupal 11.1 and later. A module that still supports Drupal 10 keeps the hook function in.moduleand wraps it with#[LegacyHook]as described in the hooks page. -
The
$itemsargument of the field hook is null when the question is about the field in general (for example a field-settings form), so the code must not assume it is set. -
Field access is part of the entity API and therefore applies to forms, REST, JSON:API and the field’s formatters. A field that must be hidden belongs here and not only in a Twig template: Twig hides output, field access prevents the data from being exposed through the API at all.
-
Neutral from a field hook means "default", which for fields is allowed. A hook that wants to restrict must return forbidden, unlike entity-level hooks where neutral is the same as no.
Source: API: hook_entity_field_access()
Queries and listings
An entity query that returns entities for a user to read must say so explicitly. Drupal 10 removed the permissive default:
calling execute() without accessCheck() throws an exception. Pick the value deliberately:
// Listing for a visitor: only what the current user may view.
$ids = $this->entityTypeManager->getStorage('collection_object')
->getQuery()
->accessCheck(TRUE)
->condition('on_display', 1)
->sort('accession_number')
->execute();
// A background job that processes every record regardless of who is logged in.
$ids = $this->entityTypeManager->getStorage('collection_object')
->getQuery()
->accessCheck(FALSE)
->condition('on_display', 1)
->execute();
Source: API: EntityAccessControlHandler
accessCheck(TRUE) loads each result and runs the entity access handler (or joins the node_access table for nodes),
so it is the only variant that respects the rules in this page. accessCheck(FALSE) is correct for cron, queue workers
and migrations, and is a data leak anywhere its result reaches a response. Reading the database directly through
$connection->select() bypasses entity access, field access and node grants completely; treat every such query as a
security-sensitive code path. See Database API and Entity Queries.
Node grants
Node access has a database layer in addition to the handler: grants. A module that restricts nodes to groups of users
writes records into the node_access table when a node is saved (hook_node_access_records()), and tells core which
grants an account holds (hook_node_grants()). Core matches records and held grants when it checks a node operation and,
for listings, when a query is tagged node_access. The design is the reason Views and search can filter thousands of
nodes in one query, which a per-entity handler could not do.
The museum shares some exhibitions with partner museums. Each shared exhibition carries a field_loan_program value
(a list of integers, one per programme) and each partner curator account carries a field_partner_program value. A partner
may view only the exhibitions shared with its programme; museum curators view all of them; the general public views
exhibitions that are not in any programme.
Three points drive the design:
-
Once any module implements
hook_node_grants(), core stops writing the default "everyone can view published nodes" record. Every node therefore needs a record, including nodes the module does not care about, or ordinary visitors lose access to them. The module writes a record in a realm for "open" nodes too. -
Grants are additive. A record can only widen access. If a node must stay hidden from some users, the realms they hold must not match any of its records. Another module’s grants for the same node still apply; to take a grant away use
hook_node_access_records_alter(). -
Records ignore the published status. The module has to put the published flag into
grant_viewitself, otherwise a draft becomes visible to everyone who holds the grant.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Hook;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Session\AccountInterface;
use Drupal\node\NodeInterface;
/**
* Restricts shared exhibitions to the museum staff and the partner museum.
*/
final class LoanProgramGrants {
/** Realm for nodes shared with a partner programme; the gid is the programme. */
public const REALM_PROGRAM = 'meridian_loan';
/** Realm for every other node; the gid is always 0. */
public const REALM_OPEN = 'meridian_loan_open';
public function __construct(
private readonly EntityTypeManagerInterface $entityTypeManager,
) {}
/**
* Writes the records for a node that is being saved.
*
* @return array<int, array<string, mixed>>
*/
#[Hook('node_access_records')]
public function records(NodeInterface $node): array {
$published = (int) $node->isPublished();
$programme = $node->hasField('field_loan_program') && !$node->get('field_loan_program')->isEmpty()
? (int) $node->get('field_loan_program')->value
: 0;
return [[
'realm' => $programme > 0 ? self::REALM_PROGRAM : self::REALM_OPEN,
'gid' => $programme,
'grant_view' => $published,
'grant_update' => 0,
'grant_delete' => 0,
'langcode' => $node->language()->getId(),
]];
}
/**
* Tells core which grants this account holds for an operation.
*
* @return array<string, array<int, int>>
*/
#[Hook('node_grants')]
public function grants(AccountInterface $account, string $op): array {
$grants = [self::REALM_OPEN => [0]];
if ($op !== 'view') {
return $grants;
}
if ($account->hasPermission('curate meridian collection')) {
// Museum staff see every programme.
$grants[self::REALM_PROGRAM] = [1, 2, 3];
}
elseif ($account->isAuthenticated()) {
$user = $this->entityTypeManager->getStorage('user')->load($account->id());
if ($user !== NULL && $user->hasField('field_partner_program') && !$user->get('field_partner_program')->isEmpty()) {
$grants[self::REALM_PROGRAM] = [(int) $user->get('field_partner_program')->value];
}
}
return $grants;
}
}
Source: API: hook_node_access_records()
Operational facts about grants:
-
Records are written on save. After installing the module, changing its rules or changing the field a rule reads, existing nodes still carry the old records. Drupal then reports that the content access permissions need to be rebuilt. The rebuild rewrites the whole
node_accesstable in a batch, so run it in a quiet period and not on every deployment, only when the rules changed. -
The rebuild API moved in 11.4. The procedural
node_access_rebuild()andnode_access_needs_rebuild()are deprecated in Drupal 11.4.0 and are removed in 13.0.0. Their replacement is theDrupal\node\NodeAccessRebuildservice withrebuild(),needsRebuild()andsetNeedsRebuild(). Code in an update function or a form should inject it (or resolve it by its class name) rather than call the old function. Older books, tutorials and contributed modules still show the procedural call. -
Status report and Drush. The rebuild is also available from Reports > Status report and on the command line:
ddev drush php:eval "\Drupal::service(\Drupal\node\NodeAccessRebuild::class)->rebuild();"
ddev drush cache:rebuild
-
Node ID 0 records. A record for node ID 0 means "this realm and gid may view every node". The rebuild erases such records, so a module that relies on one must write it again after the rebuild.
-
The query tag. Queries with
accessCheck(TRUE)and Views joinnode_accessonly for nodes. Other entity types have no grants layer and rely on their handler. -
Bypass. The permission
bypass node accessand administrator roles skip grants. Do not give it to a role to "make the site work" while testing a grants module. -
Moderation and workspaces. Content moderation does not use grants for its states; it uses transition permissions, see Content Moderation, Workflows and Revisions. A workspace changes which revision a user sees, not the grants, see Workspaces.
Grants are a heavy tool. They suit "this group of users sees this group of nodes" on a large site. For a handful of
rules, a hook_node_access veto (above) or a dedicated entity type with its own handler is simpler and does not
need rebuilds.
The Access Policy API
Before Drupal 10.3, "what permissions does this account hold?" was answered in one place: the union of the permissions of
its roles, with user 1 as a special case. The Access Policy API, added in 10.3.0, turns that into a pipeline. An access
policy is a service tagged access_policy that contributes permissions to, or revokes them from, an account for a
scope. Core ships UserRolesAccessPolicy (role permissions and the is_admin flag) and SuperUserAccessPolicy (user 1,
unless security.enable_super_user is false). A module adds its own to express rules that are not "has this role":
time of year, the user’s department, the active domain, a feature flag.
The interface, Drupal\Core\Session\AccessPolicyInterface, has four methods; the abstract AccessPolicyBase implements
sensible defaults:
| Method | Purpose |
|---|---|
|
Whether the policy takes part for the requested scope. |
|
The build phase. Returns the permissions the policy grants as a |
|
The alter phase, run after every policy has built its permissions. It exists to revoke permissions. The interface
documentation says to write another policy using |
|
The cache contexts the policy’s calculation always varies by. They are used as the cache variation key for the calculated permissions of the account. |
Source: API: AccessPolicyInterface
A CalculatedPermissionsItem is created with new CalculatedPermissionsItem(array $permissions, bool $isAdmin = FALSE,
string $scope = 'drupal', string|int $identifier = 'drupal'). When $isAdmin is true the item stores no list and
hasPermission() returns true for everything in that scope and identifier. The identifier allows one scope to have many
instances: a contributed Domain module could use the scope domain and an identifier per domain, so that an editor is
allowed to publish on one domain only. Core itself uses only the default scope and identifier.
A policy for curators
The museum rule: during the annual loan season (the loan_season flag in meridian_collection.settings), users with the
curator role additionally hold manage object loans. This is not a role grant, so the permission does not appear on the
role’s permission page, but hasPermission() returns true and the loan routes open. The shape of the policy, including
its place in the site builder’s view, is described in
Users, Roles and Permissions; here is the developer version.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Access;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Session\AccessPolicyBase;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Session\CalculatedPermissionsItem;
use Drupal\Core\Session\RefinableCalculatedPermissionsInterface;
/**
* Grants curators the loan-management permission during the loan season.
*/
final class LoanSeasonAccessPolicy extends AccessPolicyBase {
public function __construct(private readonly ConfigFactoryInterface $configFactory) {}
/**
* {@inheritdoc}
*/
public function calculatePermissions(AccountInterface $account, string $scope): RefinableCalculatedPermissionsInterface {
// AccessPolicyBase adds the persistent contexts to the new object.
$calculated = parent::calculatePermissions($account, $scope);
$settings = $this->configFactory->get('meridian_collection.settings');
// Whenever the flag changes, the cached result is rebuilt.
$calculated->addCacheableDependency($settings);
if (!$settings->get('loan_season') || !in_array('curator', $account->getRoles(), TRUE)) {
return $calculated;
}
return $calculated->addItem(new CalculatedPermissionsItem(['manage object loans']));
}
/**
* {@inheritdoc}
*/
public function getPersistentCacheContexts(): array {
// The result varies by the roles of the account, never by user.permissions.
return ['user.roles'];
}
}
# meridian_collection.services.yml
services:
meridian_collection.loan_season_access_policy:
class: Drupal\meridian_collection\Access\LoanSeasonAccessPolicy
autowire: true
tags:
- { name: access_policy }
Source: Access Policy API
The policy keys off the role id curator, which looks like the role check this page told you to avoid. The difference is that a
policy exists to implement a rule about a role or attribute of the account when no permission can express it; the
site-wide ability it grants is still a permission (manage object loans), declared in the YAML file and checked everywhere
by name. Prefer an account field, a group or a configurable setting over a hard-coded role id when the museum can model it
that way.
Revoking with the alter phase
The second museum rule: while the collections-management system is migrated, an administrator can set a freeze flag. During the freeze nobody may edit collection objects, not even holders of the permission through a role. A policy cannot "forbid" a permission in the build phase, because policies only add; it uses the alter phase to remove it.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Access;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Session\AccessPolicyBase;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Session\CalculatedPermissionsItem;
use Drupal\Core\Session\RefinableCalculatedPermissionsInterface;
/**
* Removes the editing permissions while the collection is frozen.
*/
final class CollectionFreezeAccessPolicy extends AccessPolicyBase {
private const REVOKED = ['edit collection objects', 'delete collection objects', 'create collection objects'];
public function __construct(private readonly ConfigFactoryInterface $configFactory) {}
/**
* {@inheritdoc}
*/
public function alterPermissions(AccountInterface $account, string $scope, RefinableCalculatedPermissionsInterface $calculated_permissions): void {
$settings = $this->configFactory->get('meridian_collection.settings');
// Declare the dependency even when the freeze is off, so switching it on is seen.
$calculated_permissions->addCacheableDependency($settings);
if (!$settings->get('collection_frozen')) {
return;
}
foreach ($calculated_permissions->getItems() as $item) {
if ($item->isAdmin() || $item->getScope() !== $scope) {
// Administrators keep everything, otherwise nobody could end the freeze.
continue;
}
$remaining = array_diff($item->getPermissions(), self::REVOKED);
$calculated_permissions->addItem(
new CalculatedPermissionsItem($remaining, FALSE, $item->getScope(), $item->getIdentifier()),
TRUE,
);
}
}
}
Source: Access Policy API
The second argument of addItem() overwrites the existing item for the same scope and identifier instead of
merging into it. The service entry is the same as for the previous policy, with the new class and a different service id.
The policy needs no getPersistentCacheContexts() override because the base class returns none and the result depends
only on a configuration value, which the cacheable dependency covers.
Rules that keep policies safe and fast:
-
Mark policy classes
final. The documentation advises against extending another module’s policy because a policy takes part in both phases. To change a core or contributed policy, decorate the service or replace it in a service provider. -
Declare every dependency. Contexts that always apply go in
getPersistentCacheContexts(); data-dependent ones (configuration, entities) are added withaddCacheableDependency()on the calculated object. Forgetting one keeps stale permissions in the cache of an account. The processor tags every cached result withaccess_policies, so a policy that changes its own rules can invalidate them with that tag. -
Do not use
user.permissionsas a context. It is computed from the result and would recurse. -
Be cheap. The calculation runs once per account and scope and is cached, but a slow policy delays the first request of every session after a cache clear. Avoid queries; read configuration, the account object and values already loaded.
-
Use the account you receive. The processor may calculate permissions for an account other than the current user (for example when an administrator previews, or a queue acts on behalf of a user).
\Drupal::currentUser()inside a policy would give the wrong person’s rights. -
Keep one concern per policy. Several small policies are easier to test than one policy with branches.
-
Permissions are immutable once built. Application code cannot add a permission in the middle of a request; to change the outcome, change the inputs and let the cache invalidate.
Testing access
Access code is easy to test and expensive to get wrong, so test each layer. A kernel test is enough for permissions and
policies; use a functional test (BrowserTestBase) for a route with a rendered denial page.
<?php
declare(strict_types=1);
namespace Drupal\Tests\meridian_collection\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Drupal\user\Entity\Role;
use Drupal\user\Entity\User;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
/**
* Tests the seasonal loan permission and the collection freeze.
*/
#[Group('meridian_collection')]
#[RunTestsInSeparateProcesses]
final class AccessPolicyTest extends KernelTestBase {
protected static $modules = ['system', 'user', 'meridian_collection'];
protected function setUp(): void {
parent::setUp();
$this->installEntitySchema('user');
$this->installEntitySchema('collection_object');
$this->installConfig(['meridian_collection']);
Role::create(['id' => 'curator', 'label' => 'Curator', 'permissions' => ['edit collection objects']])->save();
}
public function testLoanSeasonGrantsLoanPermission(): void {
$curator = User::create(['name' => 'curator1', 'status' => 1, 'roles' => ['curator']]);
$curator->save();
$this->assertFalse($curator->hasPermission('manage object loans'));
$this->config('meridian_collection.settings')->set('loan_season', TRUE)->save();
$this->assertTrue($curator->hasPermission('manage object loans'));
}
public function testFreezeRevokesEditing(): void {
$curator = User::create(['name' => 'curator2', 'status' => 1, 'roles' => ['curator']]);
$curator->save();
$this->assertTrue($curator->hasPermission('edit collection objects'));
$this->config('meridian_collection.settings')->set('collection_frozen', TRUE)->save();
$this->assertFalse($curator->hasPermission('edit collection objects'));
}
}
ddev exec vendor/bin/phpunit -c web/core web/modules/custom/meridian_collection/tests/src/Kernel
The tests assert the change of an outcome after a configuration save. That is what proves the cacheable dependency is
declared; a policy that forgets it would pass a single-assertion test and fail this one. PHPUnit 11 uses attributes
(#[Group]) instead of the old @group annotations. Test the entity handler and the hooks the same way: create users
with and without each permission, then assert $entity->access('update', $user), field access with
$entity->get('valuation')->access('view', $user), and the status code of the protected routes. More on the test types
is in Automated Testing.
Security checklist
-
Deny by default. Return neutral when a rule does not apply and let the system say no. Use
forbiddenonly for explicit vetoes. -
Enforce on the server, at the layer that owns the data. Hiding a link, a form element or a Twig variable is a usability feature. The route, entity, field or query must also refuse.
-
Check the right account. In a hook or a service use the
$accountargument. A cron job, a queue worker or an API call may run as a different user than the one in the session. -
Declare cacheability. Missing contexts and tags are how one user’s decision is served to another. When in doubt, prefer more specific metadata over
max-age 0. -
Use
restrict access: trueon permissions that allow privilege escalation, raw markup, configuration changes or other people’s data, and never grant them to the Authenticated user role. -
Never expose state changes on a plain GET link. Add
_csrf_tokenor use a form. -
accessCheck(FALSE)is for batch work. Every use needs a reason in a comment, and its output must not be sent to a user. -
Private files follow entity access. A private file is served only if some
hook_file_downloadimplementation allows it; the file system page shows how to tie it to an entity access check, see File System and Private Files. -
APIs obey the same handlers. JSON:API and REST apply entity and field access, so a rule written once protects the headless kiosk as well, see JSON:API.
-
Review user 1 and administrator roles. Disable the super-user access policy in production with the
security.enable_super_usercontainer parameter where the site does not need it, and keep the number of accounts withadminister permissionssmall. The wider hardening list is in Security Hardening.
Common errors and pitfalls
| Symptom | Likely cause | Fix |
|---|---|---|
A route returns 403 for everyone, administrators included. |
The |
Add |
The curator sees a button only after a cache clear. |
An access result missing a cache context, tag or max-age. |
Add |
A new permission does not appear on the permission page. |
Cache not rebuilt, YAML indentation error or key not under the module’s file. |
|
A dynamic permission survives after its content type was deleted. |
Generated without a config dependency. |
Use |
An entity query throws about missing access checking. |
|
Call |
Visitors cannot see any content after enabling a node-grants module. |
The module wrote records only for some nodes and the default "all" record is no longer written. |
Write a record for every node, as the open realm above does, and rebuild the node access table. |
Unpublished content is visible to the partner group. |
|
Use |
A veto from another module has no effect. |
The result was neutral, not forbidden, or the handler’s allowed result was combined with |
Return |
A policy-granted permission works for one user and not another. |
Missing cache context in the policy. |
Return the real contexts in |
|
|
Use |
|
The function is deprecated in 11.4.0 and removed in 13.0.0. |
Inject |
Version notes and corrections of older material
-
Access Policy API (10.3). Permissions were computed from roles only before 10.3.0. Tutorials and books written for earlier releases describe
hasPermission()as a role lookup and know nothing about policies; Drupal 10 Development Cookbook (2023) targets Drupal 10.0, which predates the API. On 10.3 and later the role lookup is only one policy. -
Hooks as classes (11.1). Older books such as Drupal 10 Development Cookbook (2023) show every access hook as a function in
.module. Since 11.1 the#[Hook]attribute on an autowired class insrc/Hookis the standard form; ordering and removal attributes arrived in 11.2.hook_install,hook_schema,hook_requirements,hook_update_Nandhook_post_update_NAMEstay procedural, but the access hooks on this page do not. -
Annotations and static calls. The same book shows plugin and entity-type annotations such as
@ContentEntityType, and\Drupal::currentUser()or\Drupal::service()calls inside classes. Plugins use PHP attributes since 10.2, and classes receive services through constructors (core aliases and#[Autowire]since 10.1).\Drupal::belongs in procedural code and tests only. -
Entity queries and
accessCheck(). Callingexecute()withoutaccessCheck()throws an exception since Drupal 10, and in 12 query objects are no longer serialisable. -
Node access rebuild API (11.4).
node_access_rebuild()andnode_access_needs_rebuild()are deprecated in 11.4.0 and removed in 13.0.0 in favour of theNodeAccessRebuildservice. Tutorials still use the function. -
Node published status (11.3). The permission "Administer node published status" is separate from editing; the site builder view is in Users, Roles and Permissions.
-
Drupal 10 end of life. Drupal 10 ends on 2026-12-09, so new access code targets Drupal 11. Drupal 12 is scheduled for the week of 2026-12-07 and requires PHP 8.5, Symfony 8 and PHPUnit 12.5; the APIs here are unchanged in principle, but run static analysis with the Drupal deprecation rules (see Preparing for Drupal 12) before upgrading.
-
Older tutorials and Drupal 7.
hook_permission()anduser_access()belong to Drupal 7. Their replacements are the YAML file andhasPermission(). Other outdated advice is collected in Where Older Books and Tutorials Are Outdated.