Permission names are <resource>:<action>, replacing the catch-all admin permission
CI / full-gate (push) Successful in 2m43s
CI / full-gate (push) Successful in 2m43s
This commit is contained in:
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user