Address whole-project architecture + product reviews (todo §5): make readRoles transitive so group→role grants reach the JWT (matches the Roles 'Effective access' view + OPL model; per-login only), per the user's call; add a zero-JS server-rendered confirm step for delete user/group/role (views/admin/confirm.ejs + shared buildConfirmModel; the Delete control is now a GET link, the delete stays a CSRF-guarded POST); self-lockout guards — no self-delete/deactivate (Users), no self-revoke of the direct admin grant + no delete of the admin role (Roles), each → 400 + inline error (direct-grant paths incl. the seeded admin; group-only-admin lockout = robust last-effective-admin check deferred §9); extract the gate+CSRF preamble copied across the 3 admin handlers into admin-nav.ts requireAdmin/guardedForm; shellUser keeps the email (name = local part, full email beneath). Reviewers: architecture no Critical/High, product 2 Critical + 1 High (all fixed). Deferred (scoped): host route-table→§6, list/template dedup→§5 cleanup, success-flash/empty-states/dangling-refs→§5 polish/§8, safeUrl→§7, 413/https/§N-drift→§9. Tests-first (extended the 3 admin HTTP tests + login/shell-context units); typecheck + 244 units + 8 visual + auth-refresh E2E green; stability-reviewer APPROVE

This commit is contained in:
2026-06-18 19:18:50 +02:00
parent 6920751cb8
commit c78e95889c
16 changed files with 213 additions and 99 deletions

View File

@@ -409,7 +409,7 @@ session cookie.
``` ```
── AT LOGIN / REFRESH (the only time Ory is on the path) ────────── ── AT LOGIN / REFRESH (the only time Ory is on the path) ──────────
Kratos verifies credentials Kratos verifies credentials
└─► app reads the user's roles from Keto (Keto = source of truth) └─► app reads the user's roles from Keto (direct + transitive via groups)
└─► app writes them as a derived projection on the identity (admin API) └─► app writes them as a derived projection on the identity (admin API)
└─► whoami(tokenize_as: "plainpages") ─► signed JWT └─► whoami(tokenize_as: "plainpages") ─► signed JWT
claims: { sub, email, roles:[…from Keto], exp ≈ 10m } claims: { sub, email, roles:[…from Keto], exp ≈ 10m }
@@ -432,7 +432,11 @@ and the user can already read these coarse roles in their own JWT, so nothing is
That projection is a per-login cache, authoritative nowhere; nothing edits it by hand, and That projection is a per-login cache, authoritative nowhere; nothing edits it by hand, and
a stale one self-heals on the next login. a stale one self-heals on the next login.
Cost: **one Keto read + one identity refresh per login** — never per request. JWKS A role can be granted to a user directly or to a **group** the user belongs to; login
resolves both (enumerate the defined roles, ask Keto to resolve each membership), so the
JWT `roles` match what the admin **Effective access** view shows.
Cost: **a handful of Keto reads + one identity refresh per login** — never per request. JWKS
is cached, so even signature verification hits the network only on key rotation. The is cached, so even signature verification hits the network only on key rotation. The
app stays stateless; "stay signed in" = re-mint the JWT on a short TTL, the one app stays stateless; "stay signed in" = re-mint the JWT on a short TTL, the one
moment authz is recomputed from Keto. moment authz is recomputed from Keto.

View File

