Permission names are <resource>:<action>, replacing the catch-all admin permission
CI / full-gate (push) Successful in 2m43s

This commit is contained in:
2026-08-05 12:45:06 +02:00
parent 225569b08a
commit 065d865d24
23 changed files with 388 additions and 180 deletions
+9 -8
View File
@@ -30,14 +30,15 @@ test("permissionTuple grants a permission to user:<id> in the Permission namespa
});
});
test("seedPermissions unions ADMIN_PERMISSIONS (default 'admin') with the discovered plugins' declared permissions", () => {
// Clean clone: no ADMIN_PERMISSIONS, the scheduling plugin declares its two tokens → the demo admin
// gets exactly today's behaviour, but derived from discovery, not hardcoded in the host.
assert.deepEqual(seedPermissions(undefined, ["scheduling:read", "scheduling:write"]), ["admin", "scheduling:read", "scheduling:write"]);
assert.deepEqual(seedPermissions(undefined, []), ["admin"]); // no plugins → just the base admin permission
assert.deepEqual(seedPermissions("admin, ops ", ["inventory:read"]), ["admin", "ops", "inventory:read"]); // env trimmed + extended
assert.deepEqual(seedPermissions("admin,scheduling:read", ["scheduling:read"]), ["admin", "scheduling:read"]); // dedup, no double grant
assert.deepEqual(seedPermissions("admin,, ", [" scheduling:read ", ""]), ["admin", "scheduling:read"]); // blanks dropped, tokens trimmed (both sides)
test("seedPermissions unions ADMIN_PERMISSIONS (empty by default) with the discovered plugins' declared permissions", () => {
// Clean clone: no ADMIN_PERMISSIONS, the scheduling plugin declares its two names → the demo admin
// holds exactly what the installed plugins gate on, derived from discovery, not hardcoded here.
assert.deepEqual(seedPermissions(undefined, ["scheduling:read", "scheduling:write"]), ["scheduling:read", "scheduling:write"]);
// No plugins → nothing to grant. A host-invented base would be a permission that gates nothing.
assert.deepEqual(seedPermissions(undefined, []), []);
assert.deepEqual(seedPermissions("ops:read, ops:write ", ["inventory:read"]), ["ops:read", "ops:write", "inventory:read"]); // env trimmed + extended
assert.deepEqual(seedPermissions("scheduling:read", ["scheduling:read"]), ["scheduling:read"]); // dedup, no double grant
assert.deepEqual(seedPermissions(",, ", [" scheduling:read ", ""]), ["scheduling:read"]); // blanks dropped, names trimmed (both sides)
});
test("seedAdmin on a fresh stack creates the identity and grants every permission (one tuple each)", async () => {
+11 -9
View File
@@ -2,9 +2,9 @@
// kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`:
// 1. generate the JWKS signing key if absent (committed dev key makes this a safety net);
// 2. seed a demo admin (admin@plainpages.local / admin) in Kratos;
// 3. grant it its permissions in Keto so menu/permission checks resolve out of the box — `admin` plus
// every discovered plugin's declared permission names, so a dropped-in plugin is usable by
// the demo admin with no host config edit (the host stays plugin-agnostic).
// 3. grant it its permissions in Keto so menu/permission checks resolve out of the box — every
// discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so a dropped-in
// plugin is usable by the demo admin with no host config edit (the host stays plugin-agnostic).
// Then prints a first-run banner; fails loud on any unexpected upstream error.
import { existsSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
@@ -28,13 +28,15 @@ export function permissionTuple(userId: string, permission: string) {
return { namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${userId}` };
}
// The permissions to grant the demo admin = the configured base (ADMIN_PERMISSIONS, default just `admin`)
// The permissions to grant the demo admin = the configured base (ADMIN_PERMISSIONS, empty by default)
// unioned with every discovered plugin's declared permission names (a route/nav `permission` is a
// coarse permission — granted as a Keto `Permission:<token>#members` tuple). So the host names no plugin, yet a
// dropped-in plugin's tokens are seeded out of the box. Deduped, order-stable, blanks dropped.
export function seedPermissions(adminRolesEnv: string | undefined, declaredPermissions: string[]): string[] {
// coarse permission — granted as a Keto `Permission:<name>#granted` tuple). So the host names no plugin, yet a
// 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[] {
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredPermissions)])];
return [...new Set([...clean((adminPermissionsEnv ?? "").split(",")), ...clean(declaredPermissions)])];
}
// --- JWKS safety net -----------------------------------------------------------------
@@ -143,7 +145,7 @@ async function main() {
await runWithLog(log, async () => {
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key");
// Seed `admin` (or ADMIN_PERMISSIONS) + every discovered plugin's declared permission names, so the
// 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 permissions = seedPermissions(env["ADMIN_PERMISSIONS"], declared);
+57 -28
View File
@@ -871,11 +871,14 @@ async function adminHarness(t: TestContext, opts: AppOptions = {}) {
const token = issueCsrfToken(ADMIN_CSRF);
const nowSec = Math.floor(Date.now() / 1000);
const cookie = (permissions: string[]) => `${SESSION_COOKIE}=${mintJwt({ email: "admin@x", exp: nowSec + 600, permissions, sub: "admin1" })}; ${CSRF_COOKIE}=${token}`;
const get = (path: string, permissions: string[] = ["admin"]) => fetch(url + path, { headers: { cookie: cookie(permissions) }, redirect: "manual" });
const get = (path: string, permissions: string[] = ADMIN_ALL) => fetch(url + path, { headers: { cookie: cookie(permissions) }, redirect: "manual" });
const post = (path: string, body: string) =>
fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(["admin"]) }, method: "POST", redirect: "manual" });
fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(ADMIN_ALL) }, method: "POST", redirect: "manual" });
return { get, post, token, url };
}
// What the plugin itself declares — the harness holds every screen's read and write, so a screen
// test exercises the screen rather than the gate. assertAdminGate covers the refusals.
const ADMIN_ALL = (adminManifest.permissions ?? []).map((p) => p.name);
// Every admin route is gated: anonymous → /login, a signed-in non-admin → 403.
async function assertAdminGate(url: string, get: (path: string, permissions?: string[]) => Promise<Response>, path: string) {
const anon = await fetch(url + path, { redirect: "manual" });
@@ -1105,11 +1108,28 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
await assertAdminGate(url, get, "/admin/users");
// Nav: the admin plugin's section composes into the one global menu for an admin, and is filtered
// out for a signed-in non-admin (the gate on the section header) — proving the drop-in nav fragment.
// Nav: the admin plugin's section composes into the one global menu, and each screen is filtered
// by its own read permission — proving the drop-in nav fragment. A user holding only users:read
// sees Users and nothing else; holding none of the four, composeNav drops the emptied header.
assert.match(await (await get("/dashboard")).text(), /href="\/admin\/users"/);
const usersOnlyNav = await (await get("/dashboard", ["users:read"])).text();
assert.match(usersOnlyNav, /href="\/admin\/users"/);
assert.doesNotMatch(usersOnlyNav, /href="\/admin\/groups"/);
assert.doesNotMatch(await (await get("/dashboard", ["scheduling:read"])).text(), /href="\/admin\/users"/);
// The read/write split: users:read opens the list but is refused on every mutation, and the
// resources don't leak — a users holder is not a groups holder.
assert.equal((await get("/admin/users", ["users:read"])).status, 200);
assert.equal((await get("/admin/groups", ["users:read", "users:write"])).status, 403);
const readOnlyPost = await fetch(url + "/admin/users", {
body: `_csrf=${token}&email=nope@example.com`,
headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${SESSION_COOKIE}=${mintJwt({ email: "r@x", exp: Math.floor(Date.now() / 1000) + 600, permissions: ["users:read"], sub: "reader1" })}; ${CSRF_COOKIE}=${token}` },
method: "POST",
redirect: "manual",
});
assert.equal(readOnlyPost.status, 403);
assert.equal(store.some((i) => i.traits?.email === "nope@example.com"), false);
// List: the admin sees the rows + the "add" link; the status filter narrows server-side.
const listHtml = await (await get("/admin/users")).text();
assert.match(listHtml, /ada@example\.com/);
@@ -1240,10 +1260,10 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
{ id: ada, schema_id: "default", state: "active", traits: { email: "ada@example.com" } },
{ id: grace, schema_id: "default", state: "active", traits: { email: "grace@example.com" } },
];
// grace is in the `eng` group; `editor` is an existing permission whose only direct member is ada.
// grace is in the `eng` group; `docs:write` is an existing permission whose only direct member is ada.
const tuples: RelationTuple[] = [
{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${grace}` },
{ namespace: "Permission", object: "editor", relation: "granted", subject_id: `user:${ada}` },
{ namespace: "Permission", object: "docs:write", relation: "granted", subject_id: `user:${ada}` },
];
// Mirror Keto's expand shape: the subject rides on `tuple`, set nodes carry members as children.
const expandSet = (set: SubjectSet): ExpandTree => ({
@@ -1262,59 +1282,68 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
// List: the existing permission shows + the "add" link.
const listHtml = await (await get("/admin/permissions")).text();
assert.match(listHtml, /href="\/admin\/permissions\/editor"/);
assert.match(listHtml, /href="\/admin\/permissions\/docs%3Awrite"/);
assert.match(listHtml, /href="\/admin\/permissions\/new"/);
// Create: a valid post writes the first-member tuple and redirects to the detail.
assert.match(await (await get("/admin/permissions/new")).text(), /Create permission/);
const created = await post("/admin/permissions", `_csrf=${token}&name=viewer&member=user:${ada}`);
const created = await post("/admin/permissions", `_csrf=${token}&name=docs%3Aread&member=user:${ada}`);
assert.equal(created.status, 303);
assert.equal(created.headers.get("location"), "/admin/permissions/viewer");
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "viewer" && tp.subject_id === `user:${ada}`));
assert.equal(created.headers.get("location"), "/admin/permissions/docs%3Aread");
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "docs:read" && tp.subject_id === `user:${ada}`));
assert.equal(denylist.isRevoked(ada, 0), true); // assigning a permission to a user revokes their stale token so the grant lands now
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
const before = tuples.length;
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=Bad Name&member=user:${ada}`)).status, 400);
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=editor&member=user:${ada}`)).status, 400); // already exists
// A bare word has no <resource>:<action> shape — the rule the create form now enforces.
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=editor&member=user:${ada}`)).status, 400);
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=docs%3Awrite&member=user:${ada}`)).status, 400); // already exists
assert.equal((await post("/admin/permissions", `name=x&member=user:${ada}`)).status, 403);
assert.equal(tuples.length, before);
// Detail: ada (direct) is in the effective-access list; grace (only reachable via a group) is not
// yet — though grace appears elsewhere as an assignable candidate, so target the effective <li>.
const effectiveLi = (email: string) => new RegExp(`<li><span class="cell-strong">${email.replace(".", "\\.")}`);
const detail = await (await get("/admin/permissions/editor")).text();
const detail = await (await get("/admin/permissions/docs%3Awrite")).text();
assert.match(detail, effectiveLi("ada@example.com"));
assert.doesNotMatch(detail, effectiveLi("grace@example.com"));
// Assign the `eng` group to the permission → grace now holds it transitively (effective access via expand).
await post("/admin/permissions/editor/members", `_csrf=${token}&member=group:eng`);
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor" && tp.subject_set?.object === "eng"));
const withGroup = await (await get("/admin/permissions/editor")).text();
await post("/admin/permissions/docs%3Awrite/members", `_csrf=${token}&member=group:eng`);
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "docs:write" && tp.subject_set?.object === "eng"));
const withGroup = await (await get("/admin/permissions/docs%3Awrite")).text();
assert.match(withGroup, effectiveLi("grace@example.com"));
// Revoke the group membership.
await post("/admin/permissions/editor/members/delete", `_csrf=${token}&member=group:eng`);
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor" && tp.subject_set?.object === "eng"));
await post("/admin/permissions/docs%3Awrite/members/delete", `_csrf=${token}&member=group:eng`);
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "docs:write" && tp.subject_set?.object === "eng"));
// Unassigning a *user* membership likewise revokes that user's live token, so the loss of access is immediate.
await post("/admin/permissions/editor/members", `_csrf=${token}&member=user:${grace}`);
await post("/admin/permissions/editor/members/delete", `_csrf=${token}&member=user:${grace}`);
await post("/admin/permissions/docs%3Awrite/members", `_csrf=${token}&member=user:${grace}`);
await post("/admin/permissions/docs%3Awrite/members/delete", `_csrf=${token}&member=user:${grace}`);
assert.equal(denylist.isRevoked(grace, 0), true);
// Delete the permission: a confirm step (GET) then the POST removes every member tuple, back to the list.
assert.match(await (await get("/admin/permissions/editor/delete")).text(), /Cancel/);
const del = await post("/admin/permissions/editor/delete", `_csrf=${token}`);
assert.match(await (await get("/admin/permissions/docs%3Awrite/delete")).text(), /Cancel/);
const del = await post("/admin/permissions/docs%3Awrite/delete", `_csrf=${token}`);
assert.equal(del.status, 303);
assert.equal(del.headers.get("location"), "/admin/permissions");
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor"));
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "docs:write"));
// Self-protection: the admin permission can't be deleted, nor can you revoke your own admin (sub admin1).
tuples.push({ namespace: "Permission", object: "admin", relation: "granted", subject_id: "user:admin1" });
assert.equal((await post("/admin/permissions/admin/delete", `_csrf=${token}`)).status, 400);
assert.ok(tuples.some((tp) => tp.object === "admin"));
assert.equal((await post("/admin/permissions/admin/members/delete", `_csrf=${token}&member=user:admin1`)).status, 400);
assert.ok(tuples.some((tp) => tp.object === "admin" && tp.subject_id === "user:admin1"));
// Self-protection: permissions:write can't be deleted — without it nobody could grant anything
// again — nor can you revoke your own direct grant of it (sub admin1).
tuples.push({ namespace: "Permission", object: "permissions:write", relation: "granted", subject_id: "user:admin1" });
assert.equal((await post("/admin/permissions/permissions%3Awrite/delete", `_csrf=${token}`)).status, 400);
assert.ok(tuples.some((tp) => tp.object === "permissions:write"));
assert.equal((await post("/admin/permissions/permissions%3Awrite/members/delete", `_csrf=${token}&member=user:admin1`)).status, 400);
assert.ok(tuples.some((tp) => tp.object === "permissions:write" && tp.subject_id === "user:admin1"));
// A permission written before the <resource>:<action> rule stays addressable, so it can be cleaned up.
tuples.push({ namespace: "Permission", object: "legacy", relation: "granted", subject_id: `user:${ada}` });
assert.equal((await get("/admin/permissions/legacy")).status, 200);
assert.equal((await post("/admin/permissions/legacy/delete", `_csrf=${token}`)).status, 303);
assert.ok(!tuples.some((tp) => tp.object === "legacy"));
// An invalid permission name in the path → 404; malformed %-encoding doesn't 500.
assert.equal((await get("/admin/permissions/Bad%20Name")).status, 404);
+3 -1
View File
@@ -37,7 +37,9 @@ export interface Route {
}
// A Keto Permission this plugin gates on — declared for docs/seeding. Permission names are a shared
// global namespace (so an operator grants them once in Keto); namespace as `<id>:<action>`.
// global namespace (so an operator grants them once in Keto) and are always `<resource>:<action>`
// `scheduling:read`, `users:write`. A bare word names who someone is rather than what they may do,
// which is a role, and roles are groups here (README → Users, groups & permissions).
export interface PermissionDecl {
description?: string;
name: string;