Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 21 additions & 11 deletions mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -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:",
Expand Down
61 changes: 61 additions & 0 deletions src/admin/admin-tuples.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
revoke(userId: string, relation: string): Promise<void>;
}

/** 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<number> {
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;
}
52 changes: 52 additions & 0 deletions tests/admin/admin_tuples_test.ts
Original file line number Diff line number Diff line change
@@ -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<StoredAccount> & { 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"]]);
});
62 changes: 62 additions & 0 deletions tests/admin/role_grants_e2e_test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
Loading