Editorial roles permissions
Goal
Provision a site's editorial roles (user.role.*) and their content permissions — choosing a single content_editor or a two-tier author/editor split, granting the correct own-vs-any permission set per content bundle (and, when a workflow exists, its transition permissions) through idempotent recipe config actions — so editors can do exactly their job and nothing more, and the grants survive config export/import.
Opinion
Compose roles from own/any permission pairs. The author tier gets the own side (create, edit own, delete own, view own unpublished); the editor tier gets the any side plus publishing. This own/any split — not a special module — is what separates the tiers. Source: guide drupal/editorial-workflow/editorial-role-permission-model.
Cite a shipped reference, don't invent roles. Drupal CMS ships a single content_editor; the core demo_umami profile ships the two-tier author/editor. Match whichever fits the site and diverge only with a reason. Note there is no content_author role in Drupal CMS — do not cite one. Source: guide drupal/editorial-workflow/editorial-role-permission-model.
Grant through config actions, never bake permissions into role YAML. Use grantPermissions (and grantPermissionsForEachNodeType for per-bundle scaling) with strict: false, so grants compose across recipes and re-apply cleanly. Source: guides drupal/editorial-workflow/editorial-role-permission-model, drupal/recipes/config-actions-entity-specific.
Editorial roles are never is_admin. is_admin: true bypasses all permission checks — that is the administrator posture. An editorial role keeps is_admin: false and enumerates exactly the permissions it needs. Source: guide drupal/editorial-workflow/editorial-role-permission-model.
Workflow-transition permissions are consumed, not authored here. When a moderation workflow exists, the editor tier receives its use {workflow} transition {id} permissions — but this recipe does not create the workflow; it takes the transition IDs as input. Source: guide drupal/editorial-workflow/editorial-role-permission-model.
What this recipe refuses
- Granting the author tier
edit any/delete anypermissions. - Granting
bypass node accessoradminister nodesto an author- or editor-tier role. - Setting
is_admin: trueon an editorial role. - Baking the permission list into
user.role.*.ymlby hand instead of via config actions. - Citing a
content_authorrole as a Drupal standard (it does not ship). - Granting a
use {workflow} transition {id}permission before that workflow/transition exists.
Preconditions
- Drupal 10.4+ or 11.x; the core
userandnodemodules. - The content bundles the roles will act on exist.
- If transition permissions are in scope, the moderation workflow exists first (see the content-moderation recipe).
- Config export is in use, so the roles and grants are deployable.
- DDEV runs the site; the verifier's commands go through
ddev.
Input contract
Generic, source-agnostic, supplied by the caller (adapter skill, human operator, or orchestrator).
roles:
- id: string # e.g. content_editor, author, editor
label: string
tier: string # author (own) | editor (any + publish) | custom
bundles: # content types this role acts on
- string
extra_permissions: # subsystem grants beyond content (media, taxonomy…)
- string
workflow_transitions: # optional; consumed, not created here
workflow_id: string
grant_to_role: string # usually the editor tier
transition_ids:
- string
Sequence
If invoked in dry-run mode, perform all reads and derivations but emit a preview instead of writing config. Dry-run is required.
-
Resolve the role model. Decide single
content_editorvs two-tierauthor/editorfrom input; map each role to a shipped reference where one fits. Seedrupal/editorial-workflow/editorial-role-permission-model. -
Create each role if absent —
user.role.{id}withid/label/weightandis_admin: false; if present, skip creation. Seedrupal/security/permissions-and-roles. -
Derive the per-bundle permission set by tier. Author tier → own-scoped grants; editor tier → any-scoped grants plus publish, where publish means the workflow's transition permissions from step 5. Without a workflow the editor tier does not publish, because core then needs
administer nodes, which this recipe refuses. Expand acrossbundles. Seedrupal/editorial-workflow/editorial-role-permission-model. -
Grant via config actions —
grantPermissions(orgrantPermissionsForEachNodeTypefor all-bundle scaling) withstrict: false; addextra_permissionsto the tier that needs them. Never hand-edit the role's permission list. Seedrupal/recipes/config-actions-entity-specific. -
Grant workflow-transition permissions if supplied — add
use {workflow_id} transition {id}for eachtransition_idsentry togrant_to_role. Do not create the workflow. Seedrupal/editorial-workflow/editorial-role-permission-model. -
Export configuration, rebuild caches, and emit a summary of roles created/no-op and the permissions granted per role, flagging any requested permission that does not exist (e.g. a transition whose workflow is absent).
Data flow
input: roles[] (id, label, tier, bundles[], extra_permissions[])
workflow_transitions (workflow_id, grant_to_role, transition_ids[])
reads project state:
user.role.* (existing roles + current grants)
node type list (bundles to expand own/any grants across)
workflows.workflow.* (to validate transition IDs, when supplied)
applies opinion (guardrails):
own/any-by-tier · cite-shipped-reference · grant-via-config-actions ·
never-is_admin · transitions-are-consumed-not-authored
references atomic detail (guides):
drupal/editorial-workflow/editorial-role-permission-model
drupal/security/permissions-and-roles
drupal/recipes/config-actions-entity-specific
emits:
user.role.<id> (is_admin: false; permissions via grantPermissions)
per-bundle content grants (own for author, any+publish for editor)
use <workflow> transition <id> (on the editor tier, when supplied)
State-awareness contract
The recipe reads existing state before writing. For each role: absent → create; present and matching → skip, log no-op; present and differing in identity (label/weight/is_admin) → conflict, do not overwrite, request operator review. Permission grants are additive — granting a permission a role lacks is a create; the recipe never revokes a permission implicitly.
Granting a use {workflow} transition {id} permission whose workflow or transition does not exist is a conflict, surfaced for review, not silently written — it depends on the content-moderation recipe having run first.
Idempotent: running the recipe twice on identical input and identical project state produces no changes on the second run.
Verifier
Each entry is one command, run from the project root after the recipe ran. It is split on spaces and never run through a shell. A non-zero exit fails the entry, whatever pass says. stdout empty reads standard output only; a non-interactive ddev drush keeps Drush's messages on standard error. A stopped DDEV project prints its start-up text on standard output, so start the site before the verify run. Entries that call .aida/editorial-roles-permissions/verify.php run the script in ## Files. It prints one line per violation and exits non-zero when it printed any.
verifier: - id: roles-exist-not-admin kind: config-assert run: ddev drush php:script /var/www/html/.aida/editorial-roles-permissions/verify.php -- roles {roles:json} pass: stdout empty - id: tier-permissions kind: config-assert run: ddev drush php:script /var/www/html/.aida/editorial-roles-permissions/verify.php -- tiers {roles:json} pass: stdout empty - id: transition-permissions kind: config-assert run: ddev drush php:script /var/www/html/.aida/editorial-roles-permissions/verify.php -- transitions {workflow_transitions:json} pass: stdout empty - id: active-equals-export kind: config-assert run: ddev drush config:status pass: stdout empty
What the entries do not prove, and where the proof is:
tier-permissionsrequires, per bundle,create,edit ownanddelete ownon the author tier, andedit anyanddelete anyon the editor tier. It refusesedit anyanddelete anyon the author tier, andbypass node accessandadminister nodeson both. It skips thecustomtier. It does not check thatextra_permissionswere granted, but the refusal reads every permission the role holds, sobypass node accessoradminister nodesgranted as an extra still fails it.- Core has no per-bundle node permission to publish. The editor tier's publishing is its transition permissions, which
transition-permissionschecks. Without a workflow, publishing is not checked. transition-permissionschecks nothing whenworkflow_transitionsis absent. When it is given,grant_to_rolemust hold each requested transition permission and no other of that workflow's, and each must be a permission the site defines.active-equals-exportproves the export is current after Sequence step 6. The state-awareness contract makes a second apply a no-op on that state.
Files
One script, which the consumer writes before the verifier runs and removes after it. Do not edit it or commit it. Drush runs it with Drupal booted and passes the words after -- in $extra.
```php .aida/editorial-roles-permissions/verify.php
| tiers