From 47be6d7d9c6d61daa0b4cf80ec54adcbf95b9b5d Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Tue, 10 Nov 2026 13:22:30 +0100 Subject: [PATCH 1/2] feat: seed direct editing tuples only for accounts that can sign in to the admin (duneorg/dune#139) A site user holding editor or author is governed by core's system.role_grants; the direct tuple an earlier version seeded for such an account is revoked. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 6 ++++ mod.ts | 32 +++++++++++------ src/admin/admin-tuples.ts | 61 ++++++++++++++++++++++++++++++++ tests/admin/admin_tuples_test.ts | 52 +++++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 src/admin/admin-tuples.ts create mode 100644 tests/admin/admin_tuples_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ee73f0f..7d72fd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,12 @@ follows [Semantic Versioning](https://semver.org). (`/api/inline-edit/content/*`), served for any signed-in editor with or without this plugin. The admin contributes "Edit source" (an overlay with its page editor) and "Open in admin" to the bar's `bar.actions` point, so the bar needs no knowledge of the admin's URLs. +- **The admin seeds direct editing tuples only for its own accounts (duneorg/dune#139).** Accounts that can sign + in to it (a local password) keep a direct tuple, so the panel works even when a site sets + `role_grants: {}`. A site user who holds `editor` or `author` (OAuth, magic link, `external-jwt`) + is governed by core's `system.role_grants` like anyone else; the direct tuple an earlier version + seeded for such an account is revoked at start (logged as `admin.authz.stale_direct_grants_revoked`). + On the default grants nothing changes for them: their group membership still grants the relation. - **Content webhooks are a listener on the page events**, not a call in each admin route: a write from the inline-edit bar, MCP or the CLI now reaches them too. `announcePageEvent()` names the signed-in user as `actor`, which the webhook payload carries. diff --git a/mod.ts b/mod.ts index dbd85b6..b3a71ff 100644 --- a/mod.ts +++ b/mod.ts @@ -18,7 +18,6 @@ import type { DunePlugin, MountApi } from "@dune/core/hooks"; import type { DuneConfig } from "@dune/core/config"; import type { StorageAdapter } from "@dune/core/storage"; import { createUserManager } from "./src/admin/auth/users.ts"; -import { highestValidRole } from "./src/admin/auth/role-utils.ts"; import { createSessionManager } from "./src/admin/auth/sessions.ts"; import { createAuthMiddleware } from "./src/admin/auth/middleware.ts"; import { LocalAuthProvider } from "./src/admin/auth/local-provider.ts"; @@ -38,6 +37,7 @@ import * as corePlugins from "@dune/core/plugins"; import denoJson from "./deno.json" with { type: "json" }; import { adminSettings } from "./src/admin/settings.ts"; import { adminBarActions, BAR_ACTIONS } from "./src/admin/bar-actions.ts"; +import { reconcileAdminTuples } from "./src/admin/admin-tuples.ts"; import { ADMIN_EDITOR, ADMIN_PAGES, ADMIN_ROUTES } from "./src/admin/contributions.ts"; import { listenForContentWebhooks } from "./src/admin/webhooks.ts"; @@ -371,16 +371,26 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { // tuple gets registered too, instead of being locked out on first run. if (runtime.authz) { try { - // users.list() returns every account in the unified store - // (data/users/) — not just admin-tier ones once public site - // visitors share the same store (Phase 5b). Only bootstrap tuples - // for accounts that actually carry an admin-tier role. - const allUsers = await users.list(); - const enabledAdminUsers = allUsers - .filter((u) => u.enabled !== false) - .map((u) => ({ id: u.id, role: highestValidRole(u.roles) })) - .filter((u) => u.role !== undefined) as { id: string; role: string }[]; - await services.authz.seedAdminTuples(enabledAdminUsers); + // users.list() returns every account in the unified store (data/users/), + // site visitors included. Only the admin's own accounts, the ones that + // can sign in to it, get a direct tuple; the rest are governed by + // core's `system.role_grants` (see admin-tuples.ts). + const revoked = await reconcileAdminTuples(await users.list(), { + seedAdminTuples: (accounts) => services.authz.seedAdminTuples(accounts), + revoke: async (userId, relation) => { + await runtime.authz!.disallowAllMatching({ + who: { type: "user", id: userId }, + was: relation as "admin" | "editor" | "author", + onWhat: { type: "app", id: "site" }, + }); + }, + }); + if (revoked > 0) { + logger.info("admin.authz.stale_direct_grants_revoked", { + count: revoked, + reason: "accounts that cannot sign in to the admin are governed by system.role_grants", + }); + } } catch (err) { console.warn( "[dune/authz] Admin tuple bootstrap failed — authz is up but tuples were not seeded; admin access will deny (403) until bootstrap succeeds:", diff --git a/src/admin/admin-tuples.ts b/src/admin/admin-tuples.ts new file mode 100644 index 0000000..87d5dc7 --- /dev/null +++ b/src/admin/admin-tuples.ts @@ -0,0 +1,61 @@ +/** + * Which accounts the admin gives direct editing rights. + * + * `admin`, `editor` and `author` in a user's `roles[]` already carry editing + * rights through core's group grants (`system.role_grants`), for any signed-in + * user. What the admin adds is a direct tuple for **its own accounts**, so they + * can use the panel even when a site turns the group grants off with + * `role_grants: {}`. Its own accounts are the ones that can sign in to it: the + * built-in sign-in is by password, so those with a local password. + * + * A site user (OAuth, magic link, `external-jwt`) who happens to hold an + * admin-tier role gets no direct tuple here: they are governed by + * `role_grants` like everyone else, and a direct tuple would keep them editing + * after the site switched that off. + * + * @module + */ + +import { highestValidRole } from "./auth/role-utils.ts"; + +/** The part of a user record this needs. */ +export interface StoredAccount { + id: string; + roles: string[]; + enabled?: boolean; + provider?: string; + passwordHash?: string; +} + +/** The part of core's authz this needs. */ +export interface TupleAuthz { + seedAdminTuples(users: Array<{ id: string; role: string }>): Promise; + revoke(userId: string, relation: string): Promise; +} + +/** An account that can sign in to the built-in admin: it has a local password. */ +export function isAdminAccount(user: StoredAccount): boolean { + return user.provider === "local" || !!user.passwordHash; +} + +/** + * Seed the direct tuple of every enabled admin account that holds an + * admin-tier role, and revoke the one an earlier version seeded for an + * account that cannot sign in to the admin. Returns how many it revoked. + */ +export async function reconcileAdminTuples(users: StoredAccount[], authz: TupleAuthz): Promise { + const seed: Array<{ id: string; role: string }> = []; + const stale: Array<{ id: string; role: string }> = []; + for (const user of users) { + const role = highestValidRole(user.roles); + if (role === undefined) continue; + if (isAdminAccount(user)) { + if (user.enabled !== false) seed.push({ id: user.id, role }); + } else { + stale.push({ id: user.id, role }); + } + } + await authz.seedAdminTuples(seed); + for (const { id, role } of stale) await authz.revoke(id, role); + return stale.length; +} diff --git a/tests/admin/admin_tuples_test.ts b/tests/admin/admin_tuples_test.ts new file mode 100644 index 0000000..457c18d --- /dev/null +++ b/tests/admin/admin_tuples_test.ts @@ -0,0 +1,52 @@ +/** + * The admin seeds direct editing tuples for its own accounts only; a site user + * who holds an admin-tier role is governed by core's system.role_grants. + */ + +import { assertEquals } from "@std/assert"; +import { isAdminAccount, reconcileAdminTuples, type StoredAccount } from "../../src/admin/admin-tuples.ts"; + +function fakeAuthz() { + const seeded: Array<{ id: string; role: string }> = []; + const revoked: Array<[string, string]> = []; + return { + seeded, + revoked, + seedAdminTuples: (users: Array<{ id: string; role: string }>) => (seeded.push(...users), Promise.resolve()), + revoke: (id: string, relation: string) => (revoked.push([id, relation]), Promise.resolve()), + }; +} + +const account = (over: Partial & { id: string }): StoredAccount => ({ roles: [], ...over }); + +Deno.test("a password account is an admin account; OAuth, magic-link and invited ones are not", () => { + assertEquals(isAdminAccount(account({ id: "a", provider: "local" })), true); + assertEquals(isAdminAccount(account({ id: "a", provider: "github", passwordHash: "pbkdf2:..." })), true); + for (const provider of ["github", "google", "magic", "invited", "external-jwt"]) { + assertEquals(isAdminAccount(account({ id: "a", provider })), false, provider); + } +}); + +Deno.test("only admin accounts with an admin-tier role are seeded", async () => { + const authz = fakeAuthz(); + await reconcileAdminTuples([ + account({ id: "admin1", provider: "local", roles: ["admin"] }), + account({ id: "ed1", provider: "local", roles: ["member", "editor"] }), + account({ id: "plain", provider: "local", roles: ["member"] }), + account({ id: "off", provider: "local", roles: ["admin"], enabled: false }), + account({ id: "visitor", provider: "github", roles: ["editor"] }), + ], authz); + assertEquals(authz.seeded, [{ id: "admin1", role: "admin" }, { id: "ed1", role: "editor" }]); +}); + +Deno.test("a tuple an earlier version seeded for an account that cannot sign in is revoked, and counted", async () => { + const authz = fakeAuthz(); + const n = await reconcileAdminTuples([ + account({ id: "visitor", provider: "github", roles: ["author", "member"] }), + account({ id: "buyer", provider: "magic", roles: ["editor"] }), + account({ id: "real", provider: "local", roles: ["editor"] }), + account({ id: "tagged", provider: "github", roles: ["member"] }), + ], authz); + assertEquals(n, 2); + assertEquals(authz.revoked, [["visitor", "author"], ["buyer", "editor"]]); +}); From 3503c79c78c881df92575d932238b5bcedc58f63 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Tue, 10 Nov 2026 13:23:03 +0100 Subject: [PATCH 2/2] test: role_grants {} with the admin installed Co-Authored-By: Claude Sonnet 5.5 --- tests/admin/role_grants_e2e_test.ts | 62 +++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/admin/role_grants_e2e_test.ts diff --git a/tests/admin/role_grants_e2e_test.ts b/tests/admin/role_grants_e2e_test.ts new file mode 100644 index 0000000..6198d90 --- /dev/null +++ b/tests/admin/role_grants_e2e_test.ts @@ -0,0 +1,62 @@ +/** + * With the admin installed, `system.role_grants: {}` leaves a site user who + * holds `editor` without editing rights, while the admin's own account keeps + * working. A tuple an earlier version seeded for the site user is revoked. + */ + +import { assertEquals } from "@std/assert"; +import { join } from "@std/path"; +import { bootstrap } from "@dune/core/bootstrap"; +import { createDuneApp } from "@dune/core/fresh-app"; +import { createStorage } from "@dune/core/storage"; +import { createLocalUserStore } from "@dune/core/auth/user-store"; +import { hashPassword } from "@dune/core/auth/passwords"; +import { SITE_RESOURCE } from "@dune/core/auth/authz-schema"; +import { createAdminPlugin } from "../../mod.ts"; + +async function run(systemYaml: string, preseedVisitor: boolean) { + const root = await Deno.makeTempDir({ prefix: "dune_admin_role_grants_" }); + try { + await Deno.mkdir(join(root, "config"), { recursive: true }); + await Deno.writeTextFile(join(root, "config", "site.yaml"), 'title: "T"\nurl: "http://localhost:8000"\nauth:\n mode: dune\n'); + await Deno.writeTextFile(join(root, "config", "system.yaml"), systemYaml); + await Deno.mkdir(join(root, "content", "01.home"), { recursive: true }); + await Deno.writeTextFile(join(root, "content", "01.home", "default.md"), "---\ntitle: Home\n---\n\nHi\n"); + const users = createLocalUserStore({ storage: createStorage({ rootDir: root }), usersDir: "data/users" }); + const admin = await users.create({ + email: "a@example.test", + username: "ada", + provider: "local", + passwordHash: await hashPassword("correct horse battery staple"), + roles: ["admin"], + }); + const visitor = await users.create({ email: "v@example.test", provider: "github", roles: ["editor"] }); + + const ctx = await bootstrap(root, { plugins: [createAdminPlugin({ root, dev: false })] }); + if (preseedVisitor) { + // What 0.35 and the first 0.36 builds did: a direct tuple for any user with the role. + await ctx.authz!.allow({ who: { type: "user", id: visitor.id }, toBe: "editor", onWhat: SITE_RESOURCE }); + } + await createDuneApp(ctx, { root, port: 0, dev: false }); + const can = (id: string) => + // deno-lint-ignore no-explicit-any + ctx.authz!.check({ who: { type: "user", id }, canThey: "pages.update" as any, onWhat: SITE_RESOURCE }); + return { admin: await can(admin.id), visitor: await can(visitor.id) }; + } finally { + await Deno.remove(root, { recursive: true }).catch(() => {}); + } +} + +const opts = { sanitizeOps: false, sanitizeResources: false }; + +Deno.test("default grants: the admin account and the site user holding editor both edit", opts, async () => { + assertEquals(await run("debug: false\n", false), { admin: true, visitor: true }); +}); + +Deno.test("role_grants {}: the admin account keeps its rights, the site user does not", opts, async () => { + assertEquals(await run("role_grants: {}\n", false), { admin: true, visitor: false }); +}); + +Deno.test("role_grants {}: a direct tuple an earlier version seeded for the site user is revoked at start", opts, async () => { + assertEquals(await run("role_grants: {}\n", true), { admin: true, visitor: false }); +});