The open-source marketing CMS
Menu

Custom roles and fine-grained permissions for Spillway: analysis and design

Status: decided 2026-11-12 (see section 6), being built in phases. Paths are relative to the repository root, except those that start with kwall-drupal-starter/ (KWALL's Drupal starter, the reference for what sites use). Goal: "great RBAC like Drupal": roles admins add, edit and remove, and permissions specific to features, content types, fields, vocabularies, menus, forms and modules.

1. Today in Spillway

Roles and permissions

  • Five fixed roles: type Role = 'viewer' | 'author' | 'editor' | 'manager' | 'admin' (packages/shared/src/index.ts:161).
  • 23 permissions in a string union (index.ts:194-217): 8 content.* (view_drafts, create, edit_own, edit_any, delete, submit_review, publish, unpublish), content.translate, media.upload|delete, types.manage, site.settings, users.manage, forms.manage|view_submissions, ai.use, agents.use|manage, sites.manage, registry.publish, imports.manage, views.share.
  • ROLE_PERMISSIONS (index.ts:219-239) is a compile-time constant: viewer 2, author 8, editor 15, manager 20, admin 23 (docs/reference/permissions.md, generated by packages/docs-tools/src/reference.ts:278-334, which also holds the permission descriptions in PERMISSION_NOTES). hasPermission(roles, p) (index.ts:241) reads the constant directly.
  • Permission names are coarse. Across core/src/routes, mcp and agents, the literal checks are: site.settings 107, users.manage 53, content.view_drafts 39, imports.manage 23, types.manage 18, content.publish 18, the rest under 15. site.settings gates 30 files: brand (15), personalization (12), modules (10), redirects (7), google (7), editorial (6), sections, placements, menus, AI settings, CDN, kits, TMS, Stripe, search, theme, fonts, DAM. users.manage also gates SSO (9), registry review (14), mail (4), system (3) and module install.
  • Nothing is per content type, per vocabulary (term CRUD needs types.manage, routes/vocabularies.ts:46-114), per menu (menus need site.settings, routes/menus.ts), per form, or per field. Delete is "any" only (content.delete). Workflow transitions map onto submit_review, publish (also reject, schedule, workstream merge, alerts) and unpublish (also archive).

How a request gets its permissions

  1. Session (core/src/auth/session.ts:39-43,109): permissionsFor(user) = union of ROLE_PERMISSIONS over the user's global roles.
  2. Bearer (core/src/auth/bearer.ts:103-105): token permissions ∩ the user's global-role permissions (auth/tokens.ts:61-65).
  3. Site middleware (core/src/auth/site.ts:48-62) recomputes per site: sitePermissions = permissions of (global roles ∪ site roles from user_site_role), narrowed by the token (line 51-53); section roles (user_section_role) grant SECTION_PERMISSIONS (index.ts:3407) inside the section only, and their SECTION_AMBIENT_PERMISSIONS subset (index.ts:3412) site-wide (lines 54-60). The result lands in c.get('permissions') and c.get('access') (core/src/context.ts:17-33).
  4. Repos: core/src/repos/access.ts stores role arrays as JSON and filters them through a hard-coded ROLES list (cleanRoles, lines 12-18), so an unknown role key is silently dropped.

Where enforcement lives

  • Guards (core/src/auth/guards.ts): can / requirePermission (lines 13-21, 269 requirePermission call sites in core), canSiteWide (39), sectionPermissions (44), canOnEntity / requireEntityPermission (64-74), requireEditAccess (80-90, own vs any, site-wide or section), requireViewAccess (93-99), allowedCreateSections (102), listScope (108). Entity routes use them at routes/entities.ts:109-250.
  • MCP and the Site agent: each tool declares permissions / anyPermission (core/src/mcp/tools/core.ts:14,154-166, 22 declarations in mcp/tools/*.ts); runTool is the single check. MCP has its own requireEditAccess (mcp/tools/core.ts:74-78) that ignores section roles.
  • Agents: AgentRole = 'author' | 'editor' | 'manager' (index.ts:1339); a run gets permissionsForRoles([agent.role]) ∩ the asking person's permissions (core/src/agents/run.ts:49-54); tasks use the owner's global ∪ site roles (agents/tasks.ts:150). Only users.manage may give an agent Manager or Publish (routes/agents.ts:97-99).
  • Tokens: POST /api/tokens refuses permissions the user lacks, computed from global roles only (routes/tokens.ts:43-46), so a site-only user cannot mint a token for their site permissions.
  • Admin: useMe().can uses the server's list and falls back to hasPermission(user.roles, ...) (packages/admin/src/hooks/useMe.tsx:63-70); 160 can('...') calls in 67 files; sidebar entries carry a permission (admin/src/shell/Shell.tsx:34). The Users screens hard-code the five roles and their help text (admin/src/screens/users/UserDetail.tsx:23-46, ApiTokens.tsx:18,68, settings/SsoTab.tsx:18).

Other places that store role names

User.roles, UserSiteRole, UserSectionRole (index.ts:173,3373-3374); SSO defaultRoles and groupRoleMap (index.ts:2434) and SCIM group mapping (core/src/scim/service.ts:229,428-430); block style roles (index.ts:3066); shared views roles (index.ts:4617); Agent.role. Direct ROLE_PERMISSIONS / hasPermission lookups that bypass the site middleware: auth/session.ts:41, auth/tokens.ts:61, auth/bearer.ts:103, notifications/outbox.ts:195 (reviewer emails ignore site roles), routes/sites.ts:72, routes/google.ts:99, agents/run.ts:50, cli.ts:217, and the admin files above.

Findings worth fixing first

  • Site role escalates to instance admin. A site role admin on site B puts users.manage and sites.manage into the request permissions on site B (auth/site.ts:53). POST /api/users and PUT /api/users/:id only call requirePermission(c, 'users.manage') and accept global roles (routes/users.ts:22,78-80,197-203), so a site-only admin can create a global admin or give themselves global admin; /api/sites, SSO and system routes are likewise reachable (routes/sites.ts:34-61). No test covers it (test/multisite.test.ts). Fix: permissions get a scope, and instance-scoped ones come only from global roles (section 3.2).
  • No last-administrator guard. Only "cannot remove your own admin role" (routes/users.ts:203) and "cannot delete your own account" (236). Two admins can demote each other to zero; SCIM can replace roles wholesale (scim/service.ts:428-433).

Gaps against Drupal

DrupalSpillway today
Roles are config entities admins create, edit, deleteFive constants in code
Permissions declared by every module, per bundle generated23 hand-written strings
create/edit own/edit any/delete own/delete any <type> contentOne set for all types; no delete_own
Per-vocabulary term permissionstypes.manage for all terms
Per workflow transition, per workflowThree fixed actions
field_permissions: view/edit per field, opt-in per fieldNone
Per text formatFixed sanitizer (richtext/sanitize.ts)
Permissions page: roles as columns, grouped by moduleRead-only table in the docs
Group module: roles inside a departmentSection roles (fixed role names, content only)

2. The KWALL Drupal starter

The site configuration is not in config/ (empty) but in kwall-drupal-starter/web/modules/custom/kwall_starter_helper/site-configs/ with variants full-feature-edu (1,634 files), city, portal, news-site-fixes. Access modules enabled (core.extension.yml): content_moderation, field_permissions, group + gnode, flexible_permissions, scheduler, content_lock, userprotect, masquerade; the portal adds permissions_by_term.

Site roles (user.role.*.yml, full-feature-edu)

RolePermissionsMake-up
administratoris_admin: true (all, including future ones)none listed
anonymous4access content, view media, 2 text formats
authenticated134 text formats, create image media, break content lock, admin theme
content_editor52node create 8, node edit any 8, workflow transitions 3, text formats 4, media 5, media/entity browsers 13, drafts and scheduling 5, admin UI 6
content_publisher56editor + homepage (create, edit any) + transitions publish, archived_published
group_member30media, browsers, text formats, admin UI; real rights come from group roles

Patterns, with examples:

  • Per content type: 12 node types (alert, article, event, homepage, landing_page, layout_builder, location, page, person, program, social_post, testimonial). Editors get create page content and edit any page content for 8 of them; publishers 9 (adds homepage). Nobody but the administrator may create alerts or social posts or delete any node: the per-type split is used as much to withhold types as to grant them. No edit own / delete own grants at all.
  • Per workflow transition: workflow editorial (states draft, review, published, archived; transitions create_new_draft, review, publish, archive, archived_published) applied to article, homepage and page only. Editor: use editorial transition create_new_draft|review|archive; publisher adds publish and archived_published. Note the editor may archive but not publish.
  • Per vocabulary: 15 vocabularies, no term permission granted to any role (administrator only).
  • Per field: field_permissions is enabled, but all 153 field storages in full-feature-edu are permission_type: public. The portal variant marks node.field_audience custom and grants view field_audience to anonymous and authenticated (and no one but the administrator may edit it). That is the realistic use: a few sensitive fields, opt-in.
  • Per text format: 8 formats; editors get 4 (use text format full_html included).
  • Feature/admin pages: access toolbar, view the administration theme, access content overview, schedule publishing of nodes, view scheduled content, use moderation sidebar, editoria11y, and 13 access <browser> entity browser pages.
  • Per term (portal): permissions_by_term on the audience vocabulary restricts node viewing by term.
  • Group roles (department delegation): group type site_section with 9 group roles; the working ones are group_editor (10: create group_node:page entity, update any, delete own, transitions draft and review), group_publisher (13: adds delete any, publish, archive, archived_published) and group_administrator (15). Group content is page only. This is what Spillway's section roles mirror, but Drupal's group roles are themselves configurable and per type.

Takeaways: Drupal sites use per-type create/edit, per-transition publish rights, a locked "is_admin" role, opt-in field permissions on a handful of fields, and delegated roles inside a section. Spillway needs those, plus feature permissions finer than site.settings.

3. Proposed model

3.1 Roles become data

  • New table role (migration 0059_roles.ts): key (primary, ^[a-z][a-z0-9_]{0,62}$), label, description, permissions (JSON string[]), preset (built-in key or null), is_admin (bool), weight, site_id (null = instance role; reserved for site-local roles, section 7), created_at, updated_at.
  • The migration seeds the five rows from ROLE_PERMISSIONS, which stays in shared renamed ROLE_PRESETS (the source for "Reset to default" and fresh installs). Keys do not change, so user.roles, user_site_role, user_section_role, SSO maps, SCIM, block styles, shared views and agents keep working with no data migration.
  • admin gets is_admin: true: it holds every permission in the catalog, including ones added later, and cannot be edited or deleted (Drupal's administrator). Label stays "Administrator".
  • Contract: type Role = string (RoleKey), plus interface RoleDef { key; label; description; permissions: string[]; preset: string | null; isAdmin; userCount }. cleanRoles (repos/access.ts:15) validates against the role table instead of the constant.
  • Roles are instance-wide like content types and vocabularies (which per-type permissions reference). Assignments stay global, per site and per section, as today. Hosting teams (docs/dev/self-serve-hosting.md:94) then grant any role key on a site, not only the presets.

3.2 The permission catalog

One function in packages/shared builds the catalog from code plus the instance's model: buildCatalog({ types, vocabularies, menus, forms, modules }) → PermissionDef[], where PermissionDef = { key, label, description, group, scope: 'instance' | 'site', sectionable?: boolean, ambient?: boolean, risky?: boolean, implies?: string[] }. Descriptions move from docs-tools/src/reference.ts:280 into it; docs/reference/permissions.md keeps being generated from it.

Static feature permissions (each new one is granted by the migration to every role that held its parent, so nothing changes for existing sites):

TodaySplit into
site.settingssite.settings (name, SEO, general), theme.manage, brand.manage, menus.manage, redirects.manage, sections.manage, placements.manage, personalization.manage, integrations.manage (Google, CDN, DAM, Stripe, mail), ai.settings, tms.manage, kits.install
users.manageusers.manage (site), roles.manage (instance), sso.manage (instance), tokens.manage_all, system.manage (instance), registry.review
site.settings on modulesmodules.manage (instance, risky)
content.deletecontent.delete_any (alias of the old key) and new content.delete_own
content.publishkeeps publish; adds content.schedule, content.reject, workstreams.merge, alerts.manage
content.unpublishkeeps unpublish; adds content.archive, content.restore

Generated permissions (Drupal's per-bundle pattern). Specific keys always have three or more segments, so they never collide with the two-segment generic ones:

  • Per content type: content.<type>.view_drafts|create|edit_own|edit_any|delete_own|delete_any|submit_review|publish|unpublish|schedule|archive (11 per type).
  • Per vocabulary: terms.<vocab>.create|edit|delete; generic terms.manage; vocabulary definitions stay under types.manage.
  • Per menu (site): menus.<key>.edit; generic menus.manage.
  • Per form (site): forms.<key>.manage|view_submissions; generic forms.manage|view_submissions.
  • Per field, opt-in: FieldDef.access?: 'public' | 'custom' (default public, like field_permissions). Only custom fields generate field.<type>.<field>.view|edit, keeping the matrix small.
  • Per module: new ModuleManifest.permissions?: { key, label, description, risky? }[] (index.ts:1651), registered as module.<name>.<key>; module routes and admin pages declare them; the SDK exposes ctx.can(key).

Scope. scope: 'instance' permissions (sites.manage, roles.manage, sso.manage, modules.manage, system.manage, types.manage, imports.manage, global users.manage actions) are granted only by global role assignments. Site and section assignments contribute site-scoped permissions only. This closes the escalation in section 1.

3.3 Resolution: one resolver with generic fallback

Shared, pure, used by core and the admin:

interface Grants { isAdmin: boolean; site: Set<string>; sections: Map<string, Set<string>> }
function can(g: Grants, perm: string, on?: { type?: string; field?: string; vocab?: string; menu?: string; form?: string; sectionId?: string | null; authorId?: string; userId?: string }): boolean

Rule: can(g, 'content.edit_any', { type: 'event' }) is true when the grants hold content.edit_any (generic, every type) or content.event.edit_any; with sectionId, the section's set is consulted too. Own/any pairs keep today's logic (guards.ts:80-90). Permissions are additive only (no deny), as in Drupal.

Mapping existing checks with minimal churn:

  • can(c, p) and requirePermission(c, p) (guards.ts:13-21) keep their signatures and gain an optional on argument; the 269 existing calls compile unchanged and behave the same for the generic keys.
  • canOnEntity, requireEntityPermission, requireEditAccess, requireViewAccess already receive the entity; EntityScope (guards.ts:57) gains type, and they pass it to the resolver. That converts every entity route (routes/entities.ts:161-250), review, bulk and workstreams in one change.
  • allowedCreateSections and listScope return { sectionIds, types }; GET /api/entities and the review queue add type IN (...) when the user only holds some types.
  • MCP: mcp/tools/core.ts drops its own requireEditAccess and calls the guard's; toolAllowed treats a generic declared permission as "the generic or any type variant" for listing, and each tool re-checks with { type } at run time.
  • Every direct ROLE_PERMISSIONS lookup listed in section 1 goes through grantsFor(user, site), which loads role definitions from an in-memory cache keyed by a role_version counter (bumped on every role write and read once per request, so multi-process deployments see changes at once).
  • GET /api/auth/me returns the grants (role keys, permission keys, section grants); the admin's useMe().can(perm, on?) uses the shared resolver and loses its hasPermission(user.roles) fallback.

3.4 Sections

A section assignment of role R grants R's permissions that are sectionable (content, per-type content, media.upload, ai.use, the section's own menu) inside the section, and the ambient subset site-wide. That generalizes SECTION_PERMISSIONS / SECTION_AMBIENT_PERMISSIONS (index.ts:3407-3412) into catalog flags, so a custom role such as "Department page editor" (content.page.create, content.page.edit_any, content.page.submit_review) works per section exactly like Drupal's group_editor.

3.5 Tokens, the Site agent and MCP

  • Tokens keep a list of permission keys (auth/tokens.ts:20) and may now hold specific ones (content.post.create). A check passes when both the user's grants and the token's list allow it: can(userGrants, p, on) && can(tokenGrants, p, on). This replaces set intersection (auth/site.ts:51, bearer.ts:104), which breaks once generic keys imply specific ones.
  • Token creation validates each requested key with the resolver against the user's grants on the current site (fixes routes/tokens.ts:43, which ignores site roles). A token never holds instance permissions unless the user holds them globally.
  • Agents: Agent.role accepts any role key. Choosing a role that holds a risky permission or content.publish keeps today's rule (routes/agents.ts:97-99) under roles.manage. Run permissions = role grants ∧ person grants (agents/run.ts:49-54), evaluated with the same two-sided can.
  • AI never publishes: the agent's publish action still needs both the action switch and the permission.

3.6 Field-level enforcement

For fields with access: 'custom':

  • Write (one choke point): WriteOptions in content/entities.ts already carries allowedSectionIds; add grants. In prepareUpdate (around line 140) and createEntity (165), for each custom field the actor may not edit: on update, a submitted value must deep-equal before.data[key] or the save fails with 403 field_forbidden naming the fields; a missing key keeps the stored value; on create the field takes its default. The same check runs in prepareSuggestedVersion (suggestions), revision restore (restoring keeps the current value of locked fields), bulk, workstream saves, translations and AI drafts. System actors (importer, kits, scheduler) pass grants: null and bypass it. The 17 other callers of createEntity / updateEntity get the guard through WriteOptions, not route by route.
  • Read: a single redactEntity(entity, grants) applied in the entity serializers: GET /api/entities and /:id, revisions and diff, by-path, MCP entity tools and resources, exports, webhooks, the admin search index and AI context. Fields the user may not view are removed, and the response lists them in redactedFields so the editor can say so.
  • Admin: the entity form receives fieldAccess: Record<fieldKey, 'edit' | 'view' | 'none'> and renders view-only fields read-only (Puck already supports readOnly, index.ts:151), hides none, and shows a lock icon with "Your role cannot edit this field".
  • Public output stays out of scope at first: rendering and /api/public/* show what the type renders. If needed later, add an anonymous pseudo-role (and member for site accounts) whose view grants filter the public API, with cache keys unaffected because they apply to everyone.

3.7 Migration

  1. Seed role rows from ROLE_PRESETS; mark admin is_admin.
  2. For every split permission, add the new keys to each role holding the parent (site.settings → its 12 children, and so on), so effective access is identical.
  3. Rename content.delete to content.delete_any in roles and tokens (keep the old key as an alias in the resolver for one release).
  4. Token rows keep their keys; the alias table maps old keys to the new set at check time.
  5. Test that, for every preset and every route in the coverage map, the before and after decisions are equal.

4. API and admin UI

Endpoints (contract in packages/shared, documented in docs/api.md)

  • GET /api/permissions → catalog grouped (Content, Content: <type>, Fields, Taxonomy, Menus, Forms, Media, Site, Users and access, AI and agents, Modules, System), with scope, risky and source (core or module).
  • GET /api/roles → RoleDef[] with user counts (global, site, section). POST /api/roles (key, label, description, permissions, copyFrom?), GET/PUT/DELETE /api/roles/:key, POST /api/roles/:key/reset (presets only), PUT /api/roles/permissions (matrix save: { [role]: { add: string[]; remove: string[] } }, one transaction).
  • Permission: roles.manage (instance, admin preset only). Every write audited: role.create, role.update (before and after plus the added and removed keys), role.delete, role.reset. Writes bump role_version.
  • Assignment endpoints stay (PUT /api/users/:id, /site-roles, /section-roles) and accept any role key; their audit rows are unchanged.

Safety rails

  • admin (is_admin) cannot be edited, renamed or deleted.
  • At least one active user holds global admin: enforced on user update, delete, deactivation, SCIM role replacement and role deletion (409 last_admin).
  • You cannot remove your own admin role (exists, routes/users.ts:203), and a matrix save that would remove roles.manage or users.manage from your own effective grants needs confirm: true.
  • No escalation: a non-admin may only grant permissions they hold and only assign roles whose permissions are a subset of their own on that site; global roles and instance permissions need a global users.manage / roles.manage.
  • Deleting a role in use returns 409 with the counts unless reassignTo is given; references in SSO maps, SCIM mappings, block styles, shared views and agents are rewritten or removed in the same transaction and listed in the audit row.
  • Removing a content type, vocabulary, menu, form or field deletes its generated keys from roles and tokens; unknown keys are ignored by the resolver and shown as "No longer exists" in the matrix with a clean-up button.

Admin screens (packages/admin/src/screens/users/, documented in docs/getting-started.md, Users)

  • Users → Roles: list with label, description, user count, "Preset" or "Custom" badge; New role (blank or copy of another), Edit (label, description, the role's permissions grouped with checkboxes, Drupal's per-role page), Reset to default for presets, Delete with reassign.
  • Users → Permissions: the matrix. Roles as columns (Administrator first, locked and all checked), permissions as rows in collapsible groups with one section per content type (its 11 actions), per vocabulary, menu, form and module; a filter box; "Changed only" toggle; a generic grant shows the specific rows as checked and dimmed ("granted by Edit any content"); risky rows carry a warning tag; sticky header and first column; Save shows a summary ("Editor: +3, -1") before applying.
  • User detail: role pickers become checkbox lists from GET /api/roles (global, per site, per section) instead of the fixed list and help (UserDetail.tsx:23-46); the access summary is computed with the resolver. The single nested-role choice in Create user (passwordLinks.test.ts asserts nesting) becomes a multi-select with presets first.
  • SSO and SCIM group maps (SsoTab.tsx:18), block styles "Who may pick it", shared views and Agents → Role read the role list.
  • Content type editor: per field, "Restrict with permissions" (sets access: 'custom') linking to the matrix rows.

5. Phases

PhaseSizeScopeTests that prove it
0. Fix scope and admin guardsSInstance-scoped permissions only from global roles; last-admin guard; MCP uses the guard's requireEditAccess; tokens validate against site grantsA site-only admin gets 403 on POST /api/users with global roles, PUT /api/users/:id roles, /api/sites, SSO; demoting or deactivating the last admin is 409 (REST and SCIM); a section editor can edit their section's page over MCP
1. Roles as data, one resolverMrole table and seed, RoleKey = string, catalog and resolver in shared, grantsFor with the version cache, every direct ROLE_PERMISSIONS use replaced, /api/auth/me grants, admin useMeGolden test: each preset resolves to exactly today's permission set on every site and section; full suite and smoke tests unchanged; unknown role keys are rejected; role cache refreshes after a write
2. Roles CRUD and the matrixMRoles API, Roles and Permissions screens, dynamic role pickers (users, SSO, SCIM, styles, views, agents), safety rails, audit, docs and reference regenerationAPI tests for create, edit, copy, reset, delete with reassign, escalation refusals, audit rows; a custom role assigned on one site grants only there; e2e smoke: create a role, tick a permission, a user with it sees the screen
3. Feature permissionsMSplit site.settings, users.manage, content.delete, content.publish, content.unpublish; migration grants children to parents' holders; route and tool updates; mcp/coverage.tsA coverage test fails when a route or tool names a key missing from the catalog; per-feature tests (a role with only redirects.manage can manage redirects, nothing else); migration test: before and after decisions equal for presets
4. Per content typeLGenerated content.<type>.*, EntityScope.type, list and review queue filtering, bulk, workstreams, scheduling, archive, delete own/any, sections, MCP and agents, tokens with specific keysA role × type × action matrix over REST and MCP; list endpoints return only permitted types; section + type combination; a token with content.post.create cannot create a page; agent run limited by both sides
5. Vocabularies, menus, forms, modulesMterms.<vocab>.*, menus.<key>.edit (folding the section menu rule into it), forms.<key>.*, manifest permissions and SDK ctx.canTerm CRUD per vocabulary; a menu editor edits one menu only; a form reader reads one form's submissions; a module's declared permission appears in the catalog and gates its route
6. Field permissionsLFieldDef.access, write guard in content/entities.ts, redactEntity everywhere, admin read-only widgets, type editor switchChanging a locked field is 403 over REST, MCP, bulk, suggestions and restore while other fields save; hidden fields are absent from entity, list, revision, diff, export, webhook and MCP output and from admin search; e2e: the widget is read-only
7. WorkflowsLConfigurable workflows: states (with which one is live), transitions with from and to states, per content type; workflow.<wf>.<transition> permissions; the default workflow equals today's steps; the editor's action bar, review queue, scheduler, bulk, releases and the AI tools follow the type's workflowA type on the default workflow behaves exactly as today; a custom workflow (draft, legal review, published) allows only its transitions, each gated by its permission, over REST, MCP and bulk; history records transitions
8. OptionalS to MAnonymous and member pseudo-roles for public field visibilityDefined when chosen

Phases 0 to 2 deliver "custom roles admins add, edit and remove" (per site, decision 1); 3 to 6 deliver Drupal-level granularity; 7 delivers configurable workflows. Each phase keeps pnpm test, the Postgres lane and the smoke tests green and updates CHANGELOG.md, docs/api.md, docs/getting-started.md and the generated reference.

6. Risks and decisions for the owner

Risks:

  • Matrix size. 12 types × 11 actions alone is 132 rows. Mitigation: collapsible per-type sections, generic rows that cover all types, opt-in field permissions.
  • Leaks around field redaction. Search snippets, diffs, revision history, AI context, exports and webhooks all carry entity data; one missed serializer exposes a field. Mitigation: one redactEntity plus a test that walks every entity-returning route and tool from mcp/coverage.ts.
  • Escalation through role editing. Delegated roles.manage without the subset rule lets a manager grant themselves anything; keep roles.manage admin-only by default.
  • Contract change. Role widening from a union to string affects typed clients of packages/shared and packages/client (MIT); call it out in the changelog.
  • Performance. Per-request grants now read role definitions; the version-counter cache keeps it to one small query.

Decisions (Kevin, 2026-11-12):

  1. Roles are per site. Each site has its own role definitions: the five presets are seeded on every site (and on new sites) and can be edited per site, and a site can add its own roles. A global role assignment (user.roles) resolves against each site's definition of that key; a custom role exists on one site and is assigned there as a site or section role. Instance permissions (sites, SSO, mail, backups, registry, trusted modules, roles of the instance) come only from the global admin role, which is locked. Section 3.1 changes accordingly: role.site_id is required, the primary key is (site_id, key), and the role cache is keyed by site.
  2. A save that changes a locked field is refused with 403 field_forbidden naming the fields.
  3. Restricted fields on the public site: out of scope for now (default).
  4. Administrators only manage roles and the permissions matrix (roles.manage stays in the locked admin role; not delegable).
  5. Preset names stay (viewer, author, editor, manager, admin); labels are editable per site.
  6. Configurable workflows now. A phase is added after per-type permissions: workflows with states and transitions (Drupal's workflows and content_moderation), assigned per content type, each transition a permission (workflow.<workflow>.<transition>), replacing the fixed draft, review, published and archived steps; the default workflow reproduces today's behavior exactly.
  7. The Phase 0 escalation fix ships first, on its own (kwall-llc/spillway-cms#138).

Spillway 0.1.0 · Edit this page on GitHub

Was this page helpful?