@@ -7,12 +7,9 @@
// building-block view models; `handleAdminGroups` is the imperative shell app.ts dispatches to — it // building-block view models; `handleAdminGroups` is the imperative shell app.ts dispatches to — it
// gates (admin only), CSRF-guards every mutation, and maps each action to a RouteResult. // gates (admin only), CSRF-guards every mutation, and maps each action to a RouteResult.
import { ADMIN_GROUPS_BASE, ADMIN_PERMISSION, adminNav } from "./admin-nav.ts"; import { ADMIN_GROUPS_BASE, adminNav, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
import type { FieldConfig } from "./admin-users.ts"; import type { FieldConfig } from "./admin-users.ts";
import { readFormBody } from "./body.ts";
import type { RequestContext, User } from "./context.ts"; import type { RequestContext, User } from "./context.ts";
import { CSRF_FIELD, verifyCsrfRequest } from "./csrf.ts";
import { GuardError } from "./guards.ts";
import type { KetoClient, RelationQuery, RelationTuple, SubjectSet } from "./keto-client.ts"; import type { KetoClient, RelationQuery, RelationTuple, SubjectSet } from "./keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts"; import type { KratosAdmin } from "./kratos-admin.ts";
import { parseListQuery } from "./list-query.ts"; import { parseListQuery } from "./list-query.ts";
@@ -334,21 +331,11 @@ export async function handleAdminGroups(ctx: RequestContext, csrfToken: string,
const path = ctx.url.pathname; const path = ctx.url.pathname;
if (path !== ADMIN_GROUPS_BASE && !path.startsWith(`${ADMIN_GROUPS_BASE}/`)) return null; if (path !== ADMIN_GROUPS_BASE && !path.startsWith(`${ADMIN_GROUPS_BASE}/`)) return null;
if (!ctx.user) throw new GuardError(401, "authentication required", "/login"); const user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403)
if (!ctx.roles.includes(ADMIN_PERMISSION)) throw new GuardError(403, "admin role required");
const { keto, kratosAdmin, menu, render } = deps; const { keto, kratosAdmin, menu, render } = deps;
const user = ctx.user;
const method = (ctx.req.method ?? "GET").toUpperCase(); const method = (ctx.req.method ?? "GET").toUpperCase();
const seg = path.slice(ADMIN_GROUPS_BASE.length).split("/").filter(Boolean); const seg = path.slice(ADMIN_GROUPS_BASE.length).split("/").filter(Boolean);
const form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined
let form: URLSearchParams | undefined;
if (method === "POST") {
form = await readFormBody(ctx.req);
if (!verifyCsrfRequest({ cookieHeader: ctx.req.headers.cookie, secret: deps.csrfSecret, submitted: form.get(CSRF_FIELD) })) {
throw new GuardError(403, "invalid CSRF token");
}
}
const renderList = async (): Promise<RouteResult> => { const renderList = async (): Promise<RouteResult> => {
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS })); const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
@@ -397,6 +384,13 @@ export async function handleAdminGroups(ctx: RequestContext, csrfToken: string,
if (tuple && tuple.subject_set?.object !== name) await keto.writeTuple(tuple); if (tuple && tuple.subject_set?.object !== name) await keto.writeTuple(tuple);
return { redirect: base }; return { redirect: base };
} }
if (seg.length === 2 && seg[1] === "delete" && method === "GET") {
return { html: await render("admin/confirm", { model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { href: base, label: name }, { label: "Delete" }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete group", csrfToken,
current: "groups", menu, message: `Delete group ${name}? This removes the group and all its memberships.`, title: "Delete group", user,
}) }) };
}
if (seg.length === 2 && seg[1] === "delete" && method === "POST") { if (seg.length === 2 && seg[1] === "delete" && method === "POST") {
await keto.deleteTuple({ namespace: GROUP_NS, object: name, relation: MEMBERS }); // removes every member tuple await keto.deleteTuple({ namespace: GROUP_NS, object: name, relation: MEMBERS }); // removes every member tuple
return { redirect: ADMIN_GROUPS_BASE }; return { redirect: ADMIN_GROUPS_BASE };

View File

@@ -4,8 +4,13 @@
// for a non-admin), and `adminNav()` is the in-screen sidebar each admin screen renders (a link home // for a non-admin), and `adminNav()` is the in-screen sidebar each admin screen renders (a link home
// + the same section, with the active item marked `current`). // + the same section, with the active item marked `current`).
import { readFormBody } from "./body.ts";
import type { RequestContext, User } from "./context.ts";
import { CSRF_FIELD, verifyCsrfRequest } from "./csrf.ts";
import { GuardError } from "./guards.ts";
import { type MenuConfig } from "./menu-config.ts"; import { type MenuConfig } from "./menu-config.ts";
import { composeNav, type NavNode } from "./nav.ts"; import { composeNav, type NavNode } from "./nav.ts";
import { buildShellContext } from "./shell-context.ts";
export const ADMIN_PERMISSION = "admin"; // role token gating the admin section export const ADMIN_PERMISSION = "admin"; // role token gating the admin section
export const ADMIN_USERS_BASE = "/admin/users"; export const ADMIN_USERS_BASE = "/admin/users";
@@ -40,3 +45,45 @@ export function adminNav(roles: string[], menu: MenuConfig, current: AdminScreen
adminSection(current), adminSection(current),
]], menu.override, roles); ]], menu.override, roles);
} }
// The shared gate for every admin screen: a signed-in admin only. Throws GuardError that app.ts maps
// (anonymous → /login, non-admin → 403). Returns the (non-null) user for the handler to thread on.
export function requireAdmin(ctx: RequestContext): User {
if (!ctx.user) throw new GuardError(401, "authentication required", "/login");
if (!ctx.roles.includes(ADMIN_PERMISSION)) throw new GuardError(403, "admin role required");
return ctx.user;
}
// Read + CSRF-verify a mutation's form body once. Every admin write is a first-party POST form, so a
// POST without a valid double-submit token is refused (GuardError → 403); non-POST ⇒ undefined.
export async function guardedForm(ctx: RequestContext, csrfSecret: string): Promise<URLSearchParams | undefined> {
if ((ctx.req.method ?? "GET").toUpperCase() !== "POST") return undefined;
const form = await readFormBody(ctx.req);
if (!verifyCsrfRequest({ cookieHeader: ctx.req.headers.cookie, secret: csrfSecret, submitted: form.get(CSRF_FIELD) })) {
throw new GuardError(403, "invalid CSRF token");
}
return form;
}
// Build the model for the shared destructive-action confirm page (views/admin/confirm.ejs): a single
// danger action behind a deliberate second step, plus a cancel link. Reused by all three screens.
export function buildConfirmModel(opts: {
breadcrumbs: { href?: string; label: string }[];
cancelHref: string;
confirmAction: string;
confirmLabel: string;
csrfToken: string;
current: AdminScreen;
menu: MenuConfig;
message: string;
title: string;
user: User | null;
}) {
return {
cancelHref: opts.cancelHref,
confirm: { action: opts.confirmAction, label: opts.confirmLabel },
message: opts.message,
nav: adminNav(opts.user?.roles ?? [], opts.menu, opts.current),
shell: buildShellContext({ breadcrumbs: opts.breadcrumbs, csrfToken: opts.csrfToken, menu: opts.menu, title: opts.title, user: opts.user }),
};
}

View File

