Permission names are <resource>:<action>, replacing the catch-all admin permission

This commit is contained in:
2026-08-05 12:45:06 +02:00
parent 9412b90946
commit 27fee5f8a3
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);