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): 8content.*(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 bypackages/docs-tools/src/reference.ts:278-334, which also holds the permission descriptions inPERMISSION_NOTES).hasPermission(roles, p)(index.ts:241) reads the constant directly.- Permission names are coarse. Across
core/src/routes,mcpandagents, the literal checks are:site.settings107,users.manage53,content.view_drafts39,imports.manage23,types.manage18,content.publish18, the rest under 15.site.settingsgates 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.managealso 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 needsite.settings,routes/menus.ts), per form, or per field. Delete is "any" only (content.delete). Workflow transitions map ontosubmit_review,publish(also reject, schedule, workstream merge, alerts) andunpublish(also archive).
How a request gets its permissions
- Session (
core/src/auth/session.ts:39-43,109):permissionsFor(user)= union ofROLE_PERMISSIONSover the user's global roles. - Bearer (
core/src/auth/bearer.ts:103-105): token permissions ∩ the user's global-role permissions (auth/tokens.ts:61-65). - Site middleware (
core/src/auth/site.ts:48-62) recomputes per site:sitePermissions= permissions of (global roles ∪ site roles fromuser_site_role), narrowed by the token (line 51-53); section roles (user_section_role) grantSECTION_PERMISSIONS(index.ts:3407) inside the section only, and theirSECTION_AMBIENT_PERMISSIONSsubset (index.ts:3412) site-wide (lines 54-60). The result lands inc.get('permissions')andc.get('access')(core/src/context.ts:17-33). - Repos:
core/src/repos/access.tsstores role arrays as JSON and filters them through a hard-codedROLESlist (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, 269requirePermissioncall 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 atroutes/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 inmcp/tools/*.ts);runToolis the single check. MCP has its ownrequireEditAccess(mcp/tools/core.ts:74-78) that ignores section roles. - Agents:
AgentRole = 'author' | 'editor' | 'manager'(index.ts:1339); a run getspermissionsForRoles([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). Onlyusers.managemay give an agent Manager or Publish (routes/agents.ts:97-99). - Tokens:
POST /api/tokensrefuses 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().canuses the server's list and falls back tohasPermission(user.roles, ...)(packages/admin/src/hooks/useMe.tsx:63-70); 160can('...')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
adminon site B putsusers.manageandsites.manageinto the request permissions on site B (auth/site.ts:53).POST /api/usersandPUT /api/users/:idonly callrequirePermission(c, 'users.manage')and accept globalroles(routes/users.ts:22,78-80,197-203), so a site-only admin can create a global admin or give themselves globaladmin;/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
| Drupal | Spillway today |
|---|---|
| Roles are config entities admins create, edit, delete | Five constants in code |
| Permissions declared by every module, per bundle generated | 23 hand-written strings |
create/edit own/edit any/delete own/delete any <type> content | One set for all types; no delete_own |
| Per-vocabulary term permissions | types.manage for all terms |
| Per workflow transition, per workflow | Three fixed actions |
field_permissions: view/edit per field, opt-in per field | None |
| Per text format | Fixed sanitizer (richtext/sanitize.ts) |
| Permissions page: roles as columns, grouped by module | Read-only table in the docs |
| Group module: roles inside a department | Section 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)
| Role | Permissions | Make-up |
|---|---|---|
| administrator | is_admin: true (all, including future ones) | none listed |
| anonymous | 4 | access content, view media, 2 text formats |
| authenticated | 13 | 4 text formats, create image media, break content lock, admin theme |
| content_editor | 52 | node 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_publisher | 56 | editor + homepage (create, edit any) + transitions publish, archived_published |
| group_member | 30 | media, 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 contentandedit any page contentfor 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. Noedit own/delete owngrants 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 addspublishandarchived_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_permissionsis enabled, but all 153 field storages in full-feature-edu arepermission_type: public. The portal variant marksnode.field_audiencecustomand grantsview field_audienceto 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_htmlincluded). - Feature/admin pages:
access toolbar,view the administration theme,access content overview,schedule publishing of nodes,view scheduled content,use moderation sidebar, editoria11y, and 13access <browser> entity browser pages. - Per term (portal):
permissions_by_termon theaudiencevocabulary restricts node viewing by term. - Group roles (department delegation): group type
site_sectionwith 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: addsdelete any,publish,archive,archived_published) and group_administrator (15). Group content ispageonly. 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(migration0059_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 renamedROLE_PRESETS(the source for "Reset to default" and fresh installs). Keys do not change, souser.roles,user_site_role,user_section_role, SSO maps, SCIM, block styles, shared views and agents keep working with no data migration. admingetsis_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), plusinterface 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):
| Today | Split into |
|---|---|
site.settings | site.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.manage | users.manage (site), roles.manage (instance), sso.manage (instance), tokens.manage_all, system.manage (instance), registry.review |
site.settings on modules | modules.manage (instance, risky) |
content.delete | content.delete_any (alias of the old key) and new content.delete_own |
content.publish | keeps publish; adds content.schedule, content.reject, workstreams.merge, alerts.manage |
content.unpublish | keeps 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; genericterms.manage; vocabulary definitions stay undertypes.manage. - Per menu (site):
menus.<key>.edit; genericmenus.manage. - Per form (site):
forms.<key>.manage|view_submissions; genericforms.manage|view_submissions. - Per field, opt-in:
FieldDef.access?: 'public' | 'custom'(default public, likefield_permissions). Only custom fields generatefield.<type>.<field>.view|edit, keeping the matrix small. - Per module: new
ModuleManifest.permissions?: { key, label, description, risky? }[](index.ts:1651), registered asmodule.<name>.<key>; module routes and admin pages declare them; the SDK exposesctx.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)andrequirePermission(c, p)(guards.ts:13-21) keep their signatures and gain an optionalonargument; the 269 existing calls compile unchanged and behave the same for the generic keys.canOnEntity,requireEntityPermission,requireEditAccess,requireViewAccessalready receive the entity;EntityScope(guards.ts:57) gainstype, and they pass it to the resolver. That converts every entity route (routes/entities.ts:161-250), review, bulk and workstreams in one change.allowedCreateSectionsandlistScopereturn{ sectionIds, types };GET /api/entitiesand the review queue addtype IN (...)when the user only holds some types.- MCP:
mcp/tools/core.tsdrops its ownrequireEditAccessand calls the guard's;toolAllowedtreats 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_PERMISSIONSlookup listed in section 1 goes throughgrantsFor(user, site), which loads role definitions from an in-memory cache keyed by arole_versioncounter (bumped on every role write and read once per request, so multi-process deployments see changes at once). GET /api/auth/mereturns the grants (role keys, permission keys, section grants); the admin'suseMe().can(perm, on?)uses the shared resolver and loses itshasPermission(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.roleaccepts any role key. Choosing a role that holds ariskypermission orcontent.publishkeeps today's rule (routes/agents.ts:97-99) underroles.manage. Run permissions = role grants ∧ person grants (agents/run.ts:49-54), evaluated with the same two-sidedcan. - AI never publishes: the agent's
publishaction still needs both the action switch and the permission.
3.6 Field-level enforcement
For fields with access: 'custom':
- Write (one choke point):
WriteOptionsincontent/entities.tsalready carriesallowedSectionIds; addgrants. InprepareUpdate(around line 140) andcreateEntity(165), for each custom field the actor may not edit: on update, a submitted value must deep-equalbefore.data[key]or the save fails with 403field_forbiddennaming the fields; a missing key keeps the stored value; on create the field takes its default. The same check runs inprepareSuggestedVersion(suggestions), revision restore (restoring keeps the current value of locked fields), bulk, workstream saves, translations and AI drafts. System actors (importer, kits, scheduler) passgrants: nulland bypass it. The 17 other callers ofcreateEntity/updateEntityget the guard throughWriteOptions, not route by route. - Read: a single
redactEntity(entity, grants)applied in the entity serializers:GET /api/entitiesand/: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 inredactedFieldsso 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 supportsreadOnly,index.ts:151), hidesnone, 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 ananonymouspseudo-role (andmemberfor site accounts) whose view grants filter the public API, with cache keys unaffected because they apply to everyone.
3.7 Migration
- Seed
rolerows fromROLE_PRESETS; markadminis_admin. - 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. - Rename
content.deletetocontent.delete_anyin roles and tokens (keep the old key as an alias in the resolver for one release). - Token rows keep their keys; the alias table maps old keys to the new set at check time.
- 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 bumprole_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 (409last_admin). - You cannot remove your own admin role (exists,
routes/users.ts:203), and a matrix save that would removeroles.manageorusers.managefrom your own effective grants needsconfirm: 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
reassignTois 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.tsasserts 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
| Phase | Size | Scope | Tests that prove it |
|---|---|---|---|
| 0. Fix scope and admin guards | S | Instance-scoped permissions only from global roles; last-admin guard; MCP uses the guard's requireEditAccess; tokens validate against site grants | A 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 resolver | M | role 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 useMe | Golden 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 matrix | M | Roles API, Roles and Permissions screens, dynamic role pickers (users, SSO, SCIM, styles, views, agents), safety rails, audit, docs and reference regeneration | API 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 permissions | M | Split site.settings, users.manage, content.delete, content.publish, content.unpublish; migration grants children to parents' holders; route and tool updates; mcp/coverage.ts | A 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 type | L | Generated content.<type>.*, EntityScope.type, list and review queue filtering, bulk, workstreams, scheduling, archive, delete own/any, sections, MCP and agents, tokens with specific keys | A 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, modules | M | terms.<vocab>.*, menus.<key>.edit (folding the section menu rule into it), forms.<key>.*, manifest permissions and SDK ctx.can | Term 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 permissions | L | FieldDef.access, write guard in content/entities.ts, redactEntity everywhere, admin read-only widgets, type editor switch | Changing 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. Workflows | L | Configurable 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 workflow | A 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. Optional | S to M | Anonymous and member pseudo-roles for public field visibility | Defined 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
redactEntityplus a test that walks every entity-returning route and tool frommcp/coverage.ts. - Escalation through role editing. Delegated
roles.managewithout the subset rule lets a manager grant themselves anything; keeproles.manageadmin-only by default. - Contract change.
Rolewidening from a union tostringaffects typed clients ofpackages/sharedandpackages/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):
- 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 globaladminrole, which is locked. Section 3.1 changes accordingly:role.site_idis required, the primary key is(site_id, key), and the role cache is keyed by site. - A save that changes a locked field is refused with 403
field_forbiddennaming the fields. - Restricted fields on the public site: out of scope for now (default).
- Administrators only manage roles and the permissions matrix (
roles.managestays in the locked admin role; not delegable). - Preset names stay (viewer, author, editor, manager, admin); labels are editable per site.
- Configurable workflows now. A phase is added after per-type permissions: workflows with states and transitions (Drupal's
workflowsandcontent_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. - The Phase 0 escalation fix ships first, on its own (kwall-llc/spillway-cms#138).
Spillway 0.1.0 · Edit this page on GitHub