@@ -4,12 +4,12 @@
// the user|group membership model of the Groups screen, so the pure helpers (parseSubject, member // the user|group membership model of the Groups screen, so the pure helpers (parseSubject, member
// pickers, tuple paging) are reused from admin-groups. The one role-specific piece is the **effective // pickers, tuple paging) are reused from admin-groups. The one role-specific piece is the **effective
// access** view: `keto.expand(Role:<name>#members)` returns the membership tree, which we flatten to // access** view: `keto.expand(Role:<name>#members)` returns the membership tree, which we flatten to
// the distinct set of users who hold the role directly or transitively via a group. (The coarse JWT // the distinct set of users who hold the role directly or transitively via a group. Login resolves
// projection reads only direct grants per the README's one-read-per-login design; this view is where // the same transitive membership into the JWT `roles` (login.ts readRoles), so this view matches what
// group→role inheritance is surfaced.) Writes go only to Keto; Kratos is read only to label members. // a user's token actually grants. Writes go only to Keto; Kratos is read only to label members.
// `handleAdminRoles` is the imperative shell app.ts dispatches to — gated admin-only, CSRF-guarded. // `handleAdminRoles` is the imperative shell app.ts dispatches to — gated admin-only, CSRF-guarded.
import { ADMIN_PERMISSION, ADMIN_ROLES_BASE, adminNav } from "./admin-nav.ts"; import { ADMIN_PERMISSION, ADMIN_ROLES_BASE, adminNav, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
import { import {
type GroupView, type GroupView,
groupsFromTuples, groupsFromTuples,
@@ -23,10 +23,7 @@ import {
safeDecode, safeDecode,
} from "./admin-groups.ts"; } from "./admin-groups.ts";
import type { FieldConfig } from "./admin-users.ts"; import type { FieldConfig } from "./admin-users.ts";
import { readFormBody } from "./body.ts";
import type { RequestContext, User } from "./context.ts"; import type { RequestContext, User } from "./context.ts";
import { CSRF_FIELD, verifyCsrfRequest } from "./csrf.ts";
import { GuardError } from "./guards.ts";
import type { ExpandTree, KetoClient, RelationTuple } from "./keto-client.ts"; import type { ExpandTree, KetoClient, RelationTuple } from "./keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts"; import type { KratosAdmin } from "./kratos-admin.ts";
import { parseListQuery } from "./list-query.ts"; import { parseListQuery } from "./list-query.ts";
@@ -298,21 +295,11 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
const path = ctx.url.pathname; const path = ctx.url.pathname;
if (path !== ADMIN_ROLES_BASE && !path.startsWith(`${ADMIN_ROLES_BASE}/`)) return null; if (path !== ADMIN_ROLES_BASE && !path.startsWith(`${ADMIN_ROLES_BASE}/`)) return null;
if (!ctx.user) throw new GuardError(401, "authentication required", "/login"); const user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403)
if (!ctx.roles.includes(ADMIN_PERMISSION)) throw new GuardError(403, "admin role required");
const { keto, kratosAdmin, menu, render } = deps; const { keto, kratosAdmin, menu, render } = deps;
const user = ctx.user;
const method = (ctx.req.method ?? "GET").toUpperCase(); const method = (ctx.req.method ?? "GET").toUpperCase();
const seg = path.slice(ADMIN_ROLES_BASE.length).split("/").filter(Boolean); const seg = path.slice(ADMIN_ROLES_BASE.length).split("/").filter(Boolean);
const form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined
let form: URLSearchParams | undefined;
if (method === "POST") {
form = await readFormBody(ctx.req);
if (!verifyCsrfRequest({ cookieHeader: ctx.req.headers.cookie, secret: deps.csrfSecret, submitted: form.get(CSRF_FIELD) })) {
throw new GuardError(403, "invalid CSRF token");
}
}
const renderList = async (): Promise<RouteResult> => { const renderList = async (): Promise<RouteResult> => {
const roles = rolesFromTuples(await pagedTuples(keto, { namespace: ROLE_NS, relation: MEMBERS })); const roles = rolesFromTuples(await pagedTuples(keto, { namespace: ROLE_NS, relation: MEMBERS }));
@@ -322,12 +309,13 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
const { options } = await memberCandidates(keto, kratosAdmin); const { options } = await memberCandidates(keto, kratosAdmin);
return { html: await render("admin/role-form", { model: buildRoleFormModel({ csrfToken, memberOptions: options, menu, user, ...extra }) }) }; return { html: await render("admin/role-form", { model: buildRoleFormModel({ csrfToken, memberOptions: options, menu, user, ...extra }) }) };
}; };
const renderDetail = async (name: string): Promise<RouteResult> => { const renderDetail = async (name: string, error?: string): Promise<RouteResult> => {
const { emailById, options } = await memberCandidates(keto, kratosAdmin); const { emailById, options } = await memberCandidates(keto, kratosAdmin);
const tuples = await pagedTuples(keto, { namespace: ROLE_NS, object: name, relation: MEMBERS }); const tuples = await pagedTuples(keto, { namespace: ROLE_NS, object: name, relation: MEMBERS });
const members = tuples.map((t) => memberView(t, emailById)); const members = tuples.map((t) => memberView(t, emailById));
const effective = await effectiveUsers(keto, name, tuples.length > 0, emailById); const effective = await effectiveUsers(keto, name, tuples.length > 0, emailById);
return { html: await render("admin/role-detail", { model: buildRoleDetailModel({ candidates: options, csrfToken, effective, members, menu, role: { name }, user }) }) }; const html = await render("admin/role-detail", { model: buildRoleDetailModel({ candidates: options, csrfToken, effective, members, menu, role: { name }, user, ...(error ? { error } : {}) }) });
return error ? { html, status: 400 } : { html };
}; };
// /admin/roles — list (GET) · create (POST) // /admin/roles — list (GET) · create (POST)
@@ -362,12 +350,26 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
if (tuple) await keto.writeTuple(tuple); // the picker only offers real users/groups if (tuple) await keto.writeTuple(tuple); // the picker only offers real users/groups
return { redirect: base }; return { redirect: base };
} }
if (seg.length === 2 && seg[1] === "delete" && method === "GET") {
// Self-protection: deleting the admin role removes everyone's admin — refuse it outright.
if (name === ADMIN_PERMISSION) return renderDetail(name, "The admin role can't be deleted — it would remove all admin access.");
return { html: await render("admin/confirm", { model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { href: base, label: name }, { label: "Delete" }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete role", csrfToken,
current: "roles", menu, message: `Delete role ${name}? This revokes it from everyone it's assigned to.`, title: "Delete role", user,
}) }) };
}
if (seg.length === 2 && seg[1] === "delete" && method === "POST") { 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 await keto.deleteTuple({ namespace: ROLE_NS, object: name, relation: MEMBERS }); // removes every member tuple
return { redirect: ADMIN_ROLES_BASE }; return { redirect: ADMIN_ROLES_BASE };
} }
if (seg.length === 3 && seg[1] === "members" && seg[2] === "delete" && method === "POST") { if (seg.length === 3 && seg[1] === "members" && seg[2] === "delete" && method === "POST") {
const tuple = roleMemberTuple(name, (form!.get("member") ?? "").trim()); const member = (form!.get("member") ?? "").trim();
// Self-protection: don't let an admin revoke their own *direct* admin grant (would lock them out).
// 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);
return { redirect: base }; return { redirect: base };
} }

View File

@@ -5,11 +5,8 @@
// imperative shell app.ts dispatches to — it gates (admin only), CSRF-guards every mutation, and // imperative shell app.ts dispatches to — it gates (admin only), CSRF-guards every mutation, and
// maps each action to a RouteResult (render a page, or redirect after a write — PRG). // maps each action to a RouteResult (render a page, or redirect after a write — PRG).
import { ADMIN_PERMISSION, ADMIN_USERS_BASE, adminNav } from "./admin-nav.ts"; import { ADMIN_USERS_BASE, adminNav, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
import { readFormBody } from "./body.ts";
import type { RequestContext, User } from "./context.ts"; import type { RequestContext, User } from "./context.ts";
import { CSRF_FIELD, verifyCsrfRequest } from "./csrf.ts";
import { GuardError } from "./guards.ts";
import type { Identity, KratosAdmin, RecoveryCode } from "./kratos-admin.ts"; import type { Identity, KratosAdmin, RecoveryCode } from "./kratos-admin.ts";
import { KratosError } from "./kratos-public.ts"; import { KratosError } from "./kratos-public.ts";
import { parseListQuery } from "./list-query.ts"; import { parseListQuery } from "./list-query.ts";
@@ -306,23 +303,11 @@ export async function handleAdminUsers(ctx: RequestContext, csrfToken: string, d
const path = ctx.url.pathname; const path = ctx.url.pathname;
if (path !== ADMIN_USERS_BASE && !path.startsWith(`${ADMIN_USERS_BASE}/`)) return null; if (path !== ADMIN_USERS_BASE && !path.startsWith(`${ADMIN_USERS_BASE}/`)) return null;
if (!ctx.user) throw new GuardError(401, "authentication required", "/login"); const user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403)
if (!ctx.roles.includes(ADMIN_PERMISSION)) throw new GuardError(403, "admin role required");
const { kratosAdmin, menu, render } = deps; const { kratosAdmin, menu, render } = deps;
const user = ctx.user;
const method = (ctx.req.method ?? "GET").toUpperCase(); const method = (ctx.req.method ?? "GET").toUpperCase();
const seg = path.slice(ADMIN_USERS_BASE.length).split("/").filter(Boolean); const seg = path.slice(ADMIN_USERS_BASE.length).split("/").filter(Boolean);
const form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined
// Every mutation is a first-party form → CSRF-guard it (the host doesn't gate plugin routes,
// but it owns these). Reads the body once; the action handlers reuse the parsed form.
let form: URLSearchParams | undefined;
if (method === "POST") {
form = await readFormBody(ctx.req);
if (!verifyCsrfRequest({ cookieHeader: ctx.req.headers.cookie, secret: deps.csrfSecret, submitted: form.get(CSRF_FIELD) })) {
throw new GuardError(403, "invalid CSRF token");
}
}
const renderList = async (): Promise<RouteResult> => { const renderList = async (): Promise<RouteResult> => {
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE }); const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE });
@@ -370,18 +355,32 @@ export async function handleAdminUsers(ctx: RequestContext, csrfToken: string, d
return null; return null;
} }
if (seg.length === 2 && method === "POST") { if (seg.length === 2) {
if (seg[1] === "state") { const isSelf = targetId === user.id; // self-protection: an admin must not lock themselves out
await kratosAdmin.updateIdentity(targetId, setStatePayload(identity, identity.state === "inactive" ? "active" : "inactive")); if (seg[1] === "delete" && method === "GET") {
return { redirect: back }; if (isSelf) return { ...(await renderForm({ error: "You can't delete your own account.", identity })), status: 400 };
const view = toUserView(identity);
return { html: await render("admin/confirm", { model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { href: back, label: view.name }, { label: "Delete" }],
cancelHref: back, confirmAction: `${back}/delete`, confirmLabel: "Delete user", csrfToken,
current: "users", menu, message: `Delete ${view.email}? This permanently removes the account and can't be undone.`, title: "Delete user", user,
}) }) };
} }
if (seg[1] === "delete") { if (method === "POST") {
await kratosAdmin.deleteIdentity(targetId); if (seg[1] === "state") {
return { redirect: ADMIN_USERS_BASE }; if (isSelf) return { ...(await renderForm({ error: "You can't deactivate your own account.", identity })), status: 400 };
} await kratosAdmin.updateIdentity(targetId, setStatePayload(identity, identity.state === "inactive" ? "active" : "inactive"));
if (seg[1] === "recovery") { return { redirect: back };
const recovery = await kratosAdmin.createRecoveryCode(targetId); }
return renderForm({ identity, recovery }); if (seg[1] === "delete") {
if (isSelf) return { ...(await renderForm({ error: "You can't delete your own account.", identity })), status: 400 };
await kratosAdmin.deleteIdentity(targetId);
return { redirect: ADMIN_USERS_BASE };
}
if (seg[1] === "recovery") {
const recovery = await kratosAdmin.createRecoveryCode(targetId);
return renderForm({ identity, recovery });
}
} }
} }
return null; return null;

View File

@@ -228,7 +228,7 @@ test("session re-mint: an expired JWT backed by a live Kratos session is silentl
const nowSec = Math.floor(Date.now() / 1000); 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, roles: ["demo:read"], sub: "u1" });
const live = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: freshJwt } : { active: true, identity }) as Session); const live = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: freshJwt } : { active: true, identity }) as Session);
const keto = stubKeto({ listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "demo:read", relation: "members", subject_id: "user:u1" }] }) }); const keto = stubKeto({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "demo:read", relation: "members", subject_id: "user:u1" }] }) });
const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}; plainpages_session=s`; const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["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. // Live Kratos session: the lapsed token is re-minted — the gated route runs AND a fresh cookie rides the response.
@@ -408,7 +408,7 @@ test("login completion (/auth/complete): a live session mints the JWT cookie; no
let projected: unknown; let projected: unknown;
const kratos = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session); 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 kratosAdmin = stubAdmin({ updateMetadataPublic: async (_id, meta) => { projected = meta; return identity; } });
const keto = stubKeto({ listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "admin", relation: "members", subject_id: `user:${identity.id}` }] }) }); const keto = stubKeto({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "admin", relation: "members", subject_id: `user:${identity.id}` }] }) });
const complete = async (app: ReturnType<typeof createApp>, cookie?: string) => { const complete = async (app: ReturnType<typeof createApp>, cookie?: string) => {
await new Promise<void>((r) => app.listen(0, r)); await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close()); t.after(() => app.close());
@@ -464,7 +464,7 @@ test("logout (CSRF-guarded POST): valid token revokes the Kratos session + clear
test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, recovery (CSRF-guarded)", async (t) => { test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, recovery (CSRF-guarded)", async (t) => {
const mk = (email: string, over: Partial<Identity> = {}): Identity => const mk = (email: string, over: Partial<Identity> = {}): Identity =>
({ id: randomUUID(), schema_id: "default", state: "active", traits: { email, name: { first: "Ada", last: "Lovelace" } }, ...over }); ({ id: randomUUID(), schema_id: "default", state: "active", traits: { email, name: { first: "Ada", last: "Lovelace" } }, ...over });
const store: Identity[] = [mk("ada@example.com"), mk("babbage@example.com", { state: "inactive" })]; const store: Identity[] = [mk("ada@example.com"), mk("babbage@example.com", { state: "inactive" }), mk("you@example.com", { id: "admin1" })];
let lastCreate: { traits?: unknown } | undefined; let lastCreate: { traits?: unknown } | undefined;
const kratosAdmin = stubAdmin({ const kratosAdmin = stubAdmin({
createIdentity: async (payload) => { lastCreate = payload as { traits?: unknown }; const created = mk("grace@example.com"); store.push(created); return created; }, createIdentity: async (payload) => { lastCreate = payload as { traits?: unknown }; const created = mk("grace@example.com"); store.push(created); return created; },
@@ -528,11 +528,20 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
assert.equal(rec.status, 200); assert.equal(rec.status, 200);
assert.match(await rec.text(), /self-service\/recovery\?code=123456/); assert.match(await rec.text(), /self-service\/recovery\?code=123456/);
// Delete: removes the identity, back to the list. // Delete needs a deliberate confirm step (zero-JS): GET renders the interstitial, POST performs it.
const confirm = await (await get(`/admin/users/${target.id}/delete`)).text();
assert.match(confirm, /Cancel/);
assert.match(confirm, new RegExp(`action="/admin/users/${target.id}/delete"`));
const del = await post(`/admin/users/${target.id}/delete`, `_csrf=${token}`); const del = await post(`/admin/users/${target.id}/delete`, `_csrf=${token}`);
assert.equal(del.status, 303); assert.equal(del.status, 303);
assert.ok(!store.some((x) => x.id === target.id)); assert.ok(!store.some((x) => x.id === target.id));
// Self-protection: an admin can't delete or deactivate their own account (JWT sub = admin1).
assert.equal((await post(`/admin/users/admin1/delete`, `_csrf=${token}`)).status, 400);
assert.ok(store.some((x) => x.id === "admin1"));
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. // Unknown id → 404.
assert.equal((await get(`/admin/users/${randomUUID()}`)).status, 404); assert.equal((await get(`/admin/users/${randomUUID()}`)).status, 404);
}); });
@@ -610,7 +619,8 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${grace}`); await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${grace}`);
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`)); assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
// Delete the group: removes every member tuple, back to the list. // Delete the group: a confirm step (GET) then the POST removes every member tuple, back to the list.
assert.match(await (await get("/admin/groups/eng/delete")).text(), /Cancel/);
const del = await post("/admin/groups/eng/delete", `_csrf=${token}`); const del = await post("/admin/groups/eng/delete", `_csrf=${token}`);
assert.equal(del.status, 303); assert.equal(del.status, 303);
assert.equal(del.headers.get("location"), "/admin/groups"); assert.equal(del.headers.get("location"), "/admin/groups");
@@ -706,12 +716,20 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=group:eng`); 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")); assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor" && tp.subject_set?.object === "eng"));
// Delete the role: removes every member tuple, back to the list. // 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}`); const del = await post("/admin/roles/editor/delete", `_csrf=${token}`);
assert.equal(del.status, 303); assert.equal(del.status, 303);
assert.equal(del.headers.get("location"), "/admin/roles"); assert.equal(del.headers.get("location"), "/admin/roles");
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor")); assert.ok(!tuples.some((tp) => tp.namespace === "Role" && 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: "user:admin1" });
assert.equal((await post("/admin/roles/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=user:admin1`)).status, 400);
assert.ok(tuples.some((tp) => tp.object === "admin" && tp.subject_id === "user:admin1"));
// An invalid role name in the path → 404; malformed %-encoding doesn't 500. // 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/Bad%20Name")).status, 404);
assert.equal((await get("/admin/roles/%ZZ")).status, 404); assert.equal((await get("/admin/roles/%ZZ")).status, 404);

View File

@@ -40,18 +40,29 @@ const publicStub = (over: Partial<KratosPublic> = {}): KratosPublic => ({
...over, ...over,
}); });
test("readRoles reads direct Role memberships from Keto — paged, de-duped, sorted", async () => { test("readRoles returns roles held directly OR transitively (enumerate defined roles → Keto-check each)", async () => {
const calls: unknown[] = []; const listQ: unknown[] = [];
const checked: string[] = [];
const role = (object: string, subject: Partial<RelationTuple>): RelationTuple => ({ namespace: "Role", object, relation: "members", ...subject });
const keto = ketoStub({ const keto = ketoStub({
// Enumerate every Role tuple (paged, no subject filter) to find the distinct role names —
// subjects vary (a direct user, a group) and a name repeats across pages → de-duped.
listRelations: async (q) => { listRelations: async (q) => {
calls.push(q); listQ.push(q);
if (!q?.pageToken) return { nextPageToken: "p2", tuples: [roleTuple("editor"), roleTuple("admin")] }; if (q?.pageToken === "p2") return { nextPageToken: null, tuples: [role("editor", { subject_id: "user:other" })] };
return { nextPageToken: null, tuples: [roleTuple("admin")] }; // duplicate across pages return { nextPageToken: "p2", tuples: [
role("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }),
role("admin", { subject_id: `user:${ID}` }),
role("viewer", { subject_id: "user: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(await readRoles(keto, ID), ["admin", "editor"]);
assert.deepEqual(calls[0], { namespace: "Role", relation: "members", subject_id: `user:${ID}` }); assert.deepEqual(listQ[0], { namespace: "Role", relation: "members" }); // enumerate, not subject-filtered
assert.equal((calls[1] as { pageToken?: string }).pageToken, "p2"); // second page follows the cursor 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
}); });
test("completeLogin: read roles → project onto metadata_public → tokenize → JWT (in that order)", async () => { test("completeLogin: read roles → project onto metadata_public → tokenize → JWT (in that order)", async () => {
@@ -65,7 +76,7 @@ test("completeLogin: read roles → project onto metadata_public → tokenize
}, },
}); });
const kratosAdmin = adminStub({ updateMetadataPublic: async (_id, meta) => { events.push("project"); projected = meta; return identity; } }); const kratosAdmin = adminStub({ updateMetadataPublic: async (_id, meta) => { events.push("project"); projected = meta; return identity; } });
const keto = ketoStub({ listRelations: async () => ({ nextPageToken: null, tuples: [roleTuple("admin")] }) }); const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [roleTuple("admin")] }) });
const out = await completeLogin({ keto, kratosAdmin, kratosPublic }, "plainpages_session=s"); 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(out, { email: "admin@plainpages.local", identityId: ID, jwt: "h.p.s", roles: ["admin"] });
@@ -90,7 +101,7 @@ 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 () => { 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 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 kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session });
const keto = ketoStub({ listRelations: async () => ({ nextPageToken: null, tuples: [roleTuple("admin")] }) }); const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [roleTuple("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 roles from Keto, re-tokenize, fresh cookie.
const live = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic }, "plainpages_session=s"); const live = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic }, "plainpages_session=s");

View File

@@ -36,19 +36,23 @@ export interface CompletedLogin {
roles: string[]; roles: string[];
} }
// The coarse roles Keto grants a subject directly: `Role:<name>#members@user:<id>`. Returns // The coarse roles a user holds — directly (`Role:<name>#members@user:<id>`) or transitively via a
// the de-duped, sorted role names (the tuple `object`). One logical read, paged defensively. // group that is a member of the role. Enumerates the defined roles (the distinct objects in the Role
// Group→role inheritance lands with the Groups screen (§5); MVP grants are direct. // namespace) and asks Keto to resolve each membership, so a role 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[]> { export async function readRoles(keto: KetoClient, identityId: string): Promise<string[]> {
const subject_id = `user:${identityId}`; const subject_id = `user:${identityId}`;
const roles = new Set<string>(); const names = new Set<string>();
let pageToken: string | undefined; let pageToken: string | undefined;
do { do {
const page = await keto.listRelations({ namespace: "Role", relation: "members", subject_id, ...(pageToken ? { pageToken } : {}) }); const page = await keto.listRelations({ namespace: "Role", relation: "members", ...(pageToken ? { pageToken } : {}) });
for (const t of page.tuples) roles.add(t.object); for (const t of page.tuples) names.add(t.object);
pageToken = page.nextPageToken ?? undefined; pageToken = page.nextPageToken ?? undefined;
} while (pageToken); } while (pageToken);
return [...roles].sort(); 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();
} }
export async function completeLogin(deps: LoginDeps, cookie: string | undefined): Promise<CompletedLogin | null> { export async function completeLogin(deps: LoginDeps, cookie: string | undefined): Promise<CompletedLogin | null> {

View File

@@ -4,8 +4,8 @@ import { buildShellContext, shellUser } from "./shell-context.ts";
test("shellUser derives the profile from the real user; anonymous → Guest", () => { test("shellUser derives the profile from the real user; anonymous → Guest", () => {
assert.deepEqual(shellUser(null), { email: "", initials: "G", name: "Guest" }); assert.deepEqual(shellUser(null), { email: "", initials: "G", name: "Guest" });
// Real user: name = email, initials = first two letters of the local part, upper-cased. // 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: "", initials: "AD", name: "ada@example.com" }); assert.deepEqual(shellUser({ email: "ada@example.com", id: "u1", roles: [] }), { email: "ada@example.com", initials: "AD", name: "ada" });
}); });
test("buildShellContext maps branding + breadcrumbs, omitting unset optional fields", () => { test("buildShellContext maps branding + breadcrumbs, omitting unset optional fields", () => {

View File

@@ -2,7 +2,8 @@
// (the home dashboard, the built-in admin screens) hands to shell.ejs. Pure. Extracted so the // (the home dashboard, the built-in admin screens) hands to shell.ejs. Pure. Extracted so the
// shell user is the *real* signed-in identity (§4) — no hardcoded demo profile — and branding is // shell user is the *real* signed-in identity (§4) — 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/roles), so
// the profile shows the email with initials derived from its local part; anonymous ⇒ "Guest". // the profile shows the email's local part as the name with the full email beneath, initials from
// the local part; anonymous ⇒ "Guest".
import type { User } from "./context.ts"; import type { User } from "./context.ts";
import { type MenuConfig } from "./menu-config.ts"; import { type MenuConfig } from "./menu-config.ts";
@@ -25,7 +26,7 @@ export interface ShellModel {
export function shellUser(user: User | null | undefined): ShellUser { export function shellUser(user: User | null | undefined): ShellUser {
if (!user) return { email: "", initials: "G", name: "Guest" }; if (!user) return { email: "", initials: "G", name: "Guest" };
const local = user.email.split("@")[0] || user.email; const local = user.email.split("@")[0] || user.email;
return { email: "", initials: (local.slice(0, 2) || "U").toUpperCase(), name: user.email }; return { email: user.email, initials: (local.slice(0, 2) || "U").toUpperCase(), name: local };
} }
export function buildShellContext(opts: { export function buildShellContext(opts: {

View File

@@ -97,7 +97,7 @@ everything via Docker.
- [x] Groups: Keto subject sets — list/create/delete + membership management. → `src/admin-groups.ts`: pure view-model + Keto-tuple builders (`groupsFromTuples`, `parseSubject`/`memberTuple`, `memberView`, `isValidGroupName`, `buildGroups{List,Detail,Form}Model`) + `handleAdminGroups` (the imperative shell app.ts dispatches `/admin/groups*` to). A group is a Keto subject set `Group:<name>#members`; a member is a user (`subject_id=user:<uuid>`) or a nested group (`subject_set=Group:<other>#members`). Keto has no create-object, so a group exists while it has ≥1 member: **create** writes the first-member tuple (requires a member, rejects a duplicate/invalid name), **delete** removes every member tuple (one delete-by-partial-filter), **add/remove member** write/delete one tuple. Routes: `GET /admin/groups` (list — search/sort/paginate over one Keto namespace scan), `GET|POST /admin/groups/new`+`/` (create), `GET /admin/groups/:name` (membership detail — members by email, add a user/nested group, remove, delete-group), `POST …/members` · `…/members/delete` · `…/delete`. Writes go **only to Keto** (README "stateless"); Kratos is read only to label the member pickers by email. Gated **admin-only** (anon→/login, non-admin→403) and every mutation **CSRF-guarded**, same as Users; reuses the §1 building blocks around the shell. Extracted `src/admin-nav.ts` (shared Dashboard·Users·Groups sidebar nav) so the two screens can't drift; added a generic `rowHeader` `<th scope=row>` data-table cell (the group name links to its detail). Tests-first: `admin-groups.test.ts` (builder/validation/subject matrix), `app.test.ts` HTTP integration (gate/list/create/dup-reject/detail/add/remove/delete + CSRF + invalid-name & malformed-`%`→404), `data-table.test.ts` (rowHeader). Stability-reviewer (treated as a local PR): APPROVE; fixed its nits — symmetric subject validation (UUID-check the user id), "already exists" feedback on create, malformed-`%`→404 (`safeDecode`). typecheck + 237 units green. Boot-verified the core Keto interactions live (namespace listing, group-collapse counts, delete-group-by-filter, single-member removal). The full-stack groups-CRUD Playwright E2E is §8's scope (line 123), as with the Users screen. Roles/permissions + global-menu wiring are the next §5 items. - [x] Groups: Keto subject sets — list/create/delete + membership management. → `src/admin-groups.ts`: pure view-model + Keto-tuple builders (`groupsFromTuples`, `parseSubject`/`memberTuple`, `memberView`, `isValidGroupName`, `buildGroups{List,Detail,Form}Model`) + `handleAdminGroups` (the imperative shell app.ts dispatches `/admin/groups*` to). A group is a Keto subject set `Group:<name>#members`; a member is a user (`subject_id=user:<uuid>`) or a nested group (`subject_set=Group:<other>#members`). Keto has no create-object, so a group exists while it has ≥1 member: **create** writes the first-member tuple (requires a member, rejects a duplicate/invalid name), **delete** removes every member tuple (one delete-by-partial-filter), **add/remove member** write/delete one tuple. Routes: `GET /admin/groups` (list — search/sort/paginate over one Keto namespace scan), `GET|POST /admin/groups/new`+`/` (create), `GET /admin/groups/:name` (membership detail — members by email, add a user/nested group, remove, delete-group), `POST …/members` · `…/members/delete` · `…/delete`. Writes go **only to Keto** (README "stateless"); Kratos is read only to label the member pickers by email. Gated **admin-only** (anon→/login, non-admin→403) and every mutation **CSRF-guarded**, same as Users; reuses the §1 building blocks around the shell. Extracted `src/admin-nav.ts` (shared Dashboard·Users·Groups sidebar nav) so the two screens can't drift; added a generic `rowHeader` `<th scope=row>` data-table cell (the group name links to its detail). Tests-first: `admin-groups.test.ts` (builder/validation/subject matrix), `app.test.ts` HTTP integration (gate/list/create/dup-reject/detail/add/remove/delete + CSRF + invalid-name & malformed-`%`→404), `data-table.test.ts` (rowHeader). Stability-reviewer (treated as a local PR): APPROVE; fixed its nits — symmetric subject validation (UUID-check the user id), "already exists" feedback on create, malformed-`%`→404 (`safeDecode`). typecheck + 237 units green. Boot-verified the core Keto interactions live (namespace listing, group-collapse counts, delete-group-by-filter, single-member removal). The full-stack groups-CRUD Playwright E2E is §8's scope (line 123), as with the Users screen. Roles/permissions + global-menu wiring are the next §5 items.
- [x] Roles & permissions: Keto relations — assign roles to users/groups; "effective access" view via Keto expand. → `src/admin-roles.ts`: a role is a Keto subject set `Role:<name>#members` (OPL: members are users or groups, resolved transitively — the source of truth the §4 login projects into the JWT). Same shape as the Groups screen, so the pure membership helpers are reused from `admin-groups.ts` (`parseSubject`, `isValidGroupName`, `memberView`, `groupsFromTuples`, and now-exported `pagedTuples`/`memberCandidates`/`safeDecode`). Routes (`handleAdminRoles`, dispatched by app.ts): `GET /admin/roles` (list — search/sort/paginate over one Keto scan), `GET|POST /admin/roles/new`+`/` (create = assign first member; rejects invalid/duplicate name), `GET /admin/roles/:name` (detail), `POST …/members` (assign a user/group) · `…/members/delete` (revoke) · `…/delete` (remove all member tuples). The one role-specific piece is **effective access**: `keto.expand(Role:<name>#members, {maxDepth:50})``expandToEffectiveUsers` flattens the tree to the distinct users who hold the role directly *or transitively via a group* (the coarse JWT projection stays direct-only per the README's one-read-per-login design; this view is where group→role inheritance is surfaced). Writes go **only to Keto**; Kratos is read only to label members. Gated admin-only (anon→/login, non-admin→403) + CSRF-guarded, like Users/Groups. Added a "Roles" entry (`i-shield`) to the shared `admin-nav.ts`; new `.plain-list` CSS rule. Tests-first: `admin-roles.test.ts` (builders + expand-flatten matrix) + `app.test.ts` HTTP integration (gate/list/create/dup-reject/assign user&group/effective-access-via-expand/revoke/delete + CSRF + malformed-name→404). Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its expand-depth nit (explicit `maxDepth`). 237→243 units + typecheck green. **Live boot-verify caught a real bug the tests missed:** Keto v26.2.0's expand nests the subject under `tuple` (`{type:"leaf",tuple:{subject_id}}`), not at the node top-level as the §4 `ExpandTree` type had guessed — fixed the type + walker + the (wrongly-shaped) fixtures, then re-verified live that a user reachable only through a group surfaces in effective access; torn down. Global-menu wiring is the next §5 item. - [x] Roles & permissions: Keto relations — assign roles to users/groups; "effective access" view via Keto expand. → `src/admin-roles.ts`: a role is a Keto subject set `Role:<name>#members` (OPL: members are users or groups, resolved transitively — the source of truth the §4 login projects into the JWT). Same shape as the Groups screen, so the pure membership helpers are reused from `admin-groups.ts` (`parseSubject`, `isValidGroupName`, `memberView`, `groupsFromTuples`, and now-exported `pagedTuples`/`memberCandidates`/`safeDecode`). Routes (`handleAdminRoles`, dispatched by app.ts): `GET /admin/roles` (list — search/sort/paginate over one Keto scan), `GET|POST /admin/roles/new`+`/` (create = assign first member; rejects invalid/duplicate name), `GET /admin/roles/:name` (detail), `POST …/members` (assign a user/group) · `…/members/delete` (revoke) · `…/delete` (remove all member tuples). The one role-specific piece is **effective access**: `keto.expand(Role:<name>#members, {maxDepth:50})``expandToEffectiveUsers` flattens the tree to the distinct users who hold the role directly *or transitively via a group* (the coarse JWT projection stays direct-only per the README's one-read-per-login design; this view is where group→role inheritance is surfaced). Writes go **only to Keto**; Kratos is read only to label members. Gated admin-only (anon→/login, non-admin→403) + CSRF-guarded, like Users/Groups. Added a "Roles" entry (`i-shield`) to the shared `admin-nav.ts`; new `.plain-list` CSS rule. Tests-first: `admin-roles.test.ts` (builders + expand-flatten matrix) + `app.test.ts` HTTP integration (gate/list/create/dup-reject/assign user&group/effective-access-via-expand/revoke/delete + CSRF + malformed-name→404). Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its expand-depth nit (explicit `maxDepth`). 237→243 units + typecheck green. **Live boot-verify caught a real bug the tests missed:** Keto v26.2.0's expand nests the subject under `tuple` (`{type:"leaf",tuple:{subject_id}}`), not at the node top-level as the §4 `ExpandTree` type had guessed — fixed the type + walker + the (wrongly-shaped) fixtures, then re-verified live that a user reachable only through a group surfaces in effective access; torn down. Global-menu wiring is the next §5 item.
- [x] Wire into the menu (admin section, permission-gated). → Extracted `adminSection(current?)` in `admin-nav.ts` as the single source of truth for the built-in screens' menu links: a permission-gated (`admin`) "Admin" header whose children are Users/Groups/Roles. Wired into the **global** dashboard menu (`dashboard.ts` appends `adminSection()`) so an admin sees the section on `/`; `composeNav`'s `filterByRoles` drops the whole gated header + subtree for a non-admin/anonymous (cosmetic — the routes themselves stay independently `GuardError(403)`-gated). The in-screen `adminNav()` now reuses the same `adminSection(current)` (Dashboard link + the active-marked section) so the two navs can't drift; narrowed `AdminScreen` to `groups|roles|users` (the home link was never `current`). Reuses existing sprite icons (no icon-guard change). Tests-first: `dashboard.test.ts` (admin→section present with the three hrefs; non-admin→absent) + `app.test.ts` HTTP integration (admin JWT→`/admin/users` link rendered, anonymous→absent). Default anonymous `/` render is byte-equivalent (section filtered out) so the visual E2E is unaffected. README Layout line updated. Stability-reviewer run as a local PR: APPROVE, no Critical/High/Medium. 242→244 units + typecheck green. - [x] Wire into the menu (admin section, permission-gated). → Extracted `adminSection(current?)` in `admin-nav.ts` as the single source of truth for the built-in screens' menu links: a permission-gated (`admin`) "Admin" header whose children are Users/Groups/Roles. Wired into the **global** dashboard menu (`dashboard.ts` appends `adminSection()`) so an admin sees the section on `/`; `composeNav`'s `filterByRoles` drops the whole gated header + subtree for a non-admin/anonymous (cosmetic — the routes themselves stay independently `GuardError(403)`-gated). The in-screen `adminNav()` now reuses the same `adminSection(current)` (Dashboard link + the active-marked section) so the two navs can't drift; narrowed `AdminScreen` to `groups|roles|users` (the home link was never `current`). Reuses existing sprite icons (no icon-guard change). Tests-first: `dashboard.test.ts` (admin→section present with the three hrefs; non-admin→absent) + `app.test.ts` HTTP integration (admin JWT→`/admin/users` link rendered, anonymous→absent). Default anonymous `/` render is byte-equivalent (section filtered out) so the visual E2E is unaffected. README Layout line updated. Stability-reviewer run as a local PR: APPROVE, no Critical/High/Medium. 242→244 units + typecheck green.
- [ ] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. - [x] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Ran both on all of `src/`/`views/`/`config/`/docs (weighted to the §5 admin screens). Architecture: **no Critical/High** (functional-core/imperative-shell genuinely honored, security primitives sound). Product: **2 Critical + 1 High**. **Fixed now (tests-first):** (1) Critical (product) — the Roles "Effective access" view showed group→role membership *transitively* but `login.ts` `readRoles` granted only **direct** memberships into the JWT, so a user holding a role *only via a group* was listed as having it yet gated as if not (two screens contradicting). Per the user's call, made `readRoles` transitive: enumerate the defined roles + Keto-`check` each (resolves group membership), so the JWT now matches the Effective-access view + the OPL model — at login/refresh only, never per request (README login section + `admin-roles.ts` header updated). (2) Critical (product) — no confirmation on destructive actions: added a server-rendered (zero-JS) confirm step (`views/admin/confirm.ejs` + `partials/confirm-body.ejs`, shared `buildConfirmModel`) — `GET /admin/{users,groups,roles}/:id/delete` renders an interstitial (Cancel + the real POST); each detail/edit Delete control is now a link to it. (3) High (product) — self-lockout: an admin can no longer delete or deactivate **their own** account, revoke **their own** (direct) admin grant, or delete the **admin role** outright (each → 400 + inline error). Covers the direct-grant paths (incl. the bootstrap-seeded admin, which holds a direct grant); admin held *only* via a group can still be self-revoked, so the robust "last effective admin won't drop" check is deferred to **§9** (stability-reviewer Medium). (4) MEDIUM (arch M1 pt.1) — extracted the gate+CSRF preamble copied verbatim across the 3 admin handlers into `admin-nav.ts` `requireAdmin`/`guardedForm` (one security-critical copy, can't drift). (5) MEDIUM (arch M4) — `shellUser` no longer blanks the email: name = email local part, full email beneath (matches `toUserView`). Tests-first throughout (extended the 3 admin HTTP tests + login/shell-context units); typecheck + 244 units + 8 visual E2E + the full-stack auth-refresh E2E green (the latter re-verifies live login→transitive `readRoles``roles:["admin"]`). **Deferred (reviewer-scoped, not the §5 checkpoint):** the host internal route-table (fold the admin if-ladder + Hydra into `matchRoute`/`isAuthorized`, arch M1 pt.2) → **§6** (the 2nd/3rd Hydra screen is the forcing function); admin list-model/template near-duplication across Users/Groups/Roles (arch M3) → the §5 comment/test-cleanup items below (lines 101102); success-flash after writes + welcoming empty-list states + warn-on-dangling-group-references + >250-row truncation notice (product Medium) → §5 polish / §8 E2E; `safeUrl()` href helper (arch L1 — the recovery link is server-built, not exploitable today) → **§7** (first untrusted-URL flow); oversized-body→500 should be 413 (arch M2) + prod Ory-URL `https` enforcement (arch L3) + `§N`-in-comments / README Layout drift (arch L4) → **§9** (ops/security).
- [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. - [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff.
- [ ] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us. - [ ] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us.

17
views/admin/confirm.ejs Normal file
View File

@@ -0,0 +1,17 @@
<%#
Admin destructive-action confirmation page (todo §5): the confirm body in the app shell. Model
from buildConfirmModel: { message, confirm:{action,label}, cancelHref, nav, shell }.
%><%
const nav = include("partials/nav-tree", { nodes: model.nav });
const body = include("partials/confirm-body", { cancelHref: model.cancelHref, confirm: model.confirm, csrfToken: model.shell.csrfToken, message: model.message });
-%>
<%- include("partials/shell", {
body,
brand: model.shell.brand,
breadcrumbs: model.shell.breadcrumbs,
csrfToken: model.shell.csrfToken,
nav,
theme: model.shell.theme,
title: model.shell.title,
user: model.shell.user,
}) %>

View File

@@ -0,0 +1,17 @@
<%#
Destructive-action confirm body (todo §5), captured into the shell content slot. Zero-JS: the
delete is a deliberate second step (a POST form), with a cancel link back. Config:
message string
confirm { action, label } the danger POST endpoint + button label
cancelHref string
csrfToken
%>
<div class="form-page">
<section class="form-card admin-actions" aria-label="Confirm action">
<p><%= locals.message %></p>
<div class="form-actions">
<a class="btn" href="<%= locals.cancelHref %>">Cancel</a>
<form method="post" action="<%= locals.confirm.action %>"><input type="hidden" name="_csrf" value="<%= locals.csrfToken %>"><button class="btn btn-danger" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= locals.confirm.label %></button></form>
</div>
</section>
</div>

View File

@@ -37,6 +37,6 @@
<% } -%> <% } -%>
</section> </section>
<section class="form-card admin-actions" aria-label="Group actions"> <section class="form-card admin-actions" aria-label="Group actions">
<form method="post" action="<%= del.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><button class="btn btn-danger" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete group</button></form> <a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete group</a>
</section> </section>
</div> </div>

View File

@@ -52,6 +52,6 @@
<% } -%> <% } -%>
</section> </section>
<section class="form-card admin-actions" aria-label="Role actions"> <section class="form-card admin-actions" aria-label="Role actions">
<form method="post" action="<%= del.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><button class="btn btn-danger" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete role</button></form> <a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete role</a>
</section> </section>
</div> </div>

View File

@@ -30,7 +30,7 @@
<section class="form-card admin-actions" aria-label="Account actions"> <section class="form-card admin-actions" aria-label="Account actions">
<form method="post" action="<%= edit.recoveryAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg>Send recovery link</button></form> <form method="post" action="<%= edit.recoveryAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg>Send recovery link</button></form>
<form method="post" action="<%= edit.stateAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form> <form method="post" action="<%= edit.stateAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form>
<form method="post" action="<%= edit.deleteAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn btn-danger" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete user</button></form> <a class="btn btn-danger" href="<%= edit.deleteAction %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete user</a>
</section> </section>
<% } -%> <% } -%>
</div> </div>