Rename the coarse gate from role to permission, matching RBAC

This commit is contained in:
2026-08-03 17:02:47 +02:00
parent 04fe5b1e06
commit 096720904e
79 changed files with 744 additions and 738 deletions
+24 -24
View File
@@ -1,11 +1,11 @@
// One-command bootstrap: idempotent first-boot seeding. Guards the pure payload
// builders (Kratos create-identity body + Keto role tuple), the idempotent seedAdmin
// builders (Kratos create-identity body + Keto permission tuple), the idempotent seedAdmin
// orchestration (fresh 201 vs existing 409 → reuse id), and the JWKS generate-if-absent
// safety net. Live boot is verified by running the stack; these catch contract drift.
import { test } from "node:test";
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { ensureJwks, firstRunBanner, identityPayload, roleTuple, seedAdmin, seedRoles } from "./bootstrap.ts";
import { ensureJwks, firstRunBanner, identityPayload, permissionTuple, seedAdmin, seedPermissions } from "./bootstrap.ts";
const json = (status: number, body?: unknown) =>
new Response(body === undefined ? null : JSON.stringify(body), {
@@ -20,27 +20,27 @@ test("identityPayload is a valid Kratos create-identity body with a password cre
assert.equal(body.credentials.password.config.password, "admin");
});
test("roleTuple grants a role to identity:<id> in the Role namespace", () => {
test("permissionTuple grants a permission to identity:<id> in the Permission namespace", () => {
const id = randomUUID();
assert.deepEqual(roleTuple(id, "admin"), {
namespace: "Role",
assert.deepEqual(permissionTuple(id, "admin"), {
namespace: "Permission",
object: "admin",
relation: "members",
relation: "granted",
subject_id: `identity:${id}`,
});
});
test("seedRoles unions ADMIN_ROLES (default 'admin') with the discovered plugins' declared roles", () => {
// Clean clone: no ADMIN_ROLES, the scheduling plugin declares its two tokens → the demo admin
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(seedRoles(undefined, ["scheduling:read", "scheduling:write"]), ["admin", "scheduling:read", "scheduling:write"]);
assert.deepEqual(seedRoles(undefined, []), ["admin"]); // no plugins → just the base admin role
assert.deepEqual(seedRoles("admin, ops ", ["inventory:read"]), ["admin", "ops", "inventory:read"]); // env trimmed + extended
assert.deepEqual(seedRoles("admin,scheduling:read", ["scheduling:read"]), ["admin", "scheduling:read"]); // dedup, no double grant
assert.deepEqual(seedRoles("admin,, ", [" scheduling:read ", ""]), ["admin", "scheduling:read"]); // blanks dropped, tokens trimmed (both sides)
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("seedAdmin on a fresh stack creates the identity and grants every role (one tuple each)", async () => {
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 }[] = [];
const fetchImpl = (async (url, init) => {
@@ -57,20 +57,20 @@ test("seedAdmin on a fresh stack creates the identity and grants every role (one
ketoWriteUrl: "http://keto:4467",
kratosAdminUrl: "http://kratos:4434",
password: "admin",
roles: ["admin", "scheduling:read"],
permissions: ["admin", "scheduling:read"],
});
assert.deepEqual(result, { created: true, id, roles: ["admin", "scheduling:read"] });
assert.deepEqual(result, { created: true, id, permissions: ["admin", "scheduling:read"] });
const puts = calls.filter((c) => c.url.includes("relation-tuples"));
assert.equal(puts.length, 2); // one grant per role
assert.equal(puts.length, 2); // one grant per permission
assert.ok(puts.every((p) => p.method === "PUT"));
assert.deepEqual(puts.map((p) => p.body), [
{ namespace: "Role", object: "admin", relation: "members", subject_id: `identity:${id}` },
{ namespace: "Role", object: "scheduling:read", relation: "members", subject_id: `identity:${id}` },
{ namespace: "Permission", object: "admin", relation: "granted", subject_id: `identity:${id}` },
{ namespace: "Permission", object: "scheduling:read", relation: "granted", subject_id: `identity:${id}` },
]);
});
test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants the role", async () => {
test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants the permission", async () => {
const id = randomUUID();
let granted: unknown;
const fetchImpl = (async (url, init) => {
@@ -90,11 +90,11 @@ test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants
ketoWriteUrl: "http://keto:4467",
kratosAdminUrl: "http://kratos:4434",
password: "admin",
roles: ["admin"],
permissions: ["admin"],
});
assert.deepEqual(result, { created: false, id, roles: ["admin"] });
assert.deepEqual(granted, { namespace: "Role", object: "admin", relation: "members", subject_id: `identity:${id}` });
assert.deepEqual(result, { created: false, id, permissions: ["admin"] });
assert.deepEqual(granted, { namespace: "Permission", object: "admin", relation: "granted", subject_id: `identity:${id}` });
});
test("seedAdmin fails loud on an unexpected Kratos error", async () => {
@@ -106,7 +106,7 @@ test("seedAdmin fails loud on an unexpected Kratos error", async () => {
ketoWriteUrl: "http://keto:4467",
kratosAdminUrl: "http://kratos:4434",
password: "admin",
roles: ["admin"],
permissions: ["admin"],
}),
/Kratos/,
);
+23 -23
View File
@@ -2,8 +2,8 @@
// 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 roles in Keto so menu/role checks resolve out of the box — `admin` plus
// every discovered plugin's declared role names, so a dropped-in plugin is usable by
// 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).
// Then prints a first-run banner; fails loud on any unexpected upstream error.
import { existsSync, writeFileSync } from "node:fs";
@@ -22,19 +22,19 @@ export function identityPayload(email: string, password: string) {
};
}
// Coarse-role grant: `Role:<role>#members@identity:<id>`. Subject ids are `identity:<kratos-id>`
// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT roles.
export function roleTuple(identityId: string, role: string) {
return { namespace: "Role", object: role, relation: "members", subject_id: `identity:${identityId}` };
// Coarse-permission grant: `Permission:<permission>#members@identity:<id>`. Subject ids are `identity:<kratos-id>`
// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT permissions.
export function permissionTuple(identityId: string, permission: string) {
return { namespace: "Permission", object: permission, relation: "granted", subject_id: `identity:${identityId}` };
}
// The roles to grant the demo admin = the configured base (ADMIN_ROLES, default just `admin`)
// unioned with every discovered plugin's declared role names (a route/nav `role` is a
// coarse role — granted as a Keto `Role:<token>#members` tuple). So the host names no plugin, yet a
// The permissions to grant the demo admin = the configured base (ADMIN_PERMISSIONS, default just `admin`)
// 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 seedRoles(adminRolesEnv: string | undefined, declaredRoles: string[]): string[] {
export function seedPermissions(adminRolesEnv: 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(declaredRoles)])];
return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredPermissions)])];
}
// --- JWKS safety net -----------------------------------------------------------------
@@ -63,13 +63,13 @@ export interface SeedOptions {
ketoWriteUrl: string;
kratosAdminUrl: string;
password: string;
roles: string[];
permissions: string[];
}
export interface SeedResult {
created: boolean;
id: string;
roles: string[];
permissions: string[];
}
export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
@@ -93,17 +93,17 @@ export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
throw new Error(`bootstrap: Kratos create identity failed (${res.status}): ${await res.text()}`);
}
// Grant each role in Keto. PUT is idempotent — re-running just re-asserts the tuple.
for (const role of opts.roles) {
// Grant each permission in Keto. PUT is idempotent — re-running just re-asserts the tuple.
for (const permission of opts.permissions) {
const grant = await http(`${opts.ketoWriteUrl}/admin/relation-tuples`, {
body: JSON.stringify(roleTuple(id, role)),
body: JSON.stringify(permissionTuple(id, permission)),
headers: { "content-type": "application/json" },
method: "PUT",
});
if (!grant.ok) throw new Error(`bootstrap: Keto grant role "${role}" failed (${grant.status}): ${await grant.text()}`);
if (!grant.ok) throw new Error(`bootstrap: Keto grant permission "${permission}" failed (${grant.status}): ${await grant.text()}`);
}
return { created, id, roles: opts.roles };
return { created, id, permissions: opts.permissions };
}
async function findIdentityId(http: typeof fetch, adminUrl: string, email: string): Promise<string> {
@@ -143,10 +143,10 @@ 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_ROLES) + every discovered plugin's declared role names, so the
// Seed `admin` (or ADMIN_PERMISSIONS) + every discovered plugin's declared permission names, 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.roles ?? []).map((d) => d.name));
const roles = seedRoles(env["ADMIN_ROLES"], declared);
const declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.name));
const permissions = seedPermissions(env["ADMIN_PERMISSIONS"], declared);
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
const password = env["ADMIN_PASSWORD"] ?? "admin";
const result = await seedAdmin({
@@ -155,9 +155,9 @@ async function main() {
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
password,
roles,
permissions,
});
log.info("admin seeded", { created: result.created, id: result.id, roles: result.roles.join(", ") });
log.info("admin seeded", { created: result.created, id: result.id, permissions: result.permissions.join(", ") });
// The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw.
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
});
+3 -3
View File
@@ -1,12 +1,12 @@
// Optional revocation denylist: instant role/session revoke without putting Keto
// Optional revocation denylist: instant permission/session revoke without putting Keto
// back on the hot path. Off by default — enable with REVOCATION_DENYLIST=true.
//
// The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked role or a
// The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked permission or a
// killed session only takes effect when the token is next minted (re-login / TTL refresh) —
// up to one token TTL of lag. For security-critical revoke (offboarding, a compromised
// account) that lag is too long. An admin action records the subject as revoked-now and the
// hot path then rejects that subject's pre-revoke tokens at once, forcing a re-mint (which
// re-reads roles from Keto, or clears a now-dead session).
// re-reads permissions from Keto, or clears a now-dead session).
//
// Cost & scope: an in-memory, auto-evicting Map — no database, like the JWKS cache, so it
// stays inside the stateless model. A token carries `iat`, so a *fresh* re-login (iat after
+1 -1
View File
@@ -48,7 +48,7 @@ test("rotateJwks --prune keeps only the newest (first) key, dropping superseded
test("a JWS signed with a generated key verifies via our own verifier (reads what Kratos signs)", () => {
const key = generateJwks().keys[0]!;
const head = b64url(JSON.stringify({ alg: "ES256", kid: key.kid }));
const body = b64url(JSON.stringify({ email: "a@b.c", roles: [], sub: key.kid }));
const body = b64url(JSON.stringify({ email: "a@b.c", permissions: [], sub: key.kid }));
const sig = sign("SHA256", Buffer.from(`${head}.${body}`), { dsaEncoding: "ieee-p1363", key: createPrivateKey({ key: key as unknown as JsonWebKey, format: "jwk" }) });
const token = `${head}.${body}.${sig.toString("base64url")}`;
+2 -2
View File
@@ -12,7 +12,7 @@ function ctxFor(user: SessionIdentity | null, url = "/"): RequestContext {
return buildContext(req, new ServerResponse(req), { identity: user });
}
const alice: SessionIdentity = { email: "a@b.c", id: "u1", roles: ["admin", "scheduling:read"] };
const alice: SessionIdentity = { email: "a@b.c", id: "u1", permissions: ["admin", "scheduling:read"] };
test("requireSession returns the user, or throws GuardError(401)→/login (preserving return_to) when anonymous", () => {
assert.equal(requireSession(ctxFor(alice)), alice);
@@ -30,7 +30,7 @@ test("requireSession returns the user, or throws GuardError(401)→/login (prese
err instanceof GuardError && err.location === "/login?return_to=%2Fscheduling%2Fshifts%3Fq%3D1");
});
test("can reads a coarse role from the JWT claims; anonymous has none", () => {
test("can reads a coarse permission from the JWT claims; anonymous has none", () => {
assert.equal(can(ctxFor(alice), "admin"), true);
assert.equal(can(ctxFor(alice), "billing:write"), false);
assert.equal(can(ctxFor(null), "admin"), false);
+4 -4
View File
@@ -1,5 +1,5 @@
// Auth guards: in-handler authorization, the imperative counterpart to the
// declarative route `role` gate. The middleware already verified the session JWT and put
// declarative route `permission` gate. The middleware already verified the session JWT and put
// the User on ctx; these read it. `requireSession` asserts (throws GuardError, which app.ts maps
// to a response); `can`/`check` are predicates a handler branches on. `check` is the one live
// Keto call — the fine-grained "may I?" tier (README), reserved for relationship rules.
@@ -37,9 +37,9 @@ export function requireSession(ctx: RequestContext): SessionIdentity {
return ctx.identity;
}
// Coarse role check straight from the JWT claims — in-process, zero I/O. Anonymous ⇒ false.
export function can(ctx: RequestContext, role: string): boolean {
return ctx.roles.includes(role);
// Coarse permission check straight from the JWT claims — in-process, zero I/O. Anonymous ⇒ false.
export function can(ctx: RequestContext, permission: string): boolean {
return ctx.permissions.includes(permission);
}
// Live Keto relationship check at the point of action. The subject is the current user;
+7 -7
View File
@@ -22,11 +22,11 @@ const jwk2: JsonWebKey = { ...(k2.publicKey.export({ format: "jwk" }) as JsonWeb
const jwks = staticJwks([jwk1, jwk2]); // rotated set: two live keys
const NOW = 1_700_000_000; // fixed clock for deterministic exp/nbf checks
const valid = { email: "a@b.c", exp: NOW + 600, roles: ["admin"], sub: "u1" };
const valid = { email: "a@b.c", exp: NOW + 600, permissions: ["admin"], sub: "u1" };
test("verifyToken: a valid token → User, selecting the verify key by kid across a rotated set", async () => {
const user = await verifyToken(mint(k2.privateKey, "k2", valid), jwks, { now: NOW });
assert.deepEqual(user, { email: "a@b.c", id: "u1", roles: ["admin"] });
assert.deepEqual(user, { email: "a@b.c", id: "u1", permissions: ["admin"] });
});
test("verifyToken requires exp, rejects expiry and future nbf, with clock-skew leeway", async () => {
@@ -59,18 +59,18 @@ test("verifyToken rejects a bad signature and an unknown kid", async () => {
await assert.rejects(verifyToken(mint(k1.privateKey, "nope", valid), jwks, { now: NOW }), /no JWKS key/);
});
test("claimsToIdentity requires sub + email, defaults roles to [], keeps only string roles", () => {
test("claimsToIdentity requires sub + email, defaults permissions to [], keeps only string permissions", () => {
assert.throws(() => claimsToIdentity({ email: "a@b.c", exp: NOW }), /sub/);
assert.throws(() => claimsToIdentity({ email: "a@b.c", exp: NOW, sub: "" }), /sub/); // empty sub rejected too
assert.throws(() => claimsToIdentity({ exp: NOW, sub: "u" }), /email/);
assert.throws(() => claimsToIdentity({ email: "", exp: NOW, sub: "u" }), /email/); // empty email rejected (the shell keys signed-in vs anonymous off it)
assert.deepEqual(claimsToIdentity({ email: "a@b.c", sub: "u" }).roles, []); // roles absent
assert.deepEqual(claimsToIdentity({ email: "a@b.c", roles: ["a", 1, "b"], sub: "u" }).roles, ["a", "b"]);
assert.deepEqual(claimsToIdentity({ email: "a@b.c", sub: "u" }).permissions, []); // permissions absent
assert.deepEqual(claimsToIdentity({ email: "a@b.c", permissions: ["a", 1, "b"], sub: "u" }).permissions, ["a", "b"]);
});
test("resolveSession classifies the cookie; authenticate is its fail-closed identity projection", async () => {
const cookie = (extra: Record<string, unknown> = {}, kid = "k1") => `${SESSION_COOKIE}=${mint(k1.privateKey, kid, { ...valid, ...extra })}`;
const identity = { email: "a@b.c", id: "u1", roles: ["admin"] };
const identity = { email: "a@b.c", id: "u1", permissions: ["admin"] };
// A valid token → the user, not expired.
assert.deepEqual(await resolveSession(cookie(), jwks, { now: NOW }), { expired: false, identity });
@@ -96,6 +96,6 @@ test("verifyToken honours an optional denylist: a revoked subject's token reject
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 }), jwks, { denylist, now: NOW }), /revoked/);
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 })}`, jwks, { denylist, now: NOW }), { expired: true, identity: null });
// A token minted after the revoke (fresh login) is accepted; a different subject is untouched.
assert.deepEqual(await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW + 5 }), jwks, { denylist, now: NOW }), { email: "a@b.c", id: "u1", roles: ["admin"] });
assert.deepEqual(await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW + 5 }), jwks, { denylist, now: NOW }), { email: "a@b.c", id: "u1", permissions: ["admin"] });
await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5, sub: "u2" }), jwks, { denylist, now: NOW });
});
+5 -5
View File
@@ -2,7 +2,7 @@
// the hot path that never calls Ory. Select the verify key by `kid` from the cached JWKS,
// check the signature (src/auth/jwt.ts), validate the time/issuer/audience claims, project the
// User onto the request context. `authenticate` fails closed: any bad/expired token ⇒ null
// (anonymous), so the route renders signed-out and the role gate denies.
// (anonymous), so the route renders signed-out and the permission gate denies.
import type { SessionIdentity } from "../http/context.ts";
import { parseCookies } from "../http/cookie.ts";
import type { Denylist } from "./denylist.ts";
@@ -59,15 +59,15 @@ export function validateClaims(payload: Record<string, unknown>, options: Verify
}
// Map verified claims → the request User. sub/email are required and non-empty (the tokenizer
// always sets them; an empty email would read as anonymous in the shell); roles defaults to [] and
// always sets them; an empty email would read as anonymous in the shell); permissions defaults to [] and
// keeps only string entries (defensive).
export function claimsToIdentity(payload: Record<string, unknown>): SessionIdentity {
const sub = payload["sub"];
if (typeof sub !== "string" || sub === "") throw new TokenError("token missing sub");
const email = payload["email"];
if (typeof email !== "string" || email === "") throw new TokenError("token missing email");
const roles = payload["roles"];
return { email, id: sub, roles: Array.isArray(roles) ? roles.filter((r): r is string => typeof r === "string") : [] };
const permissions = payload["permissions"];
return { email, id: sub, permissions: Array.isArray(permissions) ? permissions.filter((r): r is string => typeof r === "string") : [] };
}
// Verify a session JWT end-to-end: select the key by `kid`, check the signature, validate
@@ -80,7 +80,7 @@ export async function verifyToken(token: string, jwks: JwksProvider, options: Ve
validateClaims(verified.payload, options);
const user = claimsToIdentity(verified.payload);
// Instant revoke: a denylisted subject's pre-revoke token is rejected as *expired* so
// resolveSession routes it through the re-mint (fresh roles from Keto, or a cleared session).
// resolveSession routes it through the re-mint (fresh permissions from Keto, or a cleared session).
if (options.denylist?.isRevoked(user.id, num(verified.payload, "iat"))) throw new TokenError("token revoked", true);
return user;
}
+4 -4
View File
@@ -22,10 +22,10 @@ const rsaJwk = rsa.publicKey.export({ format: "jwk" }) as JsonWebKey;
const ecJwk = ec.publicKey.export({ format: "jwk" }) as JsonWebKey;
test("verifies an RS256 token, returning the decoded header + payload", () => {
const token = makeJws("RS256", rsa.privateKey, { roles: ["admin"], sub: "u" });
const token = makeJws("RS256", rsa.privateKey, { permissions: ["admin"], sub: "u" });
const verified = verifyJws(token, rsaJwk);
assert.equal(verified.header.alg, "RS256");
assert.deepEqual(verified.payload, { roles: ["admin"], sub: "u" });
assert.deepEqual(verified.payload, { permissions: ["admin"], sub: "u" });
});
test("verifies an ES256 token (raw r‖s signature)", () => {
@@ -35,10 +35,10 @@ test("verifies an ES256 token (raw r‖s signature)", () => {
// All three reach and fail the signature check itself, not an earlier structural guard.
test("rejects a signature that fails verification (tampered payload, wrong key, empty)", () => {
const token = makeJws("RS256", rsa.privateKey, { roles: ["user"], sub: "u" });
const token = makeJws("RS256", rsa.privateKey, { permissions: ["user"], sub: "u" });
const [header, payload, signature] = token.split(".");
const forged = `${header}.${b64url(JSON.stringify({ roles: ["admin"], sub: "u" }))}.${signature}`;
const forged = `${header}.${b64url(JSON.stringify({ permissions: ["admin"], sub: "u" }))}.${signature}`;
assert.throws(() => verifyJws(forged, rsaJwk), /invalid signature/);
const otherJwk = generateKeyPairSync("rsa", { modulusLength: 2048 }).publicKey.export({ format: "jwk" }) as JsonWebKey;
+14 -14
View File
@@ -29,13 +29,13 @@ const keto = (fetchImpl: typeof fetch) => createKetoClient({ fetchImpl, readUrl:
test("check GETs the read API and returns the allowed boolean (true and false)", async () => {
const allow = recorder(() => res(200, { allowed: true }));
assert.equal(await keto(allow.fetchImpl).check({ namespace: "Role", object: "admin", relation: "members", subject_id: USER }), true);
assert.equal(await keto(allow.fetchImpl).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }), true);
assert.match(allow.calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/check\?/);
assert.match(allow.calls[0]!.url, /namespace=Role&object=admin&relation=members/);
assert.match(allow.calls[0]!.url, /namespace=Permission&object=admin&relation=granted/);
assert.match(allow.calls[0]!.url, new RegExp(`subject_id=${encodeURIComponent(USER).replace(/[.]/g, "\\.")}`));
// A denied check is 403 {allowed:false} (not a 200) — both statuses carry the verdict.
const deny = recorder(() => res(403, { allowed: false }));
assert.equal(await keto(deny.fetchImpl).check({ namespace: "Role", object: "admin", relation: "members", subject_id: "identity:nobody" }), false);
assert.equal(await keto(deny.fetchImpl).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: "identity:nobody" }), false);
});
test("check on a subject_set builds subject_set.* params and forwards max-depth", async () => {
@@ -51,20 +51,20 @@ test("check on a subject_set builds subject_set.* params and forwards max-depth"
test("check throws a KetoError carrying the status on an unexpected response", async () => {
await assert.rejects(
keto((async () => res(400, { error: "bad" })) as typeof fetch).check({ namespace: "Role", object: "admin", relation: "members", subject_id: USER }),
keto((async () => res(400, { error: "bad" })) as typeof fetch).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }),
(e: unknown) => e instanceof KetoError && e.status === 400,
);
});
test("listRelations builds the filter query + pagination and parses next_page_token", async () => {
const tuples = [{ namespace: "Role", object: "admin", relation: "members", subject_id: USER }];
const tuples = [{ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }];
const { calls, fetchImpl } = recorder(() => res(200, { next_page_token: "NEXT", relation_tuples: tuples }));
const out = await keto(fetchImpl).listRelations({ namespace: "Role", object: "admin", pageSize: 10, pageToken: "CUR", relation: "members" });
const out = await keto(fetchImpl).listRelations({ namespace: "Permission", object: "admin", pageSize: 10, pageToken: "CUR", relation: "granted" });
assert.deepEqual(out.tuples, tuples);
assert.equal(out.nextPageToken, "NEXT");
const url = calls[0]!.url;
assert.match(url, /^http:\/\/keto:4466\/relation-tuples\?/);
assert.match(url, /namespace=Role&object=admin&relation=members/);
assert.match(url, /namespace=Permission&object=admin&relation=granted/);
assert.match(url, /page_size=10&page_token=CUR/);
// No Link header / token in the body ⇒ null, empty list ⇒ [].
const empty = await keto((async () => res(200, {})) as typeof fetch).listRelations();
@@ -72,16 +72,16 @@ test("listRelations builds the filter query + pagination and parses next_page_to
});
test("expand GETs the read API for a subject set and returns the tree (with max-depth)", async () => {
const tree = { children: [{ tuple: { namespace: "", object: "", relation: "", subject_id: USER }, type: "leaf" }], tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Role", object: "admin", relation: "members" } }, type: "union" };
const tree = { children: [{ tuple: { namespace: "", object: "", relation: "", subject_id: USER }, type: "leaf" }], tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Permission", object: "admin", relation: "granted" } }, type: "union" };
const { calls, fetchImpl } = recorder(() => res(200, tree));
const out = await keto(fetchImpl).expand({ namespace: "Role", object: "admin", relation: "members" }, { maxDepth: 3 });
const out = await keto(fetchImpl).expand({ namespace: "Permission", object: "admin", relation: "granted" }, { maxDepth: 3 });
assert.deepEqual(out, tree);
assert.match(calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/expand\?/);
assert.match(calls[0]!.url, /namespace=Role&object=admin&relation=members&max-depth=3/);
assert.match(calls[0]!.url, /namespace=Permission&object=admin&relation=granted&max-depth=3/);
});
test("writeTuple PUTs the tuple as JSON to the write API (idempotent; non-2xx throws)", async () => {
const tuple = { namespace: "Role", object: "admin", relation: "members", subject_id: USER };
const tuple = { namespace: "Permission", object: "admin", relation: "granted", subject_id: USER };
const { calls, fetchImpl } = recorder(() => res(201, tuple));
await keto(fetchImpl).writeTuple(tuple);
assert.equal(calls[0]!.method, "PUT");
@@ -95,12 +95,12 @@ test("writeTuple PUTs the tuple as JSON to the write API (idempotent; non-2xx th
test("deleteTuple DELETEs the write API by query params (204 resolves; non-204 throws)", async () => {
const { calls, fetchImpl } = recorder(() => res(204));
await keto(fetchImpl).deleteTuple({ namespace: "Role", object: "admin", relation: "members", subject_id: USER });
await keto(fetchImpl).deleteTuple({ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER });
assert.equal(calls[0]!.method, "DELETE");
assert.match(calls[0]!.url, /^http:\/\/keto:4467\/admin\/relation-tuples\?/);
assert.match(calls[0]!.url, /namespace=Role&object=admin&relation=members/);
assert.match(calls[0]!.url, /namespace=Permission&object=admin&relation=granted/);
await assert.rejects(
keto((async () => res(404)) as typeof fetch).deleteTuple({ namespace: "Role", object: "x", relation: "members", subject_id: USER }),
keto((async () => res(404)) as typeof fetch).deleteTuple({ namespace: "Permission", object: "x", relation: "granted", subject_id: USER }),
(e: unknown) => e instanceof KetoError && e.status === 404,
);
});
+1 -1
View File
@@ -13,7 +13,7 @@ export interface SubjectSet {
}
// A relationship tuple — the wire shape for writes and the filter shape for reads. Subject
// is `subject_id` xor `subject_set` (never both). Mirrors bootstrap.ts's roleTuple.
// is `subject_id` xor `subject_set` (never both). Mirrors bootstrap.ts's permissionTuple.
export interface RelationTuple {
namespace: string;
object: string;
+4 -4
View File
@@ -1,5 +1,5 @@
// Kratos admin-API client: typed fetch wrappers over Ory Kratos' admin endpoints —
// identity CRUD + the surgical metadata_public update the login flow projects roles into.
// identity CRUD + the surgical metadata_public update the login flow projects permissions into.
// Guards the request contracts (URLs, method, JSON-Patch body, query/pagination) and the
// result mapping (201/200/404/4xx). Live wiring is verified by login completion.
import { test } from "node:test";
@@ -90,13 +90,13 @@ test("updateIdentity PUTs the full body to /admin/identities/<id> and returns th
});
test("updateMetadataPublic PATCHes a JSON-Patch `add /metadata_public` so it never clobbers traits", async () => {
const identity = { id: ID, metadata_public: { roles: ["admin"] } };
const identity = { id: ID, metadata_public: { permissions: ["admin"] } };
const { calls, fetchImpl } = recorder(() => res(200, identity));
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).updateMetadataPublic(ID, { roles: ["admin"] });
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).updateMetadataPublic(ID, { permissions: ["admin"] });
assert.deepEqual(out, identity);
assert.equal(calls[0]!.method, "PATCH");
assert.match(calls[0]!.url, new RegExp(`/admin/identities/${ID}$`));
assert.deepEqual(JSON.parse(calls[0]!.body!), [{ op: "add", path: "/metadata_public", value: { roles: ["admin"] } }]);
assert.deepEqual(JSON.parse(calls[0]!.body!), [{ op: "add", path: "/metadata_public", value: { permissions: ["admin"] } }]);
});
test("createRecoveryCode POSTs the identity id to /admin/recovery/code → { code, link }", async () => {
+2 -2
View File
@@ -1,6 +1,6 @@
// Kratos admin-API client: typed `fetch` wrappers over Ory Kratos' admin endpoints
// (internal-only admin port) — identity CRUD + the surgical `metadata_public` update login
// completion projects Keto roles into (README). Built-in `fetch` only, no SDK dep (AGENTS.md);
// completion projects Keto permissions into (README). Built-in `fetch` only, no SDK dep (AGENTS.md);
// `fetchImpl`-injectable, reuses kratos-public.ts's `KratosError` (branch on `.status`).
import { KratosError } from "./kratos-public.ts";
@@ -106,7 +106,7 @@ export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof
},
// JSON Patch `add` sets metadata_public whether it's currently absent, null, or set, and
// touches nothing else — so the login role projection never clobbers traits/state.
// touches nothing else — so the login permission projection never clobbers traits/state.
// (metadata_public, not _admin: the session the tokenizer sees carries only public metadata.)
async updateMetadataPublic(id, metadata) {
const patch = [{ op: "add", path: "/metadata_public", value: metadata }];
+20 -20
View File
@@ -1,4 +1,4 @@
// Login completion: turn a Kratos session into our session JWT — read roles from Keto,
// Login completion: turn a Kratos session into our session JWT — read permissions from Keto,
// project them onto the identity, tokenize, build the cookie. Fakes the three Ory clients;
// the live, full-stack login is verified by the Playwright E2E.
import { test } from "node:test";
@@ -6,10 +6,10 @@ import assert from "node:assert/strict";
import type { KetoClient, RelationTuple } from "./keto-client.ts";
import type { Identity, KratosAdmin } from "./kratos-admin.ts";
import type { KratosPublic, Session } from "./kratos-public.ts";
import { completeLogin, readRoles, remintSession, SESSION_COOKIE, sessionCookie } from "./login.ts";
import { completeLogin, readPermissions, remintSession, SESSION_COOKIE, sessionCookie } from "./login.ts";
const ID = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
const roleTuple = (object: string): RelationTuple => ({ namespace: "Role", object, relation: "members", subject_id: `identity:${ID}` });
const permissionTuple = (object: string): RelationTuple => ({ namespace: "Permission", object, relation: "granted", subject_id: `identity:${ID}` });
const ketoStub = (over: Partial<KetoClient> = {}): KetoClient => ({
check: async () => false,
@@ -40,32 +40,32 @@ const publicStub = (over: Partial<KratosPublic> = {}): KratosPublic => ({
...over,
});
test("readRoles returns roles held directly OR transitively (enumerate defined roles → Keto-check each)", async () => {
test("readPermissions returns permissions held directly OR transitively (enumerate defined permissions → Keto-check each)", async () => {
const listQ: unknown[] = [];
const checked: string[] = [];
const role = (object: string, subject: Partial<RelationTuple>): RelationTuple => ({ namespace: "Role", object, relation: "members", ...subject });
const permission = (object: string, subject: Partial<RelationTuple>): RelationTuple => ({ namespace: "Permission", object, relation: "granted", ...subject });
const keto = ketoStub({
// Enumerate every Role tuple (paged, no subject filter) to find the distinct role names —
// Enumerate every Permission tuple (paged, no subject filter) to find the distinct permission names —
// subjects vary (a direct user, a group) and a name repeats across pages → de-duped.
listRelations: async (q) => {
listQ.push(q);
if (q?.pageToken === "p2") return { nextPageToken: null, tuples: [role("editor", { subject_id: "identity:other" })] };
if (q?.pageToken === "p2") return { nextPageToken: null, tuples: [permission("editor", { subject_id: "identity:other" })] };
return { nextPageToken: "p2", tuples: [
role("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }),
role("admin", { subject_id: `identity:${ID}` }),
role("viewer", { subject_id: "identity:stranger" }),
permission("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }),
permission("admin", { subject_id: `identity:${ID}` }),
permission("viewer", { subject_id: "identity:stranger" }),
] };
},
// Keto resolves transitively: the user holds editor (via a group) + admin (direct), not viewer.
check: async (t) => { checked.push(t.object); return t.object === "admin" || t.object === "editor"; },
});
assert.deepEqual(await readRoles(keto, ID), ["admin", "editor"]);
assert.deepEqual(listQ[0], { namespace: "Role", relation: "members" }); // enumerate, not subject-filtered
assert.deepEqual(await readPermissions(keto, ID), ["admin", "editor"]);
assert.deepEqual(listQ[0], { namespace: "Permission", relation: "granted" }); // enumerate, not subject-filtered
assert.equal((listQ[1] as { pageToken?: string }).pageToken, "p2"); // second page follows the cursor
assert.deepEqual(checked.sort(), ["admin", "editor", "viewer"]); // every distinct role checked for the user
assert.deepEqual(checked.sort(), ["admin", "editor", "viewer"]); // every distinct permission checked for the user
});
test("completeLogin: read roles → project onto metadata_public → tokenize → JWT (in that order)", async () => {
test("completeLogin: read permissions → project onto metadata_public → tokenize → JWT (in that order)", async () => {
const events: string[] = [];
let projected: unknown;
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
@@ -76,11 +76,11 @@ test("completeLogin: read roles → project onto metadata_public → tokenize
},
});
const kratosAdmin = adminStub({ updateMetadataPublic: async (_id, meta) => { events.push("project"); projected = meta; return identity; } });
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [roleTuple("admin")] }) });
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [permissionTuple("admin")] }) });
const out = await completeLogin({ keto, kratosAdmin, kratosPublic }, "plainpages_session=s");
assert.deepEqual(out, { email: "admin@plainpages.local", identityId: ID, jwt: "h.p.s", roles: ["admin"] });
assert.deepEqual(projected, { roles: ["admin"] }); // Keto roles, projected for the tokenizer
assert.deepEqual(out, { email: "admin@plainpages.local", identityId: ID, jwt: "h.p.s", permissions: ["admin"] });
assert.deepEqual(projected, { permissions: ["admin"] }); // Keto permissions, projected for the tokenizer
assert.deepEqual(events, ["whoami", "project", "tokenize"]); // projection MUST precede tokenize
});
@@ -101,11 +101,11 @@ test("completeLogin maps a missing email trait to null and throws if the tokeniz
test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => {
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session });
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [roleTuple("admin")] }) });
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [permissionTuple("admin")] }) });
// TTL lapsed but the Kratos session lives → re-read roles from Keto, re-tokenize, fresh cookie.
// TTL lapsed but the Kratos session lives → re-read permissions from Keto, re-tokenize, fresh cookie.
const live = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic }, "plainpages_session=s");
assert.deepEqual(live.identity, { email: "admin@plainpages.local", id: ID, roles: ["admin"] });
assert.deepEqual(live.identity, { email: "admin@plainpages.local", id: ID, permissions: ["admin"] });
assert.match(live.setCookie, /^plainpages_jwt=h\.p\.s;.*Max-Age=2592000.*HttpOnly/);
// Kratos session also gone → clear the stale JWT so the next request falls through to anonymous.
+18 -18
View File
@@ -1,9 +1,9 @@
// Login completion: turn a fresh Kratos session into our locally-verifiable
// session JWT — the one moment Ory is on the path (README: Login → session JWT):
// 1. whoami(cookie) → the identity (id, email); no active session ⇒ null
// 2. read roles from Keto → the source of truth for the `roles` claim
// 2. read permissions from Keto → the source of truth for the `permissions` claim
// 3. project onto metadata_public (admin API) so the tokenizer's mapper can read them
// 4. whoami(tokenize_as) → the signed JWT { sub, email, roles }, stored as our cookie
// 4. whoami(tokenize_as) → the signed JWT { sub, email, permissions }, stored as our cookie
// Order matters: the projection is written before tokenizing, because the claims mapper
// reads only the identity, never Keto.
import type { SessionIdentity } from "../http/context.ts";
@@ -34,26 +34,26 @@ export interface CompletedLogin {
email: string | null;
identityId: string;
jwt: string;
roles: string[];
permissions: string[];
}
// The coarse roles a user holds — directly (`Role:<name>#members@identity:<id>`) or transitively via a
// group that is a member of the role. Enumerates the defined roles (the distinct objects in the Role
// namespace) and asks Keto to resolve each membership, so a role granted to a group reaches the JWT —
// The coarse permissions a user holds — directly (`Permission:<name>#members@identity:<id>`) or transitively via a
// group that is a member of the permission. Enumerates the defined permissions (the distinct objects in the Permission
// namespace) and asks Keto to resolve each membership, so a permission granted to a group reaches the JWT —
// matching the OPL model and the admin "Effective access" view. At login/refresh only, never per
// request; role count is small, so the per-role checks are cheap and run in parallel.
export async function readRoles(keto: KetoClient, identityId: string): Promise<string[]> {
// request; permission count is small, so the per-permission checks are cheap and run in parallel.
export async function readPermissions(keto: KetoClient, identityId: string): Promise<string[]> {
const subject_id = `identity:${identityId}`;
const names = new Set<string>();
let pageToken: string | undefined;
do {
const page = await keto.listRelations({ namespace: "Role", relation: "members", ...(pageToken ? { pageToken } : {}) });
const page = await keto.listRelations({ namespace: "Permission", relation: "granted", ...(pageToken ? { pageToken } : {}) });
for (const t of page.tuples) names.add(t.object);
pageToken = page.nextPageToken ?? undefined;
} while (pageToken);
const roles = [...names];
const held = await Promise.all(roles.map((object) => keto.check({ namespace: "Role", object, relation: "members", subject_id })));
return roles.filter((_, i) => held[i]).sort();
const permissions = [...names];
const held = await Promise.all(permissions.map((object) => keto.check({ namespace: "Permission", object, relation: "granted", subject_id })));
return permissions.filter((_, i) => held[i]).sort();
}
export async function completeLogin(deps: LoginDeps, cookie: string | undefined): Promise<CompletedLogin | null> {
@@ -63,15 +63,15 @@ export async function completeLogin(deps: LoginDeps, cookie: string | undefined)
const emailTrait = session.identity.traits?.["email"];
const email = typeof emailTrait === "string" ? emailTrait : null;
const roles = await readRoles(deps.keto, identityId);
await deps.kratosAdmin.updateMetadataPublic(identityId, { roles });
const permissions = await readPermissions(deps.keto, identityId);
await deps.kratosAdmin.updateMetadataPublic(identityId, { permissions });
const tokenized = await deps.kratosPublic.whoami({ ...(cookie ? { cookie } : {}), tokenizeAs: TOKENIZE_AS });
const jwt = tokenized?.tokenized;
if (!jwt) throw new Error("login completion: Kratos tokenizer returned no JWT");
currentLog()?.info("session minted", { roles: roles.join(","), sub: identityId }); // login or TTL re-mint
return { email, identityId, jwt, roles };
currentLog()?.info("session minted", { permissions: permissions.join(","), sub: identityId }); // login or TTL re-mint
return { email, identityId, jwt, permissions };
}
export interface Reminted {
@@ -80,14 +80,14 @@ export interface Reminted {
}
// Re-mint the session JWT on TTL expiry — "stay signed in" (README): the ~10m token lapsed but
// the long-lived Kratos session may still be live. A live session ⇒ re-read roles from Keto,
// the long-lived Kratos session may still be live. A live session ⇒ re-read permissions from Keto,
// re-tokenize, fresh cookie + the refreshed user (the one moment authz recomputes). A dead
// session ⇒ a cookie that *clears* the stale JWT, so later requests fall straight through to
// anonymous instead of re-hitting Ory on every one.
export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> {
const completed = await completeLogin(deps, cookie);
if (!completed) return { setCookie: clearSessionCookie(options), identity: null };
return { setCookie: sessionCookie(completed.jwt, options), identity: { email: completed.email ?? "", id: completed.identityId, roles: completed.roles } };
return { setCookie: sessionCookie(completed.jwt, options), identity: { email: completed.email ?? "", id: completed.identityId, permissions: completed.permissions } };
}
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
+1 -1
View File
@@ -2,7 +2,7 @@
// /oauth2/consent?consent_challenge=… (hydra.yml urls.consent). A first-party client (or one
// Hydra already skipped) is auto-granted the requested scopes; a third-party client shows the
// themed consent screen, then accept (allow) / reject (deny). id_token claims (email/name) come
// from the Kratos identity. OAuth2-provider role only — no first-party page needs this (README).
// from the Kratos identity. OAuth2-provider permission only — no first-party page needs this (README).
import type { AcceptConsent, ConsentRequest, HydraAdmin, OAuth2Client } from "./hydra-admin.ts";
import type { KratosPublic } from "./kratos-public.ts";
+1 -1
View File
@@ -2,7 +2,7 @@
// Hydra hands the browser to /oauth2/login?login_challenge=… (hydra.yml urls.login). We
// authenticate the user with their existing Kratos session and accept the request; Hydra then
// proceeds to consent and mints the tokens. No first-party page needs this — it's the OAuth2
// provider role only (README).
// provider permission only (README).
import type { HydraAdmin } from "./hydra-admin.ts";
import type { KratosPublic } from "./kratos-public.ts";
+1 -1
View File
@@ -192,7 +192,7 @@ function oauthLogout(hydra: HydraAdmin): BuiltinRoute["handler"] {
}
// Login completion: where Kratos lands the browser after authenticating (kratos.yml). Mint our
// session JWT — read roles from Keto, project onto the identity, tokenize — and store it as the
// session JWT — read permissions from Keto, project onto the identity, tokenize — and store it as the
// cookie; no active session bounces back to sign in.
function completeAuth(deps: { keto: KetoClient; kratosAdmin: KratosAdmin; kratosPublic: KratosPublic }, secureCookies: boolean): BuiltinRoute["handler"] {
return async (ctx: RequestContext): Promise<RouteResult> => {
+2 -2
View File
@@ -31,7 +31,7 @@ export interface Config {
otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export)
otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors)
port: number;
revocationDenylist: boolean; // enable the optional instant role/session revoke denylist
revocationDenylist: boolean; // enable the optional instant permission/session revoke denylist
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
secureCookies: boolean;
serviceName: string; // OTLP service.name — an implementer brands their own logs/traces
@@ -157,7 +157,7 @@ export function loadConfig(env: Env = process.env): Config {
otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"),
otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"),
port: readPort(env),
// Optional instant-revoke, off by default. When on, an admin deactivate/delete or role
// Optional instant-revoke, off by default. When on, an admin deactivate/delete or permission
// change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m
// tokenizer TTL + skew, so it outlasts any pre-revoke token).
revocationDenylist: readBool(env, "REVOCATION_DENYLIST", false),
+68 -68
View File
@@ -40,9 +40,9 @@ function mintJwt(payload: Record<string, unknown>): string {
const input = `${b64url(JSON.stringify({ alg: "ES256", kid: "test-kid", typ: "JWT" }))}.${b64url(JSON.stringify(payload))}`;
return `${input}.${b64url(sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key: ec.privateKey }))}`;
}
// A session cookie carrying `roles`, valid for 10 min — the auth most tests need to reach a gated page.
const session = (roles: string[] = []): string =>
`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, roles, sub: "u1" })}`;
// A session cookie carrying `permissions`, valid for 10 min — the auth most tests need to reach a gated page.
const session = (permissions: string[] = []): string =>
`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, permissions, sub: "u1" })}`;
const server = createApp({ jwks: staticJwks([ecJwk]) });
let base = "";
@@ -83,7 +83,7 @@ test("/ is the public landing: anonymous → 200 with intro + sign-in/register l
const html = await res.text();
assert.match(html, /href="\/login"/); // a prominent path to sign in
assert.match(html, /href="\/registration"/); // and to register
// the same app shell every page renders — the menu shows even when signed out (role-filtered).
// the same app shell every page renders — the menu shows even when signed out (permission-filtered).
assert.match(html, /<aside class="sidebar"/);
assert.match(html, /class="landing-title"/); // the landing hero owns the page's single <h1>
});
@@ -385,7 +385,7 @@ test("renders the 500 HTML page when a handler throws", async () => {
}
});
// A test plugin exercising each RouteResult shape, a path param, and the role gate.
// A test plugin exercising each RouteResult shape, a path param, and the permission gate.
const demoPlugin: Plugin = {
apiVersion: "1.0.0",
id: "demo",
@@ -393,7 +393,7 @@ const demoPlugin: Plugin = {
{ handler: (ctx) => ({ html: `<p>Hi ${ctx.params.name}</p>` }), method: "GET", path: "/hello/:name" },
{ handler: () => ({ json: { ok: true } }), method: "GET", path: "/data" },
{ handler: () => ({ redirect: "/demo/hello/world" }), method: "POST", path: "/go" },
{ handler: () => ({ html: "secret" }), method: "GET", path: "/secret", role: "demo:read" },
{ handler: () => ({ html: "secret" }), method: "GET", path: "/secret", permission: "demo:read" },
{ handler: () => ({ html: "open to all" }), method: "GET", path: "/public-page", public: true }, // blessed public
{ handler: () => ({ data: { who: "Plainpages" }, view: "page" }), method: "GET", path: "/page" },
],
@@ -406,7 +406,7 @@ async function startApp(t: TestContext, plugins: Plugin[], pluginsDir?: string):
return `http://localhost:${(app.address() as AddressInfo).port}`;
}
test("mounts plugin routes: params, html/json/redirect/view results, and the role gate", async (t) => {
test("mounts plugin routes: params, html/json/redirect/view results, and the permission gate", async (t) => {
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
mkdirSync(join(dir, "demo", "views"), { recursive: true });
mkdirSync(join(dir, "demo", "public"), { recursive: true });
@@ -516,9 +516,9 @@ test("a plugin view renders the native chrome; its forms are CSRF-guarded via ct
assert.equal(ok.status, 303);
});
// JWT middleware: a verified session cookie populates ctx.identity/roles, which the gate reads.
// JWT middleware: a verified session cookie populates ctx.identity/permissions, which the gate reads.
// The key + mintJwt + session() helper are hoisted above the shared `server` (top of file).
test("a verified session JWT authorizes a role-gated route; no cookie / expired token → sign in", async (t) => {
test("a verified session JWT authorizes a permission-gated route; no cookie / expired token → sign in", async (t) => {
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [demoPlugin] });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
@@ -526,8 +526,8 @@ test("a verified session JWT authorizes a role-gated route; no cookie / expired
const nowSec = Math.floor(Date.now() / 1000);
const secret = (cookie?: string) => fetch(url + "/demo/secret", { redirect: "manual", ...(cookie ? { headers: { cookie } } : {}) });
// Token carrying the gating role → the handler runs (200).
const ok = await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["demo:read"], sub: "u1" })}`);
// Token carrying the gating permission → the handler runs (200).
const ok = await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["demo:read"], sub: "u1" })}`);
assert.equal(ok.status, 200);
assert.equal(await ok.text(), "secret");
@@ -536,12 +536,12 @@ test("a verified session JWT authorizes a role-gated route; no cookie / expired
const noCookie = await secret();
assert.equal(noCookie.status, 303);
assert.equal(noCookie.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}`)).status, 303);
assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, permissions: ["demo:read"], sub: "u1" })}`)).status, 303);
// The gated dashboard renders for any signed-in user; anonymous is bounced to sign in before any
// page renders (gate on /dashboard). The Admin section links come from the admin plugin — its nav
// composition + role-filtering is covered in the admin-screen tests below.
const dash = await fetch(url + "/dashboard", { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["admin"], sub: "u1" })}` } });
// composition + permission-filtering is covered in the admin-screen tests below.
const dash = await fetch(url + "/dashboard", { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["admin"], sub: "u1" })}` } });
assert.equal(dash.status, 200);
const anonDash = await fetch(url + "/dashboard", { redirect: "manual" });
assert.equal(anonDash.status, 303);
@@ -555,7 +555,7 @@ test("revocation denylist: a revoked subject's token stops authorizing on the ho
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const nowSec = Math.floor(Date.now() / 1000);
const secret = (iat: number) => fetch(url + "/demo/secret", { redirect: "manual", headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, iat, roles: ["demo:read"], sub: "u1" })}` } });
const secret = (iat: number) => fetch(url + "/demo/secret", { redirect: "manual", headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, iat, permissions: ["demo:read"], sub: "u1" })}` } });
assert.equal((await secret(nowSec)).status, 200); // before any revoke, the token authorizes
@@ -567,10 +567,10 @@ test("revocation denylist: a revoked subject's token stops authorizing on the ho
test("session re-mint: an expired JWT backed by a live Kratos session is silently re-minted; a dead session clears it", async (t) => {
const identity: Identity = { id: "u1", traits: { email: "a@b.c" } };
const nowSec = Math.floor(Date.now() / 1000);
const freshJwt = mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["demo:read"], sub: "u1" });
const freshJwt = mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["demo:read"], sub: "u1" });
const live = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: freshJwt } : { active: true, identity }) as Session);
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "demo:read", relation: "members", subject_id: "identity:u1" }] }) });
const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}; plainpages_session=s`;
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Permission", object: "demo:read", relation: "granted", subject_id: "identity:u1" }] }) });
const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, permissions: ["demo:read"], sub: "u1" })}; plainpages_session=s`;
// Live Kratos session: the lapsed token is re-minted — the gated route runs AND a fresh cookie rides the response.
const app = createApp({ jwks: staticJwks([ecJwk]), keto, kratos: live, kratosAdmin: stubAdmin({}), plugins: [demoPlugin] });
@@ -610,7 +610,7 @@ test("guards map to responses: requireSession → /login, a failed can/check →
{ handler: (ctx) => ({ html: `hi ${requireSession(ctx).email}` }), method: "GET", path: "/me" },
{ handler: (ctx) => { if (!can(ctx, "admin")) throw new GuardError(403, "no"); return { html: "ok" }; }, method: "GET", path: "/admin-only" },
{ handler: async (ctx) => { if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id ?? "", relation: "view" }))) throw new GuardError(403, "no"); return { html: "seen" }; }, method: "GET", path: "/doc/:id" },
{ handler: () => ({ html: "gated" }), method: "GET", path: "/gated", role: "secret:read" }, // declarative route gate
{ handler: () => ({ html: "gated" }), method: "GET", path: "/gated", permission: "secret:read" }, // declarative route gate
],
};
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [guarded] });
@@ -618,7 +618,7 @@ test("guards map to responses: requireSession → /login, a failed can/check →
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const nowSec = Math.floor(Date.now() / 1000);
const auth = (roles: string[]) => ({ headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles, sub: "u1" })}` } });
const auth = (permissions: string[]) => ({ headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions, sub: "u1" })}` } });
// requireSession: anonymous bounces to /login (remembering the page); a signed-in user reaches the handler.
const anon = await fetch(url + "/guarded/me", { redirect: "manual" });
@@ -628,7 +628,7 @@ test("guards map to responses: requireSession → /login, a failed can/check →
assert.equal(me.status, 200);
assert.match(await me.text(), /hi a@b\.c/);
// can: signed-in but lacking the role → 403 page; carrying it → 200.
// can: signed-in but lacking the permission → 403 page; carrying it → 200.
assert.equal((await fetch(url + "/guarded/admin-only", auth([]))).status, 403);
assert.equal((await fetch(url + "/guarded/admin-only", auth(["admin"]))).status, 200);
@@ -636,7 +636,7 @@ test("guards map to responses: requireSession → /login, a failed can/check →
assert.equal((await fetch(url + "/guarded/doc/open", auth([]))).status, 200);
assert.equal((await fetch(url + "/guarded/doc/shut", auth([]))).status, 403);
// declarative route `role` gate: anonymous → sign in, signed-in-without-role → the 403 page, with → 200.
// declarative route `permission` gate: anonymous → sign in, signed-in-without-permission → the 403 page, with → 200.
const gAnon = await fetch(url + "/guarded/gated", { redirect: "manual" });
assert.equal(gAnon.status, 303);
assert.equal(gAnon.headers.get("location"), "/login?return_to=%2Fguarded%2Fgated");
@@ -717,7 +717,7 @@ test("themed auth GET: anonymous inits a flow (CSRF relay, stale→restart); a s
assert.equal(stale.headers.get("location"), "/login");
// Already signed in → /login + /registration short-circuit to the app dashboard; /settings stays reachable.
const signedIn = { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, roles: [], sub: "u1" })}` }, redirect: "manual" as const };
const signedIn = { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, permissions: [], sub: "u1" })}` }, redirect: "manual" as const };
for (const path of ["/login", "/registration"]) {
const res = await fetch(url + path, signedIn);
assert.equal(res.status, 303, `${path} while signed in → 303`);
@@ -856,7 +856,7 @@ const fakeKeto = (tuples: RelationTuple[] = [], over: Partial<KetoClient> = {}):
const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockKratos(async () => { throw new Error("unused"); }), whoami });
// Shared harness for the admin-screen HTTP tests: an app on a random port with an admin JWT +
// CSRF cookie. get(path, roles)/post(path, body) carry them; `token` is the matching CSRF field.
// CSRF cookie. get(path, permissions)/post(path, body) carry them; `token` is the matching CSRF field.
const ADMIN_CSRF = "admin-secret";
async function adminHarness(t: TestContext, opts: AppOptions = {}) {
const app = createApp({ csrfSecret: ADMIN_CSRF, jwks: staticJwks([ecJwk]), pluginsDir: examplesPluginsDir, plugins: [adminPlugin], ...opts });
@@ -865,14 +865,14 @@ async function adminHarness(t: TestContext, opts: AppOptions = {}) {
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const token = issueCsrfToken(ADMIN_CSRF);
const nowSec = Math.floor(Date.now() / 1000);
const cookie = (roles: string[]) => `${SESSION_COOKIE}=${mintJwt({ email: "admin@x", exp: nowSec + 600, roles, sub: "admin1" })}; ${CSRF_COOKIE}=${token}`;
const get = (path: string, roles: string[] = ["admin"]) => fetch(url + path, { headers: { cookie: cookie(roles) }, redirect: "manual" });
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 post = (path: string, body: string) =>
fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(["admin"]) }, method: "POST", redirect: "manual" });
return { get, post, token, url };
}
// Every admin route is gated: anonymous → /login, a signed-in non-admin → 403.
async function assertAdminGate(url: string, get: (path: string, roles?: string[]) => Promise<Response>, path: string) {
async function assertAdminGate(url: string, get: (path: string, permissions?: string[]) => Promise<Response>, path: string) {
const anon = await fetch(url + path, { redirect: "manual" });
assert.equal(anon.status, 303);
assert.equal(anon.headers.get("location"), `/login?return_to=${encodeURIComponent(path)}`); // remembers the page
@@ -884,7 +884,7 @@ test("login completion (/auth/complete): a live session mints the JWT cookie; no
let projected: unknown;
const kratos = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session);
const kratosAdmin = stubAdmin({ updateMetadataPublic: async (_id, meta) => { projected = meta; return identity; } });
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "admin", relation: "members", subject_id: `identity:${identity.id}` }] }) });
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Permission", object: "admin", relation: "granted", subject_id: `identity:${identity.id}` }] }) });
const complete = async (app: ReturnType<typeof createApp>, cookie?: string, returnTo?: string) => {
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
@@ -892,12 +892,12 @@ test("login completion (/auth/complete): a live session mints the JWT cookie; no
return fetch(`http://localhost:${(app.address() as AddressInfo).port}/auth/complete${q}`, { headers: cookie ? { cookie } : {}, redirect: "manual" });
};
// Live Kratos session: roles from Keto → projection → tokenize → JWT cookie, land on the dashboard.
// Live Kratos session: permissions from Keto → projection → tokenize → JWT cookie, land on the dashboard.
const ok = await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s");
assert.equal(ok.status, 303);
assert.equal(ok.headers.get("location"), "/dashboard");
assert.match(ok.headers.get("set-cookie") ?? "", /^plainpages_jwt=h\.p\.s;.*HttpOnly/);
assert.deepEqual(projected, { roles: ["admin"] }); // Keto roles projected onto the identity for the tokenizer
assert.deepEqual(projected, { permissions: ["admin"] }); // Keto permissions projected onto the identity for the tokenizer
// return_to: a safe host-relative target lands the user back where they were headed; an
// off-origin one is ignored (open-redirect guard) and falls back to the dashboard.
@@ -1164,7 +1164,7 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
assert.equal((await post(`/admin/users/admin1/state`, `_csrf=${token}`)).status, 400);
assert.equal(store.find((x) => x.id === "admin1")!.state, "active");
// Unknown id → 404; malformed %-encoding → 404 (not a 500), matching groups/roles/clients.
// Unknown id → 404; malformed %-encoding → 404 (not a 500), matching groups/permissions/clients.
assert.equal((await get(`/admin/users/${randomUUID()}`)).status, 404);
assert.equal((await get("/admin/users/%ZZ")).status, 404);
});
@@ -1235,10 +1235,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 role whose only direct member is ada.
// grace is in the `eng` group; `editor` is an existing permission whose only direct member is ada.
const tuples: RelationTuple[] = [
{ namespace: "Group", object: "eng", relation: "members", subject_id: `identity:${grace}` },
{ namespace: "Role", object: "editor", relation: "members", subject_id: `identity:${ada}` },
{ namespace: "Permission", object: "editor", relation: "granted", subject_id: `identity:${ada}` },
];
// Mirror Keto's expand shape: the subject rides on `tuple`, set nodes carry members as children.
const expandSet = (set: SubjectSet): ExpandTree => ({
@@ -1250,70 +1250,70 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
});
const keto = fakeKeto(tuples, { expand: async (set) => expandSet(set) });
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
const denylist = createDenylist(); // granting/revoking a *user's* role revokes their live tokens (a group change is transitive → left to lag)
const denylist = createDenylist(); // granting/revoking a *user's* permission revokes their live tokens (a group change is transitive → left to lag)
const { get, post, token, url } = await adminHarness(t, { denylist, keto, kratosAdmin });
await assertAdminGate(url, get, "/admin/roles");
await assertAdminGate(url, get, "/admin/permissions");
// List: the existing role shows + the "add" link.
const listHtml = await (await get("/admin/roles")).text();
assert.match(listHtml, /href="\/admin\/roles\/editor"/);
assert.match(listHtml, /href="\/admin\/roles\/new"/);
// 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\/new"/);
// Create: a valid post writes the first-member tuple and redirects to the detail.
assert.match(await (await get("/admin/roles/new")).text(), /Create role/);
const created = await post("/admin/roles", `_csrf=${token}&name=viewer&member=identity:${ada}`);
assert.match(await (await get("/admin/permissions/new")).text(), /Create permission/);
const created = await post("/admin/permissions", `_csrf=${token}&name=viewer&member=identity:${ada}`);
assert.equal(created.status, 303);
assert.equal(created.headers.get("location"), "/admin/roles/viewer");
assert.ok(tuples.some((tp) => tp.namespace === "Role" && tp.object === "viewer" && tp.subject_id === `identity:${ada}`));
assert.equal(denylist.isRevoked(ada, 0), true); // assigning a role to a user revokes their stale token so the grant lands now
assert.equal(created.headers.get("location"), "/admin/permissions/viewer");
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "viewer" && tp.subject_id === `identity:${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/roles", `_csrf=${token}&name=Bad Name&member=identity:${ada}`)).status, 400);
assert.equal((await post("/admin/roles", `_csrf=${token}&name=editor&member=identity:${ada}`)).status, 400); // already exists
assert.equal((await post("/admin/roles", `name=x&member=identity:${ada}`)).status, 403);
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=Bad Name&member=identity:${ada}`)).status, 400);
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=editor&member=identity:${ada}`)).status, 400); // already exists
assert.equal((await post("/admin/permissions", `name=x&member=identity:${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/roles/editor")).text();
const detail = await (await get("/admin/permissions/editor")).text();
assert.match(detail, effectiveLi("ada@example.com"));
assert.doesNotMatch(detail, effectiveLi("grace@example.com"));
// Assign the `eng` group to the role → grace now holds it transitively (effective access via expand).
await post("/admin/roles/editor/members", `_csrf=${token}&member=group:eng`);
assert.ok(tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor" && tp.subject_set?.object === "eng"));
const withGroup = await (await get("/admin/roles/editor")).text();
// 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();
assert.match(withGroup, effectiveLi("grace@example.com"));
// Revoke the group membership.
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=group:eng`);
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor" && tp.subject_set?.object === "eng"));
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"));
// Unassigning a *user* membership likewise revokes that user's live token, so the loss of access is immediate.
await post("/admin/roles/editor/members", `_csrf=${token}&member=identity:${grace}`);
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=identity:${grace}`);
await post("/admin/permissions/editor/members", `_csrf=${token}&member=identity:${grace}`);
await post("/admin/permissions/editor/members/delete", `_csrf=${token}&member=identity:${grace}`);
assert.equal(denylist.isRevoked(grace, 0), true);
// Delete the role: a confirm step (GET) then the POST removes every member tuple, back to the list.
assert.match(await (await get("/admin/roles/editor/delete")).text(), /Cancel/);
const del = await post("/admin/roles/editor/delete", `_csrf=${token}`);
// 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.equal(del.status, 303);
assert.equal(del.headers.get("location"), "/admin/roles");
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor"));
assert.equal(del.headers.get("location"), "/admin/permissions");
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor"));
// Self-protection: the admin role can't be deleted, nor can you revoke your own admin (sub admin1).
tuples.push({ namespace: "Role", object: "admin", relation: "members", subject_id: "identity:admin1" });
assert.equal((await post("/admin/roles/admin/delete", `_csrf=${token}`)).status, 400);
// 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: "identity: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/roles/admin/members/delete", `_csrf=${token}&member=identity:admin1`)).status, 400);
assert.equal((await post("/admin/permissions/admin/members/delete", `_csrf=${token}&member=identity:admin1`)).status, 400);
assert.ok(tuples.some((tp) => tp.object === "admin" && tp.subject_id === "identity:admin1"));
// An invalid role name in the path → 404; malformed %-encoding doesn't 500.
assert.equal((await get("/admin/roles/Bad%20Name")).status, 404);
assert.equal((await get("/admin/roles/%ZZ")).status, 404);
// An invalid permission name in the path → 404; malformed %-encoding doesn't 500.
assert.equal((await get("/admin/permissions/Bad%20Name")).status, 404);
assert.equal((await get("/admin/permissions/%ZZ")).status, 404);
});
// Built-in OAuth2 clients admin screen: gate + list/register/detail/delete over HTTP against an
+7 -7
View File
@@ -40,7 +40,7 @@ export interface AppOptions {
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
denylist?: Denylist; // optional instant-revoke; the hot path rejects revoked subjects, admin writes record revokes
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge
jwks?: JwksProvider; // verify the session JWT → ctx.identity/roles; absent ⇒ always anonymous
jwks?: JwksProvider; // verify the session JWT → ctx.identity/permissions; absent ⇒ always anonymous
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes
kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion
@@ -186,9 +186,9 @@ export function createApp(options: AppOptions = {}): Server {
}
}
// Verify the session JWT once (cached JWKS) → ctx.identity/roles; none/invalid ⇒ anonymous.
// Verify the session JWT once (cached JWKS) → ctx.identity/permissions; none/invalid ⇒ anonymous.
// If the token has lapsed but a live Kratos session still backs it (and we have the Ory
// clients), silently re-mint it — "stay signed in": re-read roles from Keto, re-tokenize,
// clients), silently re-mint it — "stay signed in": re-read permissions from Keto, re-tokenize,
// and set the fresh cookie via setHeader so it rides whatever response this request produces
// (a dead session clears the stale cookie). This is the only place the hot path touches Ory.
let user: SessionIdentity | null = null;
@@ -240,17 +240,17 @@ export function createApp(options: AppOptions = {}): Server {
}
}
// Plugin routes (any method): gate on the route's role, then run the handler. The
// Plugin routes (any method): gate on the route's permission, then run the handler. The
// handler gets ctx.chrome (native app shell) + ctx.verifyCsrf (guard its own forms); a fresh
// CSRF cookie is set so those forms have a valid double-submit token.
const match = matchRoute(plugins, method, pathname);
if (match) {
const routeCtx = buildContext(req, res, { chrome, identity: user, log: reqLog, params: match.params, verifyCsrf, ...(system ? { system } : {}) });
if (!isAuthorized(match.route, routeCtx.roles)) {
if (!isAuthorized(match.route, routeCtx.permissions)) {
// Anonymous → sign in (like the built-in screens' requireSession), remembering the page as
// return_to; a signed-in user who simply lacks the role gets the 403 page.
// return_to; a signed-in user who simply lacks the permission gets the 403 page.
if (!routeCtx.identity) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
reqLog.warn("forbidden: missing role", { path: pathname, required: match.route.role ?? "", sub: routeCtx.identity.id });
reqLog.warn("forbidden: missing permission", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.identity.id });
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
return;
}
+4 -4
View File
@@ -23,7 +23,7 @@ test("buildContext parses the URL, exposes query, and defaults to an anonymous u
assert.equal(ctx.query.get("q"), "ann");
assert.equal(ctx.query.get("page"), "2");
assert.equal(ctx.identity, null);
assert.deepEqual(ctx.roles, []);
assert.deepEqual(ctx.permissions, []);
assert.deepEqual(ctx.params, {});
});
@@ -33,12 +33,12 @@ test("buildContext threads path params supplied by the router", () => {
assert.equal(ctx.params.id, "42");
});
test("buildContext threads the user and derives roles from it", () => {
test("buildContext threads the user and derives permissions from it", () => {
const { req, res } = reqRes("/");
const user: SessionIdentity = { email: "a@b.c", id: "u1", roles: ["admin", "editor"] };
const user: SessionIdentity = { email: "a@b.c", id: "u1", permissions: ["admin", "editor"] };
const ctx = buildContext(req, res, { identity: user });
assert.equal(ctx.identity, user);
assert.equal(ctx.roles, user.roles); // same reference, never a divergent copy — buildContext is the only writer
assert.equal(ctx.permissions, user.permissions); // same reference, never a divergent copy — buildContext is the only writer
});
test("buildContext defaults a missing request URL to /", () => {
+4 -4
View File
@@ -8,11 +8,11 @@ import { createLogger, type Log } from "../logger.ts";
// middleware supplies `identity` (null until then). The host's single handler argument.
// The authenticated Kratos identity, projected from verified session JWT claims:
// `id` = `sub`, plus `email` and the coarse `roles` carried in the token.
// `id` = `sub`, plus `email` and the coarse `permissions` carried in the token.
export interface SessionIdentity {
email: string;
id: string;
roles: string[];
permissions: string[];
}
export interface RequestContext {
@@ -29,7 +29,7 @@ export interface RequestContext {
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
req: IncomingMessage;
res: ServerResponse;
roles: string[]; // identity?.roles ?? [] — coarse gate without a null-check
permissions: string[]; // identity?.permissions ?? [] — coarse gate without a null-check
// Privileged host services (Ory admin clients + instant-revoke) for a system plugin. Undefined
// unless the host wired them; every field optional. Ordinary domain plugins ignore it.
system?: SystemCapabilities;
@@ -74,7 +74,7 @@ export function buildContext(
query: url.searchParams,
req,
res,
roles: identity?.roles ?? [],
permissions: identity?.permissions ?? [],
...(options.system ? { system: options.system } : {}),
url,
verifyCsrf: options.verifyCsrf ?? (() => false), // fail-closed unless the host binds the secret
+6 -6
View File
@@ -1,6 +1,6 @@
// Guards the Ory Keto config: migrations run before the server (keto-migrate →
// keto), the DSN targets the keto database, read/write APIs serve on the ports config.ts
// points at, and the OPL declares the identity/role/group/resource namespaces. Version pinning is
// points at, and the OPL declares the identity/permission/group/resource namespaces. Version pinning is
// in compose.test.ts. Real boot is verified by running the stack; this catches edits.
import { test } from "node:test";
import assert from "node:assert/strict";
@@ -35,12 +35,12 @@ test("keto loads the OPL namespaces from the mounted file", () => {
"namespaces come from the committed OPL");
});
test("the OPL declares role, group and a resource namespace over identity subjects", () => {
for (const ns of ["Identity", "Group", "Role", "Resource"])
test("the OPL declares permission, group and a resource namespace over identity subjects", () => {
for (const ns of ["Identity", "Group", "Permission", "Resource"])
assert.match(opl, new RegExp(`class ${ns} implements Namespace`), `defines ${ns}`);
// role + group are subject sets read at login → JWT roles claim (README).
assert.match(opl, /class Role implements Namespace\s*{\s*related:\s*{\s*members:/,
"Role has a members relation");
// permission + group are subject sets read at login → JWT permissions claim (README).
assert.match(opl, /class Permission implements Namespace\s*{\s*related:\s*{\s*granted:/,
"Permission has a granted relation");
assert.match(opl, /class Group implements Namespace\s*{\s*related:\s*{\s*members:/,
"Group has a members relation");
});
+4 -4
View File
@@ -52,7 +52,7 @@ test("self-service flows return to our themed pages (on the localhost dev host)"
test("after a successful login Kratos returns to our /auth/complete route to mint the JWT", () => {
assert.match(kratosYml, /default_browser_return_url:\s*http:\/\/localhost:3000\/auth\/complete/,
"login completion (read roles → project → tokenize → set cookie) runs at /auth/complete");
"login completion (read permissions → project → tokenize → set cookie) runs at /auth/complete");
});
test("recovery + verification run on email code, delivered by a courier", () => {
@@ -79,12 +79,12 @@ test("session tokenizer template 'plainpages' mints a short-lived signed JWT", (
"claims via the committed mapper");
});
test("the tokenizer claims mapper emits email + roles from the metadata_public projection", () => {
test("the tokenizer claims mapper emits email + permissions from the metadata_public projection", () => {
// metadata_public, not _admin: the session Kratos hands the tokenizer carries only public
// metadata (admin metadata is stripped), so the roles projection must live in metadata_public.
// metadata (admin metadata is stripped), so the permissions projection must live in metadata_public.
const mapper = read("ory/kratos/tokenizer/plainpages.jsonnet");
assert.match(mapper, /email:\s*session\.identity\.traits\.email/, "email ← identity trait");
assert.match(mapper, /metadata_public/, "roles ← metadata_public (the per-login Keto projection)");
assert.match(mapper, /metadata_public/, "permissions ← metadata_public (the per-login Keto projection)");
});
test("social sign-in is off by default — a clean clone stays password-only", () => {
+5 -5
View File
@@ -50,8 +50,8 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
{ name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s },
{ name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s },
{ name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ },
{ name: "a route marked public AND role is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", public: true, role: "x", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*role/s },
{ name: "a nav node marked public AND role is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", public: true, role: "x" }] };` }, match: /contranav.*public.*role/s },
{ name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", public: true, permission: "x", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s },
{ name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", public: true, permission: "x" }] };` }, match: /contranav.*public.*permission/s },
{ name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "b" }) };` }, match: /home/ },
{ name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ },
];
@@ -85,12 +85,12 @@ test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard)
assert.equal(typeof plugins[0]?.dashboard, "function");
});
test("a shared role name only warns — both plugins still load", async (t) => {
const shared = `export default { apiVersion: "1.0.0", roles: [{ name: "shared:read" }] };`;
test("a shared permission name only warns — both plugins still load", async (t) => {
const shared = `export default { apiVersion: "1.0.0", permissions: [{ name: "shared:read" }] };`;
const dir = scaffold(t, { "x/plugin.ts": shared, "y/plugin.ts": shared });
const warnings: string[] = [];
const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } });
assert.equal(plugins.length, 2);
assert.ok(warnings.some((w) => /shared:read/.test(w)), "expected a role-conflict warning");
assert.ok(warnings.some((w) => /shared:read/.test(w)), "expected a permission-conflict warning");
});
+7 -7
View File
@@ -2,7 +2,7 @@
// validate it, assemble the loaded Plugin[]. The imperative shell over plugin.ts's pure rules
// (isValidPluginId, checkApiVersion, findConflicts). Fails loud: every per-plugin problem and
// error-level conflict is collected into one boot-stopping Error; warn-level diagnostics
// (older-minor apiVersion, shared role name) log and load continues. Folder name = id.
// (older-minor apiVersion, shared permission name) log and load continues. Folder name = id.
import { existsSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path";
@@ -85,7 +85,7 @@ function asManifest(value: unknown): PluginManifest | null {
// The collection fields feed findConflicts, which iterates them — a non-array crashes it opaquely.
function shapeError(manifest: PluginManifest): string | null {
for (const field of ["nav", "roles", "routes"] as const) {
for (const field of ["nav", "permissions", "routes"] as const) {
if (manifest[field] !== undefined && !Array.isArray(manifest[field])) return `"${field}" must be an array`;
}
// `home` / `dashboard` (the landing-page overrides) are route handlers; the host calls them, so
@@ -93,20 +93,20 @@ function shapeError(manifest: PluginManifest): string | null {
for (const slot of ["home", "dashboard"] as const) {
if (manifest[slot] !== undefined && typeof manifest[slot] !== "function") return `"${slot}" must be a function (a route handler)`;
}
// `public` and `role` are contradictory on the same route/nav node — "open to all" vs
// "needs this role". Refuse rather than silently pick one, so the author's intent is unambiguous.
// `public` and `permission` are contradictory on the same route/nav node — "open to all" vs
// "needs this permission". Refuse rather than silently pick one, so the author's intent is unambiguous.
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
if (route?.public === true && route.role != null) return `route "${route.method} ${route.path}" sets both public and role — they are mutually exclusive`;
if (route?.public === true && route.permission != null) return `route "${route.method} ${route.path}" sets both public and permission — they are mutually exclusive`;
}
const navContradiction = findPublicNavContradiction(manifest.nav);
if (navContradiction) return navContradiction;
return null;
}
// Recurse the nav fragment: a node that is both `public` and `role`-gated is contradictory.
// Recurse the nav fragment: a node that is both `public` and `permission`-gated is contradictory.
function findPublicNavContradiction(nodes: PluginManifest["nav"]): string | null {
for (const node of Array.isArray(nodes) ? nodes : []) {
if (node?.public === true && node.role != null) return `nav node "${node.label ?? node.id ?? "?"}" sets both public and role — they are mutually exclusive`;
if (node?.public === true && node.permission != null) return `nav node "${node.label ?? node.id ?? "?"}" sets both public and permission — they are mutually exclusive`;
const inChild = findPublicNavContradiction(node?.children);
if (inChild) return inChild;
}
+1 -1
View File
@@ -5,7 +5,7 @@
// a plugin should import from here, never reach into deeper modules. See README.md → Building plugins.
export { definePlugin } from "./plugin.ts";
export type { HttpMethod, Plugin, PluginHooks, PluginManifest, RoleDecl, Route, RouteHandler, RouteResult } from "./plugin.ts";
export type { HttpMethod, Plugin, PluginHooks, PluginManifest, PermissionDecl, Route, RouteHandler, RouteResult } from "./plugin.ts";
export type { RequestContext, SessionIdentity } from "../http/context.ts";
export type { PageChrome } from "../ui/chrome.ts";
export type { NavNode } from "../ui/nav.ts";
+10 -10
View File
@@ -21,13 +21,13 @@ const scheduling: PluginManifest = definePlugin({
apiVersion: "1.0.0",
hooks: { onBoot: () => {} },
nav: [{
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", role: "scheduling:read" }],
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
icon: "i-cal", id: "scheduling:root", label: "Scheduling",
}],
roles: [{ description: "View shifts", name: "scheduling:read" }],
permissions: [{ description: "View shifts", name: "scheduling:read" }],
routes: [
{ handler: () => ({ data: { rows: [] }, view: "shifts" }), method: "GET", path: "/shifts", role: "scheduling:read" },
{ handler: () => ({ redirect: "/scheduling/shifts" }), method: "POST", path: "/shifts", role: "scheduling:write" },
{ handler: () => ({ data: { rows: [] }, view: "shifts" }), method: "GET", path: "/shifts", permission: "scheduling:read" },
{ handler: () => ({ redirect: "/scheduling/shifts" }), method: "POST", path: "/shifts", permission: "scheduling:write" },
{ handler: (ctx) => void ctx.res.end("raw"), method: "GET", path: "/raw" }, // void = handler wrote res itself
],
});
@@ -87,19 +87,19 @@ test("findConflicts: a duplicate id and a colliding route are loud errors", () =
assert.ok(dupRoute.some((c) => c.kind === "route" && c.level === "error" && c.message.includes("/a/t")));
});
test("findConflicts: duplicate nav id is an error, a shared role name only warns", () => {
test("findConflicts: duplicate nav id is an error, a shared permission name only warns", () => {
const navDup = findConflicts([
p({ id: "a", nav: [{ id: "dup", label: "A" }] }),
p({ id: "b", nav: [{ id: "dup", label: "B" }] }),
]);
assert.ok(navDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
// Sharing a role across plugins is legitimate → warn, not error.
const roleDup = findConflicts([
p({ id: "a", roles: [{ name: "shared:read" }] }),
p({ id: "b", roles: [{ name: "shared:read" }] }),
// Sharing a permission across plugins is legitimate → warn, not error.
const permissionDup = findConflicts([
p({ id: "a", permissions: [{ name: "shared:read" }] }),
p({ id: "b", permissions: [{ name: "shared:read" }] }),
]);
assert.ok(roleDup.some((c) => c.kind === "role" && c.level === "warn"));
assert.ok(permissionDup.some((c) => c.kind === "permission" && c.level === "warn"));
});
test("findConflicts: each single slot (`home`/`dashboard`) may have one owner — two is a loud error", () => {
+10 -10
View File
@@ -29,16 +29,16 @@ export interface Route {
handler: RouteHandler;
method: HttpMethod;
path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name
role?: string; // coarse gate — the Keto Role the caller must hold; checked before the handler runs
// Mark the page reachable by anyone, signed in or not. The same as omitting `role`
permission?: string; // coarse gate — the Keto Permission the caller must hold; checked before the handler runs
// Mark the page reachable by anyone, signed in or not. The same as omitting `permission`
// — an ungated route is already open — but stated outright, so "public" is a deliberate
// choice, not an accident. Mutually exclusive with `role` (discovery refuses both).
// choice, not an accident. Mutually exclusive with `permission` (discovery refuses both).
public?: boolean;
}
// A Keto Role this plugin gates on — declared for docs/seeding. Role names are a shared
// 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>`.
export interface RoleDecl {
export interface PermissionDecl {
description?: string;
name: string;
}
@@ -63,7 +63,7 @@ export interface PluginManifest {
home?: RouteHandler;
hooks?: PluginHooks;
nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique
roles?: RoleDecl[];
permissions?: PermissionDecl[];
routes?: Route[];
}
@@ -147,7 +147,7 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO
}
export interface PluginConflict {
kind: "dashboard" | "home" | "id" | "nav-id" | "role" | "route";
kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route";
level: "error" | "warn";
message: string;
plugins: string[]; // unique ids involved
@@ -155,7 +155,7 @@ export interface PluginConflict {
// The conflict rules: defined, loud resolution — never last-write-wins. Pure over the discovered
// plugins; discovery throws on any "error" and logs every "warn". Mount-path (`/<id>`) uniqueness
// is structural — it follows from the id check, so it needs no rule of its own. Shared role
// is structural — it follows from the id check, so it needs no rule of its own. Shared permission
// names are the one intentional overlap, so they warn rather than error.
export function findConflicts(plugins: Plugin[]): PluginConflict[] {
const out: PluginConflict[] = [];
@@ -184,9 +184,9 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] {
});
collect(plugins, (plugin, push) => {
for (const decl of plugin.roles ?? []) push(decl.name);
for (const decl of plugin.permissions ?? []) push(decl.name);
}).forEach((owners, name) => {
if (owners.length > 1) out.push({ kind: "role", level: "warn", message: `role "${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; namespace as "<id>:<action>" unless shared on purpose`, plugins: uniq(owners) });
});
return out;
+3 -3
View File
@@ -55,13 +55,13 @@ test("allowedMethods lists methods at a path (GET implies HEAD); empty when the
assert.deepEqual(allowedMethods(plugins, "/x/missing"), []);
});
test("isAuthorized: open routes pass; gated routes require the role token; public is explicitly open", () => {
test("isAuthorized: open routes pass; gated routes require the permission token; public is explicitly open", () => {
const open: Route = { handler: noop, method: "GET", path: "/" };
const gated: Route = { handler: noop, method: "GET", path: "/", role: "x:read" };
const gated: Route = { handler: noop, method: "GET", path: "/", permission: "x:read" };
const pub: Route = { handler: noop, method: "GET", path: "/", public: true }; // blessed public alias
assert.equal(isAuthorized(open, []), true);
assert.equal(isAuthorized(gated, []), false);
assert.equal(isAuthorized(gated, ["x:read"]), true);
assert.equal(isAuthorized(gated, ["other"]), false);
assert.equal(isAuthorized(pub, []), true); // open to anonymous, like omitting role — but stated outright
assert.equal(isAuthorized(pub, []), true); // open to anonymous, like omitting permission — but stated outright
});
+5 -5
View File
@@ -74,9 +74,9 @@ export function allowedMethods(plugins: Plugin[], pathname: string): string[] {
return [...methods].sort();
}
// Coarse role gate: a route marked `public` (or one with no `role`) is open; otherwise
// the user's roles (from the session JWT) must include the token. The same rule composeNav uses
// for the menu. `public` and `role` are mutually exclusive (discovery refuses both).
export function isAuthorized(route: Route, roles: string[]): boolean {
return route.public === true || route.role == null || roles.includes(route.role);
// Coarse permission gate: a route marked `public` (or one with no `permission`) is open; otherwise
// the user's permissions (from the session JWT) must include the token. The same rule composeNav uses
// for the menu. `public` and `permission` are mutually exclusive (discovery refuses both).
export function isAuthorized(route: Route, permissions: string[]): boolean {
return route.public === true || route.permission == null || permissions.includes(route.permission);
}
+1 -1
View File
@@ -31,7 +31,7 @@ const hydra = createHydraAdmin({ baseUrl: config.hydraAdminUrl, fetchImpl: oryFe
// or fetched http), then served from cache with TTL refresh + rotation-on-miss.
const jwks = await createJwksProvider(config.jwksUrl, { fetchImpl: oryFetch }); // bound an http JWKS fetch too
// Optional instant-revoke, off unless REVOCATION_DENYLIST=true: an in-memory denylist the
// hot path consults and the admin screens populate on deactivate/delete/role-change.
// hot path consults and the admin screens populate on deactivate/delete/permission-change.
const denylist = config.revocationDenylist ? createDenylist({ ttlSec: config.revocationTtlSec }) : undefined;
const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin
+6 -6
View File
@@ -9,13 +9,13 @@ const scheduling: Plugin = {
apiVersion: "1.0.0",
id: "scheduling",
nav: [{
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", role: "scheduling:read" }],
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
icon: "i-cal", id: "scheduling", label: "Scheduling",
}],
};
// A plugin with a public nav node (reachable by anyone, signed in or not).
const portal: Plugin = { apiVersion: "1.0.0", id: "portal", nav: [{ href: "/portal", id: "portal", label: "Portal", public: true }] };
// A gated section fragment like the admin plugin's nav: the header carries the role, so
// A gated section fragment like the admin plugin's nav: the header carries the permission, so
// composeNav drops the whole subtree for a non-holder (the admin screens ship as a drop-in plugin).
const adminLike: Plugin = {
apiVersion: "1.0.0", id: "admin",
@@ -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", role: "admin",
icon: "i-shield", id: "admin", label: "Admin", permission: "admin",
}],
};
@@ -45,10 +45,10 @@ test("anonymous shell Sign-in link carries the current page as return_to", () =>
assert.equal(buildPluginChrome({ currentPath: "/portal", menu: DEFAULT_MENU }).signInHref, "/login?return_to=%2Fportal");
});
test("a role holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => {
test("a permission holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => {
const chrome = buildPluginChrome({
currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling],
identity: { email: "ada@x.io", id: "u1", roles: ["scheduling:read"] },
identity: { email: "ada@x.io", id: "u1", permissions: ["scheduling:read"] },
});
assert.deepEqual(labels(chrome.nav), ["Dashboard", "Scheduling"]); // Dashboard shown to a signed-in user
const section = chrome.nav.find((n) => n.label === "Scheduling")!;
@@ -58,7 +58,7 @@ test("a role holder sees the Dashboard link + plugin nav; current path opens the
});
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], identity: { email: "a@b.c", id: "u1", roles: ["admin"] } });
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], identity: { email: "a@b.c", id: "u1", permissions: ["admin"] } });
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
+3 -3
View File
@@ -18,7 +18,7 @@ const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashbo
export interface PageChrome {
brand: { logo?: string; name: string; sub?: string };
csrfToken: string; // double-submit token for the shell's Sign-out form + a plugin's own forms
nav: NavNode[]; // global menu, composed + role-filtered + current-marked, ready for nav-tree.ejs
nav: NavNode[]; // global menu, composed + permission-filtered + current-marked, ready for nav-tree.ejs
signInHref: string; // where the shell's anonymous "Sign in" link points — carries this page as return_to
theme?: string;
user: ShellUser;
@@ -39,8 +39,8 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
const fragments: NavNode[][] = opts.identity ? [[DASHBOARD_NAV]] : [];
for (const p of opts.plugins ?? []) if (p.nav?.length) fragments.push(p.nav);
const roles = opts.identity?.roles ?? [];
const nav = composeNav(fragments, opts.menu.override, roles);
const permissions = opts.identity?.permissions ?? [];
const nav = composeNav(fragments, opts.menu.override, permissions);
if (opts.currentPath) {
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
// /admin/users/new marks the Users base leaf (/admin/users) and the dashboard marks Dashboard.
+1 -1
View File
@@ -8,7 +8,7 @@ import type { NavNode } from "./nav.ts";
const NAV: NavNode[] = [{ href: "/dashboard", label: "Dashboard" }, { children: [{ href: "/admin/users", label: "Users" }], label: "Admin" }];
test("dashboard model: titled shell, passes the unified nav + csrf + user through", () => {
const m = buildDashboardModel({ csrfToken: "tok.sig", identity: { email: "ada@x.io", id: "u1", roles: ["admin"] }, nav: NAV });
const m = buildDashboardModel({ csrfToken: "tok.sig", identity: { email: "ada@x.io", id: "u1", permissions: ["admin"] }, nav: NAV });
assert.equal(m.shell.title, "Dashboard");
assert.equal(m.shell.csrfToken, "tok.sig");
assert.equal(m.shell.user.name, "ada"); // real signed-in identity, not a demo profile
+1 -1
View File
@@ -1,7 +1,7 @@
// Central menu config: config/menu.ts lets an operator set branding (app name, logo,
// default theme) and reorder/rename/group/hide nav nodes across all plugins. The reorder/rename/
// group/hide part is the NavOverride composeNav already applies (the override always wins, before
// the per-user role filter). Authored as TypeScript (defineMenu types it); loaded once at
// the per-user permission filter). Authored as TypeScript (defineMenu types it); loaded once at
// boot — fail-loud on a malformed file, defaults when absent (clean clone needs no config).
import { existsSync } from "node:fs";
+3 -3
View File
@@ -18,8 +18,8 @@ test("menu renders trigger, positioning, the item matrix and check groups", asyn
{ label: "Docs", href: "/docs" }, // link
{ sep: true },
{ label: "Sign out", icon: "i-logout", danger: true },
{ group: { legend: "Role", name: "role", control: "radio", options: [
{ value: "", label: "Any role", checked: true },
{ group: { legend: "Permission", name: "permission", control: "radio", options: [
{ value: "", label: "Any permission", checked: true },
{ value: "admin", label: "Admin" },
] } },
{ group: { name: "col", options: [{ value: "name", label: "Name", checked: true }] } }, // checkbox default, no legend
@@ -38,7 +38,7 @@ test("menu renders trigger, positioning, the item matrix and check groups", asyn
assert.match(html, /<button class="menu-item danger" type="button"><svg class="ico"><use href="#i-logout"\s*\/?><\/svg>Sign out<\/button>/);
// Check group: radios reflect `checked`; legend optional; control defaults to checkbox.
assert.match(html, /<fieldset class="menu-field"><legend class="menu-head">Role<\/legend><label class="menu-check"><input type="radio" name="role" value="" checked>Any role<\/label><label class="menu-check"><input type="radio" name="role" value="admin">Admin<\/label><\/fieldset>/);
assert.match(html, /<fieldset class="menu-field"><legend class="menu-head">Permission<\/legend><label class="menu-check"><input type="radio" name="permission" value="" checked>Any permission<\/label><label class="menu-check"><input type="radio" name="permission" value="admin">Admin<\/label><\/fieldset>/);
assert.match(html, /<fieldset class="menu-field"><label class="menu-check"><input type="checkbox" name="col" value="name" checked>Name<\/label><\/fieldset>/);
});
+3 -3
View File
@@ -25,7 +25,7 @@ const nodes = [
{ label: "Webhooks (soon)" }, // leaf · static
],
},
{ label: "Roles & Access", children: [{ label: "Roles", href: "/roles" }] }, // header · static · closed
{ label: "Permissions & Access", children: [{ label: "Permissions", href: "/permissions" }] }, // header · static · closed
],
},
];
@@ -59,8 +59,8 @@ test("nav-tree renders the header/leaf × clickable/static matrix with counts, i
assert.match(html, /<span class="nav-self"><span class="nav-label">Webhooks \(soon\)<\/span><\/span>/);
// Header · static · closed (no [open]) + label escaping in both label and aria-label.
assert.match(html, /<details class="nav-disc"><summary class="nav-tog" aria-label="Toggle Roles &amp; Access">/);
assert.match(html, /<span class="nav-label">Roles &amp; Access<\/span>/);
assert.match(html, /<details class="nav-disc"><summary class="nav-tog" aria-label="Toggle Permissions &amp; Access">/);
assert.match(html, /<span class="nav-label">Permissions &amp; Access<\/span>/);
});
test("nav-tree renders an empty root list with no nodes and never throws", async () => {
+16 -16
View File
@@ -2,32 +2,32 @@ import assert from "node:assert/strict";
import { test } from "node:test";
import { composeNav, type NavNode } from "./nav.ts";
// Two plugin fragments; ids let the override target nodes, `role` gates per role.
// Two plugin fragments; ids let the override target nodes, `permission` gates per permission.
const fragments: NavNode[][] = [
[{
icon: "i-cal", id: "sched", label: "Scheduling",
children: [
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", role: "scheduling:read" },
{ href: "/scheduling/manage", id: "manage", label: "Manage", role: "scheduling:admin" },
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" },
{ href: "/scheduling/manage", id: "manage", label: "Manage", permission: "scheduling:admin" },
],
}],
[{ href: "/reports", id: "reports", label: "Reports", role: "reports:read" }],
[{ href: "/reports", id: "reports", label: "Reports", permission: "reports:read" }],
];
test("composeNav merges fragments, filters by role, and emits clean render nodes", () => {
test("composeNav merges fragments, filters by permission, and emits clean render nodes", () => {
const tree = composeNav(fragments, {}, ["scheduling:read"]);
// Reports gone (no reports:read), Manage gone (no scheduling:admin), header kept with Shifts.
// Output carries no `id`/`role` and omits absent fields — ready for nav-tree.ejs.
// Output carries no `id`/`permission` and omits absent fields — ready for nav-tree.ejs.
assert.deepEqual(tree, [
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling/shifts", label: "Shifts" }] },
]);
});
test("composeNav drops gated subtrees, empty headers, and (with no roles) all gated nodes", () => {
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", role: "admin", children: [{ href: "/u", id: "u", label: "Users" }] },
{ id: "admin", label: "Admin", permission: "admin", children: [{ href: "/u", id: "u", label: "Users" }] },
{ id: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] },
]];
assert.deepEqual(composeNav(gatedHeader, {}, []), [
@@ -36,26 +36,26 @@ test("composeNav drops gated subtrees, empty headers, and (with no roles) all ga
// 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", role: "x" }] },
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", role: "y" }] },
{ 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" }] },
]];
assert.deepEqual(composeNav(emptyHeader, {}, []), [{ href: "/hub", label: "Hub" }]);
// No fragments / no roles → empty tree, never throws.
// No fragments / no permissions → empty tree, never throws.
assert.deepEqual(composeNav(), []);
});
test("composeNav keeps a node marked public for everyone — the blessed public alias", () => {
// A header with one public child + one gated child: with no roles, the public child keeps the
// A header with one public child + one gated child: with no permissions, the public child keeps the
// header alive (the gated child is filtered out) — so a plugin can show a public menu option to all.
const frag: NavNode[][] = [[{
icon: "i-cal", id: "sched", label: "Scheduling",
children: [
{ href: "/scheduling", id: "overview", label: "Overview", public: true },
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", role: "scheduling:read" },
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" },
],
}]];
// `public` is filter-only (like id/role) — never rendered into the output node.
// `public` is filter-only (like id/permission) — never rendered into the output node.
assert.deepEqual(composeNav(frag, {}, []), [
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }] },
]);
@@ -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", role: "root" },
{ href: "/secret", id: "secret", label: "Secret", permission: "root" },
]];
const tree = composeNav(base, {
@@ -76,7 +76,7 @@ test("composeNav applies the override: rename, group, order, hide (then filters)
hide: ["c"], // remove c from inside the group
}, ["root"]);
// grp emitted (b only, c hidden), reordered before a; Secret kept now that role "root" is present.
// grp emitted (b only, c hidden), reordered before a; Secret kept now that permission "root" is present.
assert.deepEqual(tree, [
{ icon: "i-box", label: "Group", open: true, children: [{ href: "/b", label: "Beta" }] },
{ href: "/a", label: "First" },
+13 -13
View File
@@ -1,10 +1,10 @@
// composeNav: merge each plugin's nav fragment into one tree, apply the central
// override, then role-filter per user. Pure and I/O-free — menu gating reads the JWT
// `roles` claim (README "The menu system"), never Keto. A node is visible iff it is `public`, or
// declares no `role`, or `roles` includes that role name; a gated header hides its whole
// override, then permission-filter per user. Pure and I/O-free — menu gating reads the JWT
// `permissions` claim (README "The menu system"), never Keto. A node is visible iff it is `public`, or
// declares no `permission`, or `permissions` includes that permission name; a gated header hides its whole
// subtree, and a pure header left with no children is dropped. The config/menu.ts supplies
// the override (+ branding); this helper only transforms data, so its result is per-deployment
// up to the final role filter and emits clean nodes ready for nav-tree.ejs (no id/role).
// up to the final permission filter and emits clean nodes ready for nav-tree.ejs (no id/permission).
export interface NavNode {
id?: string; // stable key for override targeting; stripped from the rendered tree
@@ -15,12 +15,12 @@ export interface NavNode {
icon?: string;
label: string;
open?: boolean;
role?: string; // required role token; consumed by the filter, never rendered
public?: boolean; // show to everyone, signed in or not — the blessed alias for "no role", stated outright; consumed by the filter, never rendered. Mutually exclusive with role (discovery refuses both).
permission?: string; // required permission token; consumed by the filter, never rendered
public?: boolean; // show to everyone, signed in or not — the blessed alias for "no permission", stated outright; consumed by the filter, never rendered. Mutually exclusive with permission (discovery refuses both).
}
// Central override (config/menu.ts). Targets nodes by `id`; applied rename → group →
// order → hide, then the per-user role filter runs last.
// order → hide, then the per-user permission filter runs last.
export interface NavOverride {
groups?: NavGroupSpec[]; // wrap top-level nodes (by id) under a new header
hide?: string[]; // remove nodes by id, at any depth (incl. a group's id)
@@ -39,14 +39,14 @@ export interface NavGroupSpec {
export function composeNav(
fragments: NavNode[][] = [],
override: NavOverride = {},
roles: string[] = [],
permissions: string[] = [],
): NavNode[] {
let nodes: NavNode[] = fragments.flat();
if (override.rename) nodes = renameTree(nodes, override.rename);
if (override.groups?.length) nodes = applyGroups(nodes, override.groups);
if (override.order?.length) nodes = applyOrder(nodes, override.order);
if (override.hide?.length) nodes = hideTree(nodes, new Set(override.hide));
return filterByRoles(nodes, new Set(roles)).map(toRenderNode);
return filterByRoles(nodes, new Set(permissions)).map(toRenderNode);
}
function renameTree(nodes: NavNode[], rename: Record<string, string>): NavNode[] {
@@ -103,19 +103,19 @@ function hideTree(nodes: NavNode[], hide: Set<string>): NavNode[] {
return out;
}
function filterByRoles(nodes: NavNode[], roles: Set<string>): NavNode[] {
function filterByRoles(nodes: NavNode[], permissions: Set<string>): NavNode[] {
const out: NavNode[] = [];
for (const n of nodes) {
if (n.public !== true && n.role != null && !roles.has(n.role)) continue; // gated → drop node + subtree (public always shows)
if (n.public !== true && n.permission != null && !permissions.has(n.permission)) continue; // gated → drop node + subtree (public always shows)
if (!n.children) { out.push(n); continue; }
const children = filterByRoles(n.children, roles);
const children = filterByRoles(n.children, permissions);
if (children.length === 0 && n.href == null) continue; // empty pure header → drop
out.push({ ...n, children });
}
return out;
}
// Strip the helper-only fields (id/role) and drop absent ones, so the tree is exactly
// Strip the helper-only fields (id/permission) and drop absent ones, so the tree is exactly
// what nav-tree.ejs reads.
function toRenderNode(n: NavNode): NavNode {
const out: NavNode = { label: n.label };
+2 -2
View File
@@ -5,7 +5,7 @@ import { buildShellContext, shellUser } from "./shell-context.ts";
test("shellUser derives the profile from the real user; anonymous → Guest", () => {
assert.deepEqual(shellUser(null), { email: "", initials: "G", name: "Guest" });
// Real user: name = email local part, email kept, initials = first two letters of the local part.
assert.deepEqual(shellUser({ email: "ada@example.com", id: "u1", roles: [] }), { email: "ada@example.com", initials: "AD", name: "ada" });
assert.deepEqual(shellUser({ email: "ada@example.com", id: "u1", permissions: [] }), { email: "ada@example.com", initials: "AD", name: "ada" });
});
test("buildShellContext maps branding + breadcrumbs, omitting unset optional fields", () => {
@@ -22,7 +22,7 @@ test("buildShellContext maps branding + breadcrumbs, omitting unset optional fie
menu: { branding: { logo: "/l.svg", name: "Acme", sub: "Ops", theme: "dark" }, override: {} },
signInHref: "/login?return_to=%2Fx",
title: "Users",
identity: { email: "a@b.c", id: "u1", roles: ["admin"] },
identity: { email: "a@b.c", id: "u1", permissions: ["admin"] },
});
assert.deepEqual(full.brand, { logo: "/l.svg", name: "Acme", sub: "Ops" });
assert.equal(full.theme, "dark");
+1 -1
View File
@@ -1,7 +1,7 @@
// Shell view-model builder: the brand/theme/user/title block every app-shell page
// (the home dashboard, the built-in admin screens) hands to shell.ejs. Pure. Extracted so the
// shell user is the *real* signed-in identity — no hardcoded demo profile — and branding is
// read from one place. The User carries no display name (the JWT holds only id/email/roles), so
// read from one place. The User carries no display name (the JWT holds only id/email/permissions), so
// the profile shows the email's local part as the name with the full email beneath, initials from
// the local part; anonymous ⇒ "Guest".