Skip to content

feat(auth): system.role_grants chooses which groups hold the admin-tier relations - #141

Merged
zumbrunn merged 57 commits into
mainfrom
feat/configurable-role-grants
Nov 10, 2026
Merged

zumbrunn merged 57 commits into
mainfrom
feat/configurable-role-grants

Conversation

@zumbrunn

Copy link
Copy Markdown
Member

Closes #139. Stacked on #138.

  • system.role_grants (system.yaml): group → admin | editor | author. Default unchanged (each of the three groups holds its own relation). Remap a group (staff: editor), keep a subset, or {} for none. Invalid values fail at load.
  • The bootstrap reconciles: every group → admin-tier tuple on the site belongs to this setting, so a grant removed from config is revoked at the next start (HMAC stores: dune authz:sign).
  • EditorIdentity.relation: the relation core decided, so the author limit (own pages only) follows the mapping, not role names.
  • The start-up note and dune doctor --boot access line follow the mapping.
  • Tests: reconcile (default, {}, remap, subset, idempotent), config validation, the WebSocket gate with a remapped group, the resolver's relation.

Companions: plugin-admin (seeds direct tuples only for its own accounts, so {} is not undone by it), plugin-inline-edit, plugin-api, dune-docs.

🤖 Generated with Claude Code

zumbrunn and others added 30 commits November 6, 2026 22:47
…dmin settings

Core's history engine, job state, search analytics and public sessions use the
runtime directory, and the history engine uses max_revisions, so they cannot
live under admin: when the admin is optional. They are system.runtime_dir and
system.max_revisions now, resolved by resolveRuntimeDir() and
resolveMaxRevisions(). The old admin.* keys, in both spellings, are refused with
the new location (step 1 of the admin config plan).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ix slot) (#123, step 2)

Core no longer reads admin.path. The admin plugin provides the admin.prefix
capability slot; core reads it lazily for page-cache and transform exclusion,
onRequest short-circuit protection, plugin route reservation, hybrid static
output and the dev/serve banners. With no provider there is no admin and no
path is treated as one. resolveCapabilities() resolves slots idempotently for
callers that run before createDuneApp(). MCP stops reporting admin.path.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…st (#123, step 4)

bootstrap() stops importing @dune/plugin-admin itself. The admin runs only when site.yaml
lists it under plugins:; dune new writes the entry and dune migrate:config adds it and
removes admin.enabled. admin.enabled and admin.authz_store are removed, the authProvider
bootstrap option and the admin version guard are gone, the authz system is created when a
plugin provides auth.session or declares authzActions, plugin factories receive
(config, { root, dev }), multisite builds its island snapshot from the sites' plugins,
and dune doctor keys its admin check on admin.prefix.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…#123, step 3)

services.engines.mt/audit, PluginServicesOptions.root, SiteConfig.machine_translation and
the @dune/core/mt and @dune/core/audit entry points are removed; @dune/plugin-admin owns
them now. The response-transforms test stops importing types from the published admin.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…t core's (#123, step 5)

DuneConfig.admin and its types, defaults, schema entry and validation are removed; a system.yaml admin:
block is refused. The settings live under config: of the admin's entry in plugins: and dune migrate:config
moves them (lifting runtime_dir, max_revisions and data_dir to the top level). MCP stops reporting admin
settings and drops the audit-log resource.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ig too

site.machine_translation is the admin's setting: migrate:config moves it under config: of the admin's
entry as machine_translation, renaming apiKey/baseUrl to api_key/base_url; a site.yaml that still has it
is refused.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The authz system now exists whenever the site has a login (site.auth), a
plugin provides auth.session, or a plugin declares authzActions, and
mountDuneAuth reuses that one instance. admin, editor and author are granted
once at group level (group:<role> holds <role> on app:site), so the role
membership site auth already syncs is enough: no per-user tuple, no admin
plugin. The resource is renamed from app:admin to app:site; tuples are derived
data and re-seeded at every start.

Closes #130

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…t once (#130)

Group membership, which page gating and the admin-tier role grants read, was
only seeded at startup, so any change to roles[] waited for a restart. User
stores that core creates are now wrapped with withRoleSync: create, a roles
update and delete make member-of-group tuples match the record. The CLI role
commands sync the same way, and the payments webhook stops writing the
membership twice on a synced store.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
main squash-merged #124, #126 and #125 (rebased), so this branch's history no
longer matched its base. The tree is #128's own change replayed onto main, with
the slot renamed to editor.session and the doctor check keyed on admin.prefix.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The base was squash-merged into main (and this stack rebased), so history no
longer matched. The tree is this PR's own change replayed onto the new base.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The base was squash-merged into main (and this stack rebased), so history no
longer matched. The tree is this PR's own change replayed onto the new base.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The base was squash-merged into main (and this stack rebased), so history no
longer matched. The tree is this PR's own change replayed onto the new base.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…lare their own extension points (#127)

AdminPageRegistration, AdminRouteRegistration, ContentEditorPlugin and the bar
action types leave core's contract along with the keys admin.pages,
admin.routes, editor.content and bar.actions: core never read them. Capabilities
and Contributions are open to declaration merging, keyed by the name of the
plugin that consumes the key. ResponseTransformContext loses barActions and
adminPrefix; a bar-owning plugin reads them through MountApi.capabilities.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…mContext.capabilities

Replaces barActions and adminPrefix with the registry's reader, so a transform
asks for what it needs per request and a plugin module shared between sites keeps
no state of its own.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…admin.prefix

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…o the editor.inline provider (#127)

Core resolves who is editing (a site user, else the editor.session provider's
user; both need pages.update by authz.check), checks the origin of writes, and
passes the request to InlineEditManager.handleRequest. The WebSocket and the
bar's response transform use the same resolver, so a signed-in site user gets
the bar too, and their requests bypass the shared page cache.

InlineEditManager drops commit/patchFields (core never called them; the admin's
content routes did) and gains handleRequest. Page events document an optional
actor. checkInlineEditPermission* are replaced by createEditorResolver.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…on (#127)

JSR refuses 'declare module' in a published package, so plugin-owned extension
points are CapabilityKey<T> tokens (capabilityKey(name)) that provide, contribute,
get and list accept. Capabilities stays closed; Contributions is removed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…tor.session, typed capability keys, theme-api, optional admin)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…mounts plugins; drop unused admin import-map entries; warn on per-environment admin.enabled

headless-smoke set admin.enabled: false, which core now refuses. The headless main.ts
imported the admin's mount subpath unversioned; it now calls mountPlugins and takes island
specifiers from the loaded plugins.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ookup, document exact membership sync, note orphaned app:admin tuples

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
# Conflicts:
#	src/cli/migrate-config.ts
#	tests/cli/migrate_config_test.ts
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
zumbrunn and others added 10 commits November 8, 2026 14:34
…config assigns admin-tier roles automatically

An author could join the inline-edit WebSocket on a page it did not create; the commit was refused
but the shared draft was not. mayEditPage applies the commit rule at the socket.
automaticAdminTierGrants names the config paths that assign admin, editor or author by
themselves (a payment product's role, an external-jwt login): a start-up log line and a
dune doctor --boot access finding, informational, never refusing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…g roles

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Fresh's BuildOptions has no islandSpecifiers, so the option the headless
main.ts passed to the Builder constructor was silently dropped and no
plugin island (the admin's included) was registered. Register each
specifier after construction, as dev.ts and serve.ts do.

Fixes #140

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…er relations (#139)

Default unchanged (admin, editor, author each hold their own). Remap a group, keep a subset,
or {} to turn the group-level grants off. Reconciled at start: grants no longer configured are
revoked. EditorIdentity.relation carries the relation core decided, so ownership follows the
mapping. The automatic-grant note and doctor line follow it too.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…nsform harness

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Brings in the squashed #128 and the #135 doc note. The one conflict was
createAuditTrail in plugin-api/create-services.ts: this branch moves the
audit trail out of core, so its side wins and the block stays removed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
zumbrunn and others added 12 commits November 10, 2026 14:01
…ver write a lifted key twice

Two bugs in the admin-settings move:

- A 0.35 site with `admin.enabled: false` had its whole admin: block
  deleted, including runtime_dir, max_revisions and data_dir, which are
  core settings now lifted to the top level for every other site. They
  are lifted here too. Keys that belong to forms or webhooks (honeypot,
  notifications, incoming_webhooks) make it a manual move, as for an
  enabled admin, instead of being deleted.
- A lifted key already present at the top level of system.yaml was
  appended a second time, which @std/yaml refuses to parse. An equal
  value is skipped; a different one is reported as a manual move and the
  files are left untouched.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Brings in the squashed #128-#132. The one conflict was the CHANGELOG
entry for the contract split, kept as this branch has it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Brings in the squashed #133. Its tree is identical to the contract-split
head this branch already merged, so every conflict resolves to this
branch's side and the merge changes nothing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@zumbrunn
zumbrunn marked this pull request as ready for review November 10, 2026 14:01
zumbrunn and others added 2 commits November 10, 2026 15:02
Brings in the squashed #138. Its tree is identical to the docs/refresh-skills
head this branch already merged, so the two skill-file conflicts resolve to
this branch's side and the merge changes nothing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@zumbrunn
zumbrunn changed the base branch from docs/refresh-skills to main November 10, 2026 14:04
…ed group remapped down is limited (#139)

relationOf took the highest of the mapped relation and the role name, so grants[role] could only
raise the result: with role_grants { editor: author } an editor user was reported as editor and
could edit any page. The relation is now what authz says the user holds (pages.delete for admin,
config.read for editor, else author), which is right for group grants, remaps in both directions
and direct tuples. ctx.auth.role in response transforms follows it. role_grants in an
environment overlay replaces the base mapping instead of merging into it.

Reported by the Dune release session in review of #141.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@zumbrunn
zumbrunn merged commit e9f923c into main Nov 10, 2026
8 checks passed
zumbrunn added a commit that referenced this pull request Nov 10, 2026
Brings in the squashed #128-#141. This PR only adds the contract test and the
@dune/plugin-api import, so the result is main plus those two files; every
conflict (the stack's own files, now on main) resolves to main's side.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make the admin-tier group grants a configurable default (disable / remap)

1 participant