Model the read/write split in the UI: read-only views, self-revoke and inherited-grant guards

This commit is contained in:
2026-08-05 14:47:51 +02:00
parent 29d654c012
commit 765f349007
28 changed files with 287 additions and 79 deletions
+7
View File
@@ -41,6 +41,13 @@ test("seedPermissions unions ADMIN_PERMISSIONS (empty by default) with the disco
assert.deepEqual(seedPermissions(",, ", [" scheduling:read ", ""]), ["scheduling:read"]); // blanks dropped, names trimmed (both sides)
});
test("seedPermissions refuses an ADMIN_PERMISSIONS name that isn't <resource>:<action>", () => {
// The operator's env is the one remaining hand-typed path; a manifest's names were checked at
// discovery. `admin` would otherwise write a tuple that gates nothing, with no error anywhere.
assert.throws(() => seedPermissions("admin", []), /ADMIN_PERMISSIONS.*<resource>:<action>.*admin/s);
assert.throws(() => seedPermissions("users:read,Bad Name", []), /Bad Name/);
});
test("seedAdmin on a fresh stack creates the identity and grants every permission (one tuple each)", async () => {
const id = randomUUID();
const calls: { method: string; url: string; body?: unknown }[] = [];
+10 -3
View File
@@ -9,6 +9,7 @@
import { existsSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { discoverPlugins } from "../plugin-host/discovery.ts";
import { declaredPermissions, isValidPermissionName } from "../plugin-host/plugin.ts";
import { generateJwks, type JwkSet } from "./gen-jwks.ts";
import { createLogger, runWithLog, tracedFetch } from "../logger.ts";
@@ -34,9 +35,15 @@ export function permissionTuple(userId: string, permission: string) {
// dropped-in plugin's permissions are seeded out of the box. Deduped, order-stable, blanks dropped.
// The base is empty because permissions are `<resource>:<action>` and every one of them is owned by
// the plugin that gates on it — a host-invented default would gate nothing.
export function seedPermissions(adminPermissionsEnv: string | undefined, declaredPermissions: string[]): string[] {
// ADMIN_PERMISSIONS is the one place an operator names a permission by hand, so it is held to the
// same `<resource>:<action>` rule discovery applies to a manifest — fail loud rather than write a
// tuple that gates nothing. A declared name has already passed that check at discovery.
export function seedPermissions(adminPermissionsEnv: string | undefined, declaredNames: string[]): string[] {
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
return [...new Set([...clean((adminPermissionsEnv ?? "").split(",")), ...clean(declaredPermissions)])];
const configured = clean((adminPermissionsEnv ?? "").split(","));
const bad = configured.filter((name) => !isValidPermissionName(name));
if (bad.length > 0) throw new Error(`bootstrap: ADMIN_PERMISSIONS must be <resource>:<action> names, e.g. "things:read"; got ${bad.join(", ")}`);
return [...new Set([...configured, ...clean(declaredNames)])];
}
// --- JWKS safety net -----------------------------------------------------------------
@@ -147,7 +154,7 @@ async function main() {
// Seed every discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so the
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
const declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.name));
const declared = declaredPermissions(await discoverPlugins()).map((decl) => decl.name);
const permissions = seedPermissions(env["ADMIN_PERMISSIONS"], declared);
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
const password = env["ADMIN_PASSWORD"] ?? "admin";
+54
View File
@@ -1289,6 +1289,60 @@ test("admin permission grants: the picker offers the declared catalog, and a sav
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "groups:read" && tp.subject_set?.object === "eng"));
});
// Revoking your own grants can remove the last users:write on the deployment, and the instant-revoke
// hook lands it on the next request — recovery would be a curl against Keto. Guarded like
// self-deactivate and self-delete are. (`admin1` is the harness's own sub.)
test("admin permission grants: you can't revoke your own permissions, but you can still grant", async (t) => {
const identities: Identity[] = [{ id: "admin1", traits: { email: "you@example.com" } }];
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:write", relation: "granted", subject_id: "user:admin1" }];
const keto = fakeKeto(tuples);
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { post, token } = await adminHarness(t, { keto, kratosAdmin });
const refused = await post("/admin/users/admin1/permissions", `_csrf=${token}`); // every box cleared
assert.equal(refused.status, 400);
assert.match(await refused.text(), /lock yourself out/);
assert.ok(tuples.some((tp) => tp.object === "users:write" && tp.subject_id === "user:admin1"), "nothing was revoked");
// Granting yourself more is not a lockout, so it goes through.
const granted = await post("/admin/users/admin1/permissions", `_csrf=${token}&permission=users%3Awrite&permission=groups%3Aread`);
assert.equal(granted.status, 303);
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === "user:admin1"));
});
// The read/write split is only honest if the UI models it: a users:read holder must not be shown
// buttons that 403 on submit. The gate already refuses them (asserted above); this is the affordance.
test("admin screens render no write affordance for a read-only holder", async (t) => {
const ada = randomUUID();
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
const keto = fakeKeto([{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }]);
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { get } = await adminHarness(t, { keto, kratosAdmin });
const readOnly = ["users:read", "groups:read"];
const list = await (await get("/admin/users", readOnly)).text();
assert.doesNotMatch(list, /href="\/admin\/users\/new"/); // no "New user"
assert.match(list, /ada@example\.com/); // but the list itself is there — that's the point of :read
// (The shell's own sign-out is a POST form, so assert on the affordances by name, not on <form>.)
const detail = await (await get(`/admin/users/${ada}`, readOnly)).text();
assert.doesNotMatch(detail, /Save changes/);
assert.doesNotMatch(detail, /Generate recovery code/);
assert.doesNotMatch(detail, /Delete user/);
assert.doesNotMatch(detail, /Save permissions/);
assert.match(detail, /type="checkbox"[^>]*disabled/); // the permissions are shown, just not editable
const group = await (await get("/admin/groups/eng", readOnly)).text();
assert.doesNotMatch(group, /Add a member/);
assert.doesNotMatch(group, /Delete group/);
assert.doesNotMatch(group, /Save permissions/);
// A writer sees the affordances the reader didn't.
const writable = await (await get(`/admin/users/${ada}`, ["users:read", "users:write"])).text();
assert.match(writable, /Save changes/);
assert.match(writable, /Save permissions/);
});
// Built-in OAuth2 clients admin screen: gate + list/register/detail/delete over HTTP against an
// in-memory Hydra. Registration shows the one-time client_secret on the post-create page (no PRG).
test("admin OAuth2 clients screen: gate, list, register (one-time secret), detail, delete (CSRF-guarded)", async (t) => {
+1 -1
View File
@@ -209,7 +209,7 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] {
collect(plugins, (plugin, push) => {
for (const decl of plugin.permissions ?? []) push(decl.name);
}).forEach((owners, name) => {
if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${name}" declared by ${uniq(owners).length} plugins; namespace as "<id>:<action>" unless shared on purpose`, plugins: uniq(owners) });
if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${name}" declared by ${uniq(owners).length} plugins; pick a more specific "<resource>" unless shared on purpose`, plugins: uniq(owners) });
});
return out;
+2 -2
View File
@@ -24,7 +24,7 @@ const adminLike: Plugin = {
{ href: "/admin/users", id: "users", label: "Users" },
{ href: "/admin/groups", id: "groups", label: "Groups" },
],
icon: "i-shield", id: "admin", label: "Admin", permission: "admin",
icon: "i-shield", id: "admin", label: "Admin", permission: "users:read",
}],
};
@@ -58,7 +58,7 @@ test("a permission holder sees the Dashboard link + plugin nav; current path ope
});
test("a gated section (like the admin plugin) shows to a holder; a sub-path marks its base leaf current", () => {
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], user: { email: "a@b.c", id: "u1", permissions: ["admin"] } });
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], user: { email: "a@b.c", id: "u1", permissions: ["users:read"] } });
const admin = chrome.nav.find((n) => n.label === "Admin")!;
assert.ok(admin); // gated section visible to an admin
assert.equal(admin.open, true); // ancestor of the current leaf opened
+6 -6
View File
@@ -27,7 +27,7 @@ test("composeNav merges fragments, filters by permission, and emits clean render
test("composeNav drops gated subtrees, empty headers, and (with no permissions) all gated nodes", () => {
// A header the user can't reach takes its whole subtree, even visible children.
const gatedHeader: NavNode[][] = [[
{ id: "admin", label: "Admin", permission: "admin", children: [{ href: "/u", id: "u", label: "Users" }] },
{ id: "admin", label: "Admin", permission: "users:read", children: [{ href: "/u", id: "u", label: "Users" }] },
{ id: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] },
]];
assert.deepEqual(composeNav(gatedHeader, {}, []), [
@@ -36,8 +36,8 @@ test("composeNav drops gated subtrees, empty headers, and (with no permissions)
// A pure header whose children are all filtered is dropped; a header with an href survives as a leaf.
const emptyHeader: NavNode[][] = [[
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", permission: "x" }] },
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", permission: "y" }] },
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", permission: "x:read" }] },
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", permission: "y:read" }] },
]];
assert.deepEqual(composeNav(emptyHeader, {}, []), [{ href: "/hub", label: "Hub" }]);
@@ -66,7 +66,7 @@ test("composeNav applies the override: rename, group, order, hide (then filters)
{ href: "/a", id: "a", label: "Alpha" },
{ href: "/b", id: "b", label: "Beta" },
{ href: "/c", id: "c", label: "Gamma" },
{ href: "/secret", id: "secret", label: "Secret", permission: "root" },
{ href: "/secret", id: "secret", label: "Secret", permission: "secrets:read" },
]];
const tree = composeNav(base, {
@@ -74,9 +74,9 @@ test("composeNav applies the override: rename, group, order, hide (then filters)
groups: [{ icon: "i-box", id: "grp", label: "Group", open: true, children: ["b", "c"] }], // wrap b+c
order: ["grp", "a"], // grp before the lone a
hide: ["c"], // remove c from inside the group
}, ["root"]);
}, ["secrets:read"]);
// grp emitted (b only, c hidden), reordered before a; Secret kept now that permission "root" is present.
// grp emitted (b only, c hidden), reordered before a; Secret kept now that permission "secrets:read" is present.
assert.deepEqual(tree, [
{ icon: "i-box", label: "Group", open: true, children: [{ href: "/b", label: "Beta" }] },
{ href: "/a", label: "First" },