§9 optional revocation denylist (todo §9); closes the documented ~10m role/session lag for security-critical revoke, off by default (REVOCATION_DENYLIST, zero hot-path cost + zero behaviour change when off). New pure src/denylist.ts (createDenylist({ttlSec})): an in-memory, auto-evicting Map<sub, revokedAt> — revoke(sub) records now, isRevoked(sub, iat) rejects a subject's tokens minted at/before the revoke (iat <= revokedAt; missing iat fails closed), so a fresh re-login (iat after the revoke) passes while a downgrade lands immediately. Entries self-evict after REVOCATION_TTL_SEC (default 900 ≥ the 10m tokenizer TTL + skew), so it stays a bounded cache like JWKS — no database, Keto stays off the hot path. Wired: jwt-middleware.ts takes the denylist in VerifyOptions and throws TokenError(expired) on a revoked sub, so resolveSession routes it through the existing §4 re-mint (live session → fresh post-revoke JWT with current Keto roles; dead/deactivated → cleared cookie). app.ts merges it into authOptions (the same resolveSession hot-path call) and hands a bound revoke to the Users + Roles admin deps; admin-users.ts revokes on deactivate/delete, admin-roles.ts revokes a direct user: member on assign/unassign (a group:/whole-role change is transitive → left to lag, documented). server.ts builds it only when the toggle is on. Tests-first: denylist.test.ts (iat semantics, cutoff-advance, TTL eviction), jwt-middleware.test.ts (revoked→expired→re-mint, fresh passes), config.test.ts (toggle + posint TTL), app.test.ts (hot-path reject + fresh-login pass; admin deactivate/role-assign/unassign record the revoke). Stability-reviewer on the diff: APPROVE, no Critical/High/Medium (addressed its one Low). Per the §9 security-headers precedent, covered by unit + app-HTTP integration (no new browser E2E — no new user-facing page). README (Auth trade-off + new "Instant revoke" subsection, config table, Layout) updated. typecheck + 317 units green.
This commit is contained in:
+18
-5
@@ -272,6 +272,14 @@ export interface AdminRolesDeps {
|
||||
kratosAdmin: KratosAdmin;
|
||||
menu: MenuConfig;
|
||||
render: (view: string, data: Record<string, unknown>) => Promise<string>;
|
||||
revoke?: (sub: string) => void; // optional instant-revoke (§9): assigning/unassigning a *user* kills their live tokens
|
||||
}
|
||||
|
||||
// §9 instant-revoke: a role change for a `user:<id>` member must take effect now, so revoke that
|
||||
// user's live tokens (a re-mint then re-reads roles from Keto). A `group:<name>` change is
|
||||
// transitive across many users — left to lag (documented), so only direct user members revoke.
|
||||
function revokeUserMember(deps: AdminRolesDeps, member: string): void {
|
||||
if (deps.revoke && member.startsWith("user:")) deps.revoke(member.slice("user:".length));
|
||||
}
|
||||
|
||||
// A role exists exactly while it has ≥1 member (Keto has no create-object).
|
||||
@@ -322,13 +330,15 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
|
||||
if (method === "GET") return renderList();
|
||||
if (method === "POST") {
|
||||
const name = (form!.get("name") ?? "").trim();
|
||||
const tuple = roleMemberTuple(name, (form!.get("member") ?? "").trim());
|
||||
const member = (form!.get("member") ?? "").trim();
|
||||
const tuple = roleMemberTuple(name, member);
|
||||
const reject = (error: string): Promise<RouteResult> =>
|
||||
renderForm({ error, values: { member: form!.get("member") ?? "", name } }).then((r) => ({ ...r, status: 400 }));
|
||||
renderForm({ error, values: { member, name } }).then((r) => ({ ...r, status: 400 }));
|
||||
if (!isValidRoleName(name)) return reject("Role names use lowercase letters, digits, dashes and underscores.");
|
||||
if (!tuple) return reject("Pick a user or group to assign the role to.");
|
||||
if (await roleExists(keto, name)) return reject("A role with that name already exists.");
|
||||
await keto.writeTuple(tuple);
|
||||
revokeUserMember(deps, member);
|
||||
return { redirect: detailHref(name) };
|
||||
}
|
||||
return null;
|
||||
@@ -345,8 +355,9 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
|
||||
if (seg.length === 1 && method === "GET") return renderDetail(name);
|
||||
|
||||
if (seg.length === 2 && seg[1] === "members" && method === "POST") {
|
||||
const tuple = roleMemberTuple(name, (form!.get("member") ?? "").trim());
|
||||
if (tuple) await keto.writeTuple(tuple); // the picker only offers real users/groups
|
||||
const member = (form!.get("member") ?? "").trim();
|
||||
const tuple = roleMemberTuple(name, member);
|
||||
if (tuple) { await keto.writeTuple(tuple); revokeUserMember(deps, member); } // the picker only offers real users/groups
|
||||
return { redirect: base };
|
||||
}
|
||||
if (seg.length === 2 && seg[1] === "delete" && method === "GET") {
|
||||
@@ -361,6 +372,8 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
|
||||
if (seg.length === 2 && seg[1] === "delete" && method === "POST") {
|
||||
if (name === ADMIN_PERMISSION) return renderDetail(name, "The admin role can't be deleted — it would remove all admin access.");
|
||||
await keto.deleteTuple({ namespace: ROLE_NS, object: name, relation: MEMBERS }); // removes every member tuple
|
||||
// §9: a whole-role delete drops many members at once — left to lag like a group change; the
|
||||
// per-member unassign above is the instant-revoke path.
|
||||
return { redirect: ADMIN_ROLES_BASE };
|
||||
}
|
||||
if (seg.length === 3 && seg[1] === "members" && seg[2] === "delete" && method === "POST") {
|
||||
@@ -369,7 +382,7 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
|
||||
// Admin held only via a group isn't covered here — the robust "last effective admin" check is §9.
|
||||
if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return renderDetail(name, "You can't revoke your own admin access.");
|
||||
const tuple = roleMemberTuple(name, member);
|
||||
if (tuple) await keto.deleteTuple(tuple);
|
||||
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(deps, member); }
|
||||
return { redirect: base };
|
||||
}
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user