Skip to content

fix(authz)!: site users' roles grant editing, checked on app:site; roles apply without a restart (#130) - #132

Merged
zumbrunn merged 29 commits into
mainfrom
fix/site-user-editing
Nov 10, 2026
Merged

zumbrunn merged 29 commits into
mainfrom
fix/site-user-editing

Conversation

@zumbrunn

@zumbrunn zumbrunn commented Nov 7, 2026 •

Copy link
Copy Markdown
Member

Behaviour to know about on upgrade: after this PR, holding admin, editor or author in a user's roles[] carries editing rights for any signed-in user, with or without the admin plugin, live and for user_store: db and external-jwt role claims too. (On 0.35 a local-store user with such a role already got it on the next restart on a site that ran the admin.) A site that uses editor or author as a content tier should rename them. Details and the dune doctor --boot note are in the changelog and in #134.

Closes #130. Stacked on #131 (base feat/admin-settings-in-plugin); retarget to main once the stack below it merges.

What

A signed-in site user with an editing role can now edit inline on a site with no admin plugin, and a role granted or revoked while the app runs applies at once.

  • The authz system exists whenever something uses it: site.auth is configured, a plugin provides auth.session, or a plugin declares authzActions. mountDuneAuth reuses that one instance. The inline-edit WebSocket checks the public-auth instance (it differs from bootstrap's when site.auth.authz_store is db).
  • Roles grant at group level. bootstrapRoleGrants writes group:admin|editor|author → admin|editor|author on app:site once. Site auth already keeps member of group:<role> in sync, so having the role is enough: no per-user tuple, no admin plugin. A content-only role (member, subscriber, ...) grants nothing. Group subjects needed group added to the schema's subjectTypes; spiked first, grants propagate to members.
  • app:admin → app:site (SITE_RESOURCE, exported). The resource names what is protected, not the panel. Tuples are derived data and re-seeded at every start; an HMAC-protected store needs dune authz:sign once.
  • Membership follows roles[]. It was only seeded at startup, so any runtime role change waited for a restart (page gating has the same gap). syncUserRoles / withRoleSync now keep it equal on every create, roles update and delete, for user stores core creates (site auth, services.users.createLocalStore()). The CLI role commands sync too. The payments webhook checks membership first so a synced store doesn't get the tuple twice.

Behaviour changes to review

  • external-jwt: a role claim of admin, editor or author in the IdP's token now carries pages.update. Before, only users the admin plugin registered had it. Easy to guard to dune mode if you'd rather not.
  • Breaking for plugins that check the resource themselves: use SITE_RESOURCE. @dune/plugin-admin and @dune/testing already do (companion changes below).
  • A plugin that builds its own user store must call syncUserRoles itself.

Tests

  • tests/runtime/site_user_editing_test.ts: login-only site, the same with an auth.session provider, every role, and a role granted at runtime through services.users (403 → 204 → 403 without a restart). The first three fail without the fix.
  • tests/auth/sync_roles_test.ts and a payments case.
  • deno task test 2270 pass, deno task check clean, no deno.lock change. plugin-admin (283) and testing (44) suites pass against this branch.

Companion changes (already pushed to their open drafts)

Not covered: whether a running server picks up a tuple the CLI writes to the file store from another process without a restart (true of the direct tuples before this too).

🤖 Generated with Claude Code

zumbrunn and others added 14 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>
zumbrunn and others added 7 commits November 8, 2026 12:32
…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 7 commits November 8, 2026 15:49
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>
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>
…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>
@zumbrunn
zumbrunn changed the base branch from feat/admin-settings-in-plugin to main November 10, 2026 13:15
@zumbrunn
zumbrunn marked this pull request as ready for review November 10, 2026 13:29
@zumbrunn
zumbrunn merged commit 78fb5ad into main Nov 10, 2026
5 checks passed
zumbrunn added a commit that referenced this pull request Nov 10, 2026
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>
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.

Inline editing for a signed-in site user is denied whenever the admin plugin did not set up authz or the app:admin tuples

1 participant