Drupalwoo ☉

Creating a custom Drupal permission for granular access control

Drupal roles group permissions into broad jobs such as author, editor and administrator. They work well for general capabilities, but a content-heavy site eventually needs a narrower rule: who may view a procurement brief without gaining the ability to edit or export it. A custom permission creates that named capability, while a separate access check decides where it applies.

That distinction matters for Australian teams working across Sydney, Melbourne, Brisbane and regional offices, particularly on corporate, education and government sites. A procurement officer in Melbourne may need one restricted brief, while a trainer in Brisbane should see only published learning material. A shared administrator role makes both situations awkward and creates unnecessary exposure. Named accounts and least-privilege roles also provide clearer accountability when a document is downloaded or changed.

Access control sits within wider information-governance duties. The Australian Privacy Act 1988 and Australian Privacy Principles matter when procurement files contain personal information, while authorisation rules determine who can operationalise those controls. A Drupal permission is not a privacy policy, retention schedule or consent record. It should be defined, enforced and tested as an explicit part of that framework.

Control approach Where it is enforced Level of control Good fit Common limitation
Standard role permission Core route and entity checks Content type or operation Broad shared capabilities Bundles often share visibility
Custom permission with route requirement Route configuration One route or action Dedicated landing pages Does not protect other routes
Custom permission with entity access Entity access logic Node type, bundle and fields Restricted records Needs careful listing and cache testing
Field-level access Field access hooks Individual fields Particularly sensitive fields Complex and may not hide related metadata

Define the access boundary before coding

Start with the business capability, not a screen name. “View restricted procurement brief” describes an action, whereas “Procurement panel” names a group of people and may become inaccurate when responsibilities change. Choose a stable machine name such as view_restricted_procurement_brief and record which content type, route and operations it covers.

Separate viewing from editing, publishing, deleting and exporting when those duties differ. A user who can read a brief for a decision often has no reason to alter its wording or send it to a mailing list. Matching permissions to operations prevents a useful read capability from turning into a broader privilege.

Then identify the content boundary. A procurement brief might use the procurement_brief content type and an field_office_scope field containing au_national, nsw, vic or qld. The permission can grant the base capability, while the content check applies the office rule. For public-sector material, state records or freedom-of-information duties may also affect whether a document should be restricted or published.

Create the permission in a custom module

Place the definition in a small custom module so it can be versioned and deployed consistently. This example uses hook_permission(), which works across supported Drupal versions:

<?php

/**
 * Implements hook_permission().
 */
function procurement_access_permission() {
  return [
    'view_restricted_procurement_brief' => [
      'title' => 'View restricted procurement brief',
      'description' => 'View procurement briefs available to the user office scope.',
    ],
    'export_restricted_procurement_brief' => [
      'title' => 'Export restricted procurement brief',
      'description' => 'Export procurement briefs available to the user office scope.',
    ],
  ];
}

Save the code as procurement_access.module. Recent Drupal releases can also read permissions from a module-level permissions YAML file, so use the format already used by the project rather than maintaining both sources. Reuse the permission description in staff guidance so “restricted” has a precise meaning.

Clear Drupal’s caches after enabling the module and confirm the permission appears under People > Permissions. Treat its machine name as a public API: changing it later requires updating role configuration, access checks, tests and any saved links. Use lowercase letters, numbers and underscores, and avoid names tied to temporary project phases.

Assign roles without widening access

Drupal attaches permissions to roles, and users may hold more than one. Create narrow roles such as Procurement Reviewer, Procurement Editor and Procurement Publisher, then grant only the capabilities each role needs. A reviewer might receive the view permission, while a publisher receives view and publish but not export. Avoid granting new permissions to Authenticated User merely to make an early test pass.

Make role changes part of onboarding and offboarding, not an occasional database correction. Assign the role to the named person, document the approving manager and remove it when the duty ends. Drupal normally recalculates permission access on subsequent requests, but cached pages and stale role mappings from an external identity system can preserve old access, so revoke access through the normal role workflow and verify the affected routes.

Preview the interface as a restricted user, not as the site administrator. Hiding a link in JavaScript does not protect its destination, and an administrator will usually bypass the exact restriction you are trying to test. Permission-aware menus improve usability, while server-side checks remain the control that decides whether a request succeeds.

Enforce the permission at the access boundary

Defining a permission does not enforce it. Drupal must call the permission check when a user opens the entity, uses a custom route, requests an export or queries a listing. The example below covers direct node viewing; apply the other permissions at their own route or operation handlers:

use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Session\AccountInterface;

function procurement_access_entity_access(EntityInterface $entity, $op, AccountInterface $account) {
  if ($entity->getEntityTypeId() !== 'node' ||
      $entity->bundle() !== 'procurement_brief' ||
      $op !== 'view') {
    return AccessResult::neutral();
  }

  $access = AccessResult::allowedIfHasPermission(
    $account,
    'view_restricted_procurement_brief'
  );

  return $access->addCacheableDependency($entity);
}

The example grants the capability to every procurement brief of that type. Add the office-scope comparison before allowing access: read field_office_scope on the node and compare it with the user’s authorised scope. Return AccessResult::forbidden() for a mismatch and include both the node and account as cacheable dependencies. Keep this mapping in configuration or a dedicated access service, rather than scattering state abbreviations through modules.

Check every exposure path. A standard node route, JSON:API response, search result, attachment download, preview, print view and administrative listing can bypass one another if each has different logic. Entity access checks may not automatically remove titles from a Views query, so configure an equivalent list filter or a tested node-grant implementation and rebuild access records when that route is used. For custom browser controls, JavaScript action controls can organise the interface, but they remain cosmetic unless backed by the same server check.

Protect cached responses as carefully as uncached responses. A restricted page shared through a full-page cache can reach the wrong person unless cache variation includes the permission and scope, or the route is excluded from shared caching. Test response headers, reverse-proxy rules and cache invalidation after a role change. The same rule applies to bulk exports: the export job must run with an account whose export permission and scope have been deliberately defined.

Test access using realistic Drupal scenarios

Create test accounts for an anonymous visitor, an authenticated user without the role, reviewers in different office scopes, an editor and a publisher. Use a non-administrator account for every restriction test. Include unpublished, revision and archived briefs because lifecycle state can alter both route behaviour and query results.

A focused release check can cover:

Then test error semantics and cache behaviour. A 403 response confirms that a URL exists, which may itself be sensitive; a 404 can conceal that fact for selected routes. Confirm that page caches vary by the right context and that removing a role takes effect on the next request. Add browser-driven tests for the common paths and service-level tests for exports or API responses.

Keep the test fixtures small but realistic, including a national brief, a Victorian brief and a Queensland brief. That gives the suite a repeatable boundary instead of relying on whichever document happened to be uploaded before deployment. Record the expected result for each account, content type, scope and operation, then rerun those cases after Drupal core, contributed modules or permission configuration changes.

Review permissions as part of site operations

Permissions are configuration and should have an owner. Document the business purpose, eligible roles, scope rule, approver and review date beside the module code. Export role configuration into version control and include permission changes in the same release process as code. Periodic reviews should catch dormant accounts, temporary contractors and roles left behind after a staffing change.

An operational review can include:

Australian privacy expectations make careful access review particularly valuable. The Privacy Act 1988 and the Australian Privacy Principles can require organisations to protect personal information and demonstrate accountable governance. If exports feed email campaigns, also check the Spam Act 2003, consent records and unsubscribe handling; a Drupal permission cannot make an otherwise non-compliant mailing list permissible.

Distributed support adds another local operational detail. Teams can cover Sydney, Melbourne and Brisbane across daylight-saving changes, so an emergency handover should not depend on one shared administrator login. Use named accounts, role-specific on-call access and a recorded approval to restore a permission temporarily. The result is an access model that remains explicit, testable and auditable long after launch.