Model the read/write split in the UI: read-only views, self-revoke and inherited-grant guards
CI / full-gate (push) Successful in 2m39s
CI / full-gate (push) Successful in 2m39s
This commit is contained in:
@@ -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
@@ -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";
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user