From a016a0131e3915790c211f96695b78e11caa3682 Mon Sep 17 00:00:00 2001 From: lilleman Date: Thu, 18 Jun 2026 18:18:18 +0200 Subject: [PATCH] =?UTF-8?q?Built-in=20Roles=20&=20permissions=20admin=20sc?= =?UTF-8?q?reen=20(todo=20=C2=A75);=20/admin/roles=20list=20(search/sort/p?= =?UTF-8?q?aginate)=20+=20create/delete=20+=20assign-to-users/groups=20+?= =?UTF-8?q?=20"effective=20access"=20(Keto=20expand=20=E2=86=92=20transiti?= =?UTF-8?q?ve=20members),=20writing=20only=20to=20Keto=20=E2=80=94=20gated?= =?UTF-8?q?=20admin-only=20+=20CSRF-guarded=20like=20Users/Groups=20(Krato?= =?UTF-8?q?s=20read=20only=20to=20label=20members).=20A=20role=20=3D=20Ket?= =?UTF-8?q?o=20subject=20set=20Role:#members;=20reuses=20the=20Group?= =?UTF-8?q?s=20membership=20helpers=20(now-exported=20pagedTuples/memberCa?= =?UTF-8?q?ndidates/safeDecode);=20added=20a=20Roles=20nav=20entry=20(i-sh?= =?UTF-8?q?ield)=20+=20a=20.plain-list=20CSS=20rule.=20Stability-reviewer?= =?UTF-8?q?=20run=20as=20a=20local=20PR:=20APPROVE,=20no=20Critical/High;?= =?UTF-8?q?=20addressed=20its=20explicit-expand-depth=20nit.=20Live=20boot?= =?UTF-8?q?-verify=20caught=20a=20real=20bug=20the=20tests=20missed=20?= =?UTF-8?q?=E2=80=94=20Keto=20v26.2.0=20nests=20the=20expand=20subject=20u?= =?UTF-8?q?nder=20`tuple`=20(not=20node=20top-level=20as=20the=20=C2=A74?= =?UTF-8?q?=20ExpandTree=20type=20guessed),=20so=20expandToEffectiveUsers?= =?UTF-8?q?=20returned=20[];=20fixed=20type+walker+fixtures,=20re-verified?= =?UTF-8?q?=20a=20group-only=20member=20surfaces=20in=20effective=20access?= =?UTF-8?q?.=20237=E2=86=92243=20units=20+=20typecheck=20green;=20expand?= =?UTF-8?q?=20chain=20boot-verified=20live=20then=20torn=20down.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 + README.md | 5 +- public/css/styles.css | 1 + src/admin-groups.ts | 8 +- src/admin-nav.ts | 4 +- src/admin-roles.test.ts | 106 ++++++++ src/admin-roles.ts | 375 ++++++++++++++++++++++++++++ src/app.test.ts | 98 +++++++- src/app.ts | 12 +- src/keto-client.test.ts | 2 +- src/keto-client.ts | 8 +- todo.md | 4 +- views/admin/role-detail.ejs | 16 ++ views/admin/role-form.ejs | 16 ++ views/admin/roles.ejs | 21 ++ views/partials/role-detail-body.ejs | 57 +++++ views/partials/role-form-body.ejs | 26 ++ 17 files changed, 744 insertions(+), 17 deletions(-) create mode 100644 src/admin-roles.test.ts create mode 100644 src/admin-roles.ts create mode 100644 views/admin/role-detail.ejs create mode 100644 views/admin/role-form.ejs create mode 100644 views/admin/roles.ejs create mode 100644 views/partials/role-detail-body.ejs create mode 100644 views/partials/role-form-body.ejs diff --git a/AGENTS.md b/AGENTS.md index c7383bc..116cfda 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,3 +56,5 @@ docker compose -f compose.yml up --build -d # production versions** — never ranges (`^`, `~`) and never digests/hashes. npm deps are kept exact by `.npmrc` (`save-exact=true`) + `npm ci`; the base image by tag (e.g. `node:24.16.0-alpine3.24`). +- Run the stability reviewer agent after every implementation of something that can be like + a PR. That includes an implementation from the todo file that is pushed directly to master. \ No newline at end of file diff --git a/README.md b/README.md index e271e97..8eacbbb 100644 --- a/README.md +++ b/README.md @@ -535,7 +535,8 @@ src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, po src/dashboard.ts buildDashboardModel(): the home "/" People list view model (mock data, wires the §1 helpers) src/admin-users.ts Built-in Users admin screen (§5): list Kratos identities (filter/sort/paginate) + create/edit/deactivate/delete/recovery; gated + CSRF-guarded src/admin-groups.ts Built-in Groups admin screen (§5): list Keto subject sets + create/delete + membership (add/remove users & nested groups); writes only to Keto, gated + CSRF-guarded -src/admin-nav.ts adminNav(): the shared sidebar nav for the built-in admin screens (Dashboard · Users · Groups) +src/admin-roles.ts Built-in Roles admin screen (§5): list/create/delete Keto roles + assign to users/groups + "effective access" (Keto expand → transitive members); reuses the Groups membership helpers, writes only to Keto, gated + CSRF-guarded +src/admin-nav.ts adminNav(): the shared sidebar nav for the built-in admin screens (Dashboard · Users · Groups · Roles) src/shell-context.ts buildShellContext(): brand/theme/user view-model shared by the dashboard + admin screens (real signed-in user, no demo profile) src/icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs) src/list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize } @@ -546,7 +547,7 @@ src/discovery.ts discoverPlugins(): scan plugins/, import + validate each pl src/router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate (§2) src/view-resolver.ts renderPluginView(): render plugins//views/.ejs; plugin views can include() core partials (§2) src/menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding), validated at boot (§2) -views/ Core EJS templates (index = the app-shell People dashboard, admin/ = the Users list + create/edit form and the Groups list + create form + membership detail, auth = themed Kratos self-service page, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, pagination, form field, auth card, alert, flow body, user-form/group-form/group-detail bodies, menu/popover, theme switch, icon sprite) +views/ Core EJS templates (index = the app-shell People dashboard, admin/ = the Users list + create/edit form, the Groups list + create form + membership detail, and the Roles list + create form + assign/effective-access detail, auth = themed Kratos self-service page, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, pagination, form field, auth card, alert, flow body, user-form/group-form/group-detail/role-form/role-detail bodies, menu/popover, theme switch, icon sprite) public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt) config/menu.ts Central menu override + branding (optional; defaults apply if absent) ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — role/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs) + storage init (postgres/init/init.sql: one DB per service) diff --git a/public/css/styles.css b/public/css/styles.css index 569ebac..b05f049 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -677,6 +677,7 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); } .inline-form { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; } .admin-actions { flex-flow: row wrap; gap: 10px; align-items: center; } .admin-actions form { margin: 0; } +.plain-list { display: flex; flex-direction: column; gap: 6px; } .btn-danger { color: var(--neg); border-color: var(--neg-bd); } .btn-danger:hover { background: var(--neg-bg); } .recovery-link { word-break: break-all; } diff --git a/src/admin-groups.ts b/src/admin-groups.ts index 5609c6c..8639ad8 100644 --- a/src/admin-groups.ts +++ b/src/admin-groups.ts @@ -291,8 +291,8 @@ export interface AdminGroupsDeps { render: (view: string, data: Record) => Promise; } -// Drain every page of a relation-tuple query. -async function pagedTuples(keto: KetoClient, query: RelationQuery): Promise { +// Drain every page of a relation-tuple query. (Reused by the Roles screen — same membership model.) +export async function pagedTuples(keto: KetoClient, query: RelationQuery): Promise { const out: RelationTuple[] = []; let pageToken: string | undefined; do { @@ -305,7 +305,7 @@ async function pagedTuples(keto: KetoClient, query: RelationQuery): Promise; options: MemberOption[] }> { +export async function memberCandidates(keto: KetoClient, kratosAdmin: KratosAdmin): Promise<{ emailById: Map; options: MemberOption[] }> { const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE }); const emailById = new Map(); const userOptions: MemberOption[] = []; @@ -326,7 +326,7 @@ async function groupExists(keto: KetoClient, name: string): Promise { } // Decode a path segment without letting malformed %-encoding throw (→ caller treats it as not found). -function safeDecode(seg: string): string | null { +export function safeDecode(seg: string): string | null { try { return decodeURIComponent(seg); } catch { return null; } } diff --git a/src/admin-nav.ts b/src/admin-nav.ts index c1659ac..7450982 100644 --- a/src/admin-nav.ts +++ b/src/admin-nav.ts @@ -9,8 +9,9 @@ import { composeNav, type NavNode } from "./nav.ts"; export const ADMIN_PERMISSION = "admin"; // role token gating every admin screen export const ADMIN_USERS_BASE = "/admin/users"; export const ADMIN_GROUPS_BASE = "/admin/groups"; +export const ADMIN_ROLES_BASE = "/admin/roles"; -type AdminScreen = "dashboard" | "groups" | "users"; +type AdminScreen = "dashboard" | "groups" | "roles" | "users"; export function adminNav(roles: string[], menu: MenuConfig, current: AdminScreen): NavNode[] { const gated = (id: AdminScreen, href: string, icon: string, label: string): NavNode => @@ -19,5 +20,6 @@ export function adminNav(roles: string[], menu: MenuConfig, current: AdminScreen { href: "/", icon: "i-grid", id: "dashboard", label: "Dashboard" }, gated("users", ADMIN_USERS_BASE, "i-users", "Users"), gated("groups", ADMIN_GROUPS_BASE, "i-layers", "Groups"), + gated("roles", ADMIN_ROLES_BASE, "i-shield", "Roles"), ]], menu.override, roles); } diff --git a/src/admin-roles.test.ts b/src/admin-roles.test.ts new file mode 100644 index 0000000..9a0cb01 --- /dev/null +++ b/src/admin-roles.test.ts @@ -0,0 +1,106 @@ +// Built-in Roles & permissions admin screen (§5): the pure view-model + Keto builders. A role is a +// Keto subject set (Role:#members); members are users (subject_id) or groups (subject_set) — +// "assign roles to users/groups". The "effective access" view flattens a Keto `expand` tree into the +// distinct set of users who hold the role directly or transitively via a group. The HTTP +// routing/gate/CSRF + live Keto/Kratos calls are exercised over HTTP in app.test.ts. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { memberView } from "./admin-groups.ts"; +import { + buildRoleDetailModel, + buildRoleFormModel, + buildRolesListModel, + expandToEffectiveUsers, + isValidRoleName, + roleMemberTuple, +} from "./admin-roles.ts"; +import type { ExpandTree, RelationTuple } from "./keto-client.ts"; + +const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`; +const userTuple = (role: string, n: number): RelationTuple => + ({ namespace: "Role", object: role, relation: "members", subject_id: `user:${uid(n)}` }); +const groupTuple = (role: string, group: string): RelationTuple => + ({ namespace: "Role", object: role, relation: "members", subject_set: { namespace: "Group", object: group, relation: "members" } }); + +test("isValidRoleName + roleMemberTuple map the form value to a Role tuple over a user/group (else null)", () => { + for (const ok of ["admin", "editor", "team-a", "a1_b9"]) assert.equal(isValidRoleName(ok), true, ok); + for (const bad of ["", "Admin", "a b", "-bad", "a".repeat(65)]) assert.equal(isValidRoleName(bad), false, bad); + + assert.deepEqual(roleMemberTuple("editor", `user:${uid(2)}`), { namespace: "Role", object: "editor", relation: "members", subject_id: `user:${uid(2)}` }); + assert.deepEqual(roleMemberTuple("editor", "group:eng"), { namespace: "Role", object: "editor", relation: "members", subject_set: { namespace: "Group", object: "eng", relation: "members" } }); + for (const bad of ["", "user:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(roleMemberTuple("editor", bad), null, bad); +}); + +test("expandToEffectiveUsers flattens an expand tree → sorted distinct user ids, transitive through groups", () => { + // The subject rides on each node's `tuple` (Keto v26.2.0 shape, verified live). + const leaf = (n: number): ExpandTree => ({ tuple: { namespace: "", object: "", relation: "", subject_id: `user:${uid(n)}` }, type: "leaf" }); + const tree: ExpandTree = { + children: [ + leaf(1), // direct + { + children: [leaf(2), leaf(1)], // via group + dup + tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Group", object: "eng", relation: "members" } }, // a member group, not a user + type: "union", + }, + ], + tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Role", object: "admin", relation: "members" } }, + type: "union", + }; + assert.deepEqual(expandToEffectiveUsers(tree), [uid(1), uid(2)]); + assert.deepEqual(expandToEffectiveUsers(null), []); + assert.deepEqual(expandToEffectiveUsers({ type: "leaf" }), []); // an empty role +}); + +test("buildRolesListModel filters by search, sorts, paginates; the name links to the detail page", () => { + const roles = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `role-${String(i).padStart(2, "0")}` })); + + const all = buildRolesListModel({ roles, url: "http://x/admin/roles" }); + assert.equal(all.pagination.summary.total, 30); + assert.equal(all.table.rows.length, 25); // default page size + assert.equal(all.shell.title, "Roles"); + const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } }; + assert.equal(first.rowHeader.text, "role-00"); + assert.equal(first.rowHeader.href, "/admin/roles/role-00"); + + const one = buildRolesListModel({ roles, url: "http://x/admin/roles?q=role-07" }); + assert.equal(one.pagination.summary.total, 1); + assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]); + + const desc = buildRolesListModel({ roles, url: "http://x/admin/roles?sort=-members" }); + assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "role-29"); +}); + +test("buildRoleFormModel: a create form with a required name field + member options (user or group)", () => { + const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }]; + const m = buildRoleFormModel({ csrfToken: "tok.sig", memberOptions: options }); + assert.equal(m.shell.title, "New role"); + assert.equal(m.form.action, "/admin/roles"); + assert.equal(m.form.submitLabel, "Create role"); + assert.equal(m.form.csrfToken, "tok.sig"); + assert.equal(m.form.nameField.required, true); + assert.deepEqual(m.form.memberOptions, options); + + const err = buildRoleFormModel({ error: "That name is taken.", memberOptions: options, values: { member: "group:eng", name: "Admin" } }); + assert.equal(err.error, "That name is taken."); + assert.equal(err.form.nameField.value, "Admin"); + assert.equal(err.form.selectedMember, "group:eng"); +}); + +test("buildRoleDetailModel: members → rows, add-options exclude current members, effective access listed, actions wired", () => { + const members = [memberView(userTuple("admin", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("admin", "eng"), new Map())]; + const candidates = [ + { label: "ada@example.com", value: `user:${uid(1)}` }, // already a member → excluded + { label: "grace@example.com", value: `user:${uid(2)}` }, + { label: "eng (group)", value: "group:eng" }, // already a member → excluded + { label: "ops (group)", value: "group:ops" }, + ]; + const effective = [{ label: "ada@example.com" }, { label: "grace@example.com" }]; // ada direct, grace via eng + const m = buildRoleDetailModel({ candidates, effective, members, role: { name: "admin" } }); + assert.equal(m.shell.title, "admin"); + assert.equal(m.members.rows.length, 2); + assert.equal(m.members.action, "/admin/roles/admin/members/delete"); + assert.equal(m.add.action, "/admin/roles/admin/members"); + assert.deepEqual(m.add.options.map((o) => o.value), [`user:${uid(2)}`, "group:ops"]); + assert.deepEqual(m.effective.map((e) => e.label), ["ada@example.com", "grace@example.com"]); + assert.equal(m.delete.action, "/admin/roles/admin/delete"); +}); diff --git a/src/admin-roles.ts b/src/admin-roles.ts new file mode 100644 index 0000000..a750faa --- /dev/null +++ b/src/admin-roles.ts @@ -0,0 +1,375 @@ +// Built-in Roles & permissions admin screen (todo §5): list / create / delete Keto roles and assign +// them to users and groups. A role is a Keto subject set `Role:#members` (OPL: members are +// users or groups, resolved transitively) — the source of truth for the JWT `roles` claim. It shares +// 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 +// access** view: `keto.expand(Role:#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 +// projection reads only direct grants 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. +// `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 { + type GroupView, + groupsFromTuples, + isValidGroupName, + memberCandidates, + type MemberOption, + type MemberView, + memberView, + pagedTuples, + parseSubject, + safeDecode, +} from "./admin-groups.ts"; +import type { FieldConfig } from "./admin-users.ts"; +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 { ExpandTree, KetoClient, RelationTuple } from "./keto-client.ts"; +import type { KratosAdmin } from "./kratos-admin.ts"; +import { parseListQuery } from "./list-query.ts"; +import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; +import { paginate } from "./paginate.ts"; +import type { RouteResult } from "./plugin.ts"; +import { buildShellContext } from "./shell-context.ts"; + +const ROLE_NS = "Role"; +const MEMBERS = "members"; +const DEFAULT_PAGE_SIZE = 25; +const PAGE_SIZES = [25, 50, 100]; +// Expand far past any sane group-nesting depth so the effective-access view never silently +// under-reports the deepest members (Keto's own default is shallow). +const EXPAND_MAX_DEPTH = 50; + +// A role and a group share the URL-safe name rule and the user|group membership model. +export type RoleView = GroupView; +export const isValidRoleName = isValidGroupName; +export const rolesFromTuples = groupsFromTuples; +export interface EffectiveUser { + label: string; // email (or the raw id when unresolved) +} + +// The full membership tuple for assigning/revoking `value` to/from `role` (null if value is invalid). +export function roleMemberTuple(role: string, value: string): RelationTuple | null { + const subject = parseSubject(value); + return subject ? { namespace: ROLE_NS, object: role, relation: MEMBERS, ...subject } : null; +} + +// Flatten a Keto `expand` tree → the sorted, distinct user ids that effectively hold the role +// (direct leaves + users reached through member groups, any depth). The subject rides on each +// node's `tuple`; subject-set nodes (the groups) contribute nothing directly — their members +// surface as leaves under them. +export function expandToEffectiveUsers(tree: ExpandTree | null | undefined): string[] { + const ids = new Set(); + const walk = (node?: ExpandTree | null): void => { + if (!node) return; + const subjectId = node.tuple?.subject_id; + if (subjectId?.startsWith("user:")) ids.add(subjectId.slice("user:".length)); + node.children?.forEach(walk); + }; + walk(tree); + return [...ids].sort(); +} + +// ---- list view model ---- + +interface ListState { + page: number; + pageSize: number; + q: string; + sort: string | null; +} + +const SORT: Record number | string> = { + members: (r) => r.memberCount, + name: (r) => r.name, +}; +const COLUMNS = [ + { key: "name", label: "Role" }, + { key: "members", label: "Members" }, +]; + +function detailHref(name: string): string { + return `${ADMIN_ROLES_BASE}/${encodeURIComponent(name)}`; +} + +function listHref(state: ListState, overrides: Partial = {}): string { + const s = { ...state, ...overrides }; + const p = new URLSearchParams(); + if (s.q) p.set("q", s.q); + if (s.sort) p.set("sort", s.sort); + if (s.page > 1) p.set("page", String(s.page)); + if (s.pageSize !== DEFAULT_PAGE_SIZE) p.set("pageSize", String(s.pageSize)); + const qs = p.toString(); + return qs ? `${ADMIN_ROLES_BASE}?${qs}` : ADMIN_ROLES_BASE; +} + +export function buildRolesListModel(opts: { + csrfToken?: string; + menu?: MenuConfig; + roles: RoleView[]; + url: URL | URLSearchParams | string; + user?: User | null; +}) { + const menu = opts.menu ?? DEFAULT_MENU; + const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE }); + const sort = query.sort && SORT[query.sort.field] ? query.sort : null; + const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null; + const needle = query.q.toLowerCase(); + + let list = opts.roles.filter((r) => !needle || r.name.toLowerCase().includes(needle)); + if (sort) { + const get = SORT[sort.field]!; + const dir = sort.dir === "desc" ? -1 : 1; + list = [...list].sort((a, b) => { + const av = get(a), bv = get(b); + const cmp = typeof av === "number" && typeof bv === "number" ? av - bv : String(av).localeCompare(String(bv)); + return cmp * dir; + }); + } + + const page = paginate(list.length, query.page, query.pageSize, { boundaries: 1, siblings: 1 }); + const start = (page.page - 1) * page.pageSize; + const rows = list.slice(start, start + page.pageSize); + const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken }; + + return { + filterBar: listFilterBar(state), + nav: adminNav(opts.user?.roles ?? [], menu, "roles"), + pagination: listPagination(state, page), + shell: buildShellContext({ + breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Admin" }, { label: "Roles" }], + csrfToken: opts.csrfToken ?? "", + menu, + title: "Roles", + user: opts.user ?? null, + }), + table: listTable(rows, state, sort), + }; +} + +function listTable(rows: RoleView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null) { + return { + caption: "Roles", + columns: COLUMNS.map((c) => { + const dir = sort && sort.field === c.key ? sort.dir : undefined; + const next = dir === "asc" ? `-${c.key}` : c.key; + return { href: listHref(state, { page: 1, sort: next }), label: c.label, sort: dir, sortable: true }; + }), + rows: rows.map((r) => ({ + cells: [{ rowHeader: { href: detailHref(r.name), text: r.name } }, String(r.memberCount)], + name: r.name, + })), + }; +} + +function listFilterBar(state: ListState) { + const pills: { label: string; remove: string; value: string }[] = []; + if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q }); + return { + applyLabel: "Apply", + clearHref: ADMIN_ROLES_BASE, + label: "Filter roles", + pills, + rows: [[ + { label: "Search roles", name: "q", placeholder: "Search role name…", type: "search", value: state.q }, + { type: "spacer" }, + ]], + }; +} + +function listPagination(state: ListState, page: ReturnType) { + const hidden: { name: string; value: string }[] = []; + if (state.q) hidden.push({ name: "q", value: state.q }); + if (state.sort) hidden.push({ name: "sort", value: state.sort }); + return { + label: "Roles pagination", + next: { href: page.next ? listHref(state, { page: page.next }) : undefined }, + pages: page.pages.map((p) => + p.ellipsis ? { ellipsis: true } + : p.current ? { current: true, label: String(p.page) } + : { href: listHref(state, { page: p.page as number }), label: String(p.page) }), + prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined }, + rows: { hidden, label: "Rows", name: "pageSize", options: PAGE_SIZES, submitLabel: "Go", value: state.pageSize }, + summary: { from: page.from, to: page.to, total: page.total }, + }; +} + +// ---- create form + detail view models ---- + +export function buildRoleFormModel(opts: { + csrfToken?: string; + error?: string; + memberOptions: MemberOption[]; + menu?: MenuConfig; + user?: User | null; + values?: { member?: string; name?: string }; +}) { + const menu = opts.menu ?? DEFAULT_MENU; + const nameField: FieldConfig = { + autocomplete: "off", hint: "Lowercase letters, digits, dashes and underscores.", icon: "i-shield", + id: "name", label: "Role name", name: "name", required: true, value: opts.values?.name ?? "", + }; + return { + error: opts.error, + form: { + action: ADMIN_ROLES_BASE, + cancelHref: ADMIN_ROLES_BASE, + csrfToken: opts.csrfToken ?? "", + memberOptions: opts.memberOptions, + nameField, + selectedMember: opts.values?.member ?? "", + submitLabel: "Create role", + }, + nav: adminNav(opts.user?.roles ?? [], menu, "roles"), + shell: buildShellContext({ + breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: "New" }], + csrfToken: opts.csrfToken ?? "", + menu, + title: "New role", + user: opts.user ?? null, + }), + }; +} + +export function buildRoleDetailModel(opts: { + candidates: MemberOption[]; + csrfToken?: string; + effective: EffectiveUser[]; + error?: string; + members: MemberView[]; + menu?: MenuConfig; + role: { name: string }; + user?: User | null; +}) { + const menu = opts.menu ?? DEFAULT_MENU; + const name = opts.role.name; + const base = detailHref(name); + const taken = new Set(opts.members.map((m) => m.subject)); + const options = opts.candidates.filter((c) => !taken.has(c.value)); // members are users/groups, never the role itself + return { + add: { action: `${base}/members`, options }, + csrfToken: opts.csrfToken ?? "", + delete: { action: `${base}/delete` }, + effective: opts.effective, + error: opts.error, + members: { action: `${base}/members/delete`, rows: opts.members }, + nav: adminNav(opts.user?.roles ?? [], menu, "roles"), + role: { name }, + shell: buildShellContext({ + breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: name }], + csrfToken: opts.csrfToken ?? "", + menu, + title: name, + user: opts.user ?? null, + }), + }; +} + +// ---- request handler (imperative shell) ---- + +export interface AdminRolesDeps { + csrfSecret: string; + keto: KetoClient; + kratosAdmin: KratosAdmin; + menu: MenuConfig; + render: (view: string, data: Record) => Promise; +} + +// A role exists exactly while it has ≥1 member (Keto has no create-object). +async function roleExists(keto: KetoClient, name: string): Promise { + const page = await keto.listRelations({ namespace: ROLE_NS, object: name, relation: MEMBERS, pageSize: 1 }); + return page.tuples.length > 0; +} + +// The distinct users who effectively hold the role (expand → flatten → label by email). Skipped for +// an empty role (no member tuples) so we don't expand a non-existent Keto object. +async function effectiveUsers(keto: KetoClient, name: string, hasMembers: boolean, emailById: Map): Promise { + if (!hasMembers) return []; + const tree = await keto.expand({ namespace: ROLE_NS, object: name, relation: MEMBERS }, { maxDepth: EXPAND_MAX_DEPTH }); + return expandToEffectiveUsers(tree) + .map((id) => ({ label: emailById.get(id) ?? `user:${id}` })) + .sort((a, b) => a.label.localeCompare(b.label)); +} + +export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, deps: AdminRolesDeps): Promise { + const path = ctx.url.pathname; + if (path !== ADMIN_ROLES_BASE && !path.startsWith(`${ADMIN_ROLES_BASE}/`)) return null; + + if (!ctx.user) throw new GuardError(401, "authentication required", "/login"); + if (!ctx.roles.includes(ADMIN_PERMISSION)) throw new GuardError(403, "admin role required"); + + const { keto, kratosAdmin, menu, render } = deps; + const user = ctx.user; + const method = (ctx.req.method ?? "GET").toUpperCase(); + const seg = path.slice(ADMIN_ROLES_BASE.length).split("/").filter(Boolean); + + 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 => { + const roles = rolesFromTuples(await pagedTuples(keto, { namespace: ROLE_NS, relation: MEMBERS })); + return { html: await render("admin/roles", { model: buildRolesListModel({ csrfToken, menu, roles, url: ctx.url, user }) }) }; + }; + const renderForm = async (extra: { error?: string; values?: { member?: string; name?: string } }): Promise => { + const { options } = await memberCandidates(keto, kratosAdmin); + return { html: await render("admin/role-form", { model: buildRoleFormModel({ csrfToken, memberOptions: options, menu, user, ...extra }) }) }; + }; + const renderDetail = async (name: string): Promise => { + const { emailById, options } = await memberCandidates(keto, kratosAdmin); + const tuples = await pagedTuples(keto, { namespace: ROLE_NS, object: name, relation: MEMBERS }); + const members = tuples.map((t) => memberView(t, 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 }) }) }; + }; + + // /admin/roles — list (GET) · create (POST) + if (seg.length === 0) { + if (method === "GET") return renderList(); + if (method === "POST") { + const name = (form!.get("name") ?? "").trim(); + const tuple = roleMemberTuple(name, (form!.get("member") ?? "").trim()); + const reject = (error: string): Promise => + renderForm({ error, values: { member: form!.get("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); + return { redirect: detailHref(name) }; + } + return null; + } + + // /admin/roles/new — create form + if (seg.length === 1 && seg[0] === "new" && method === "GET") return renderForm({}); + + // /admin/roles/:name … + const name = safeDecode(seg[0]!); + if (name === null || !isValidRoleName(name)) return { html: await render("404", { title: "Not found" }), status: 404 }; + const base = detailHref(name); + + 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 + return { redirect: base }; + } + if (seg.length === 2 && seg[1] === "delete" && method === "POST") { + await keto.deleteTuple({ namespace: ROLE_NS, object: name, relation: MEMBERS }); // removes every member tuple + return { redirect: ADMIN_ROLES_BASE }; + } + if (seg.length === 3 && seg[1] === "members" && seg[2] === "delete" && method === "POST") { + const tuple = roleMemberTuple(name, (form!.get("member") ?? "").trim()); + if (tuple) await keto.deleteTuple(tuple); + return { redirect: base }; + } + return null; +} diff --git a/src/app.test.ts b/src/app.test.ts index 421daf2..71e1679 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -10,7 +10,7 @@ import { createApp } from "./app.ts"; import { CSRF_COOKIE, issueCsrfToken } from "./csrf.ts"; import { can, check, GuardError, requireSession } from "./guards.ts"; import { staticJwks } from "./jwks.ts"; -import type { KetoClient, RelationTuple, SubjectSet } from "./keto-client.ts"; +import type { ExpandTree, KetoClient, RelationTuple, SubjectSet } from "./keto-client.ts"; import type { Identity, KratosAdmin } from "./kratos-admin.ts"; import { KratosError, type Flow, type FlowType, type KratosPublic, type Session, type UiNode } from "./kratos-public.ts"; import { SESSION_COOKIE } from "./login.ts"; @@ -616,6 +616,102 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g assert.equal((await get("/admin/groups/%ZZ")).status, 404); }); +// Built-in Roles & permissions admin screen (§5): gate + list/create/assign/revoke/delete over HTTP +// against a fake in-memory Keto whose `expand` mirrors Keto's transitive resolution, so the +// effective-access view surfaces a user reachable only through a group. +test("admin Roles screen: gate, list, create, assign user/group, effective access (expand), revoke, delete", async (t) => { + const ada = randomUUID(); + const grace = randomUUID(); + const identities: Identity[] = [ + { 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. + const tuples: RelationTuple[] = [ + { namespace: "Group", object: "eng", relation: "members", subject_id: `user:${grace}` }, + { namespace: "Role", object: "editor", relation: "members", subject_id: `user:${ada}` }, + ]; + // Mirror Keto's expand shape: the subject rides on `tuple`, set nodes carry members as children. + const expandSet = (set: SubjectSet): ExpandTree => ({ + children: tuples + .filter((tp) => tp.namespace === set.namespace && tp.object === set.object && tp.relation === set.relation) + .map((tp) => (tp.subject_id ? { tuple: { namespace: "", object: "", relation: "", subject_id: tp.subject_id }, type: "leaf" } : expandSet(tp.subject_set!))), + tuple: { namespace: "", object: "", relation: "", subject_set: set }, + type: "union", + }); + const keto: KetoClient = { + check: async () => false, + deleteTuple: async (f) => { for (let i = tuples.length - 1; i >= 0; i--) if (matchesTuple(tuples[i]!, f)) tuples.splice(i, 1); }, + expand: async (set) => expandSet(set), + listRelations: async (q = {}) => ({ nextPageToken: null, tuples: tuples.filter((tp) => matchesTuple(tp, q)) }), + writeTuple: async (tp) => { if (!tuples.some((t) => matchesTuple(t, tp) && sameSet(t.subject_set, tp.subject_set))) tuples.push(tp); }, + }; + const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) }); + const csrfSecret = "roles-secret"; + const app = createApp({ csrfSecret, jwks: staticJwks([ecJwk]), keto, kratosAdmin }); + await new Promise((r) => app.listen(0, r)); + t.after(() => app.close()); + const url = `http://localhost:${(app.address() as AddressInfo).port}`; + const nowSec = Math.floor(Date.now() / 1000); + const token = issueCsrfToken(csrfSecret); + 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 post = (path: string, body: string) => + fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(["admin"]) }, method: "POST", redirect: "manual" }); + + // Gate: anonymous → /login; a signed-in non-admin → 403. + const anon = await fetch(url + "/admin/roles", { redirect: "manual" }); + assert.equal(anon.status, 303); + assert.equal(anon.headers.get("location"), "/login"); + assert.equal((await get("/admin/roles", [])).status, 403); + + // 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"/); + + // 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=user:${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 === `user:${ada}`)); + + // 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=user:${ada}`)).status, 400); + assert.equal((await post("/admin/roles", `_csrf=${token}&name=editor&member=user:${ada}`)).status, 400); // already exists + assert.equal((await post("/admin/roles", `name=x&member=user:${ada}`)).status, 403); + assert.equal(tuples.length, before); + + // Detail: ada (direct) is in the effective-access list; grace (only reachable via a group) is not + // yet — though grace appears elsewhere as an assignable candidate, so target the effective
  • . + const effectiveLi = (email: string) => new RegExp(`
  • ${email.replace(".", "\\.")}`); + const detail = await (await get("/admin/roles/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(); + 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")); + + // Delete the role: removes every member tuple, back to the list. + const del = await post("/admin/roles/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")); + + // 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); +}); + test("resolveStaticPath blocks traversal and control chars, allows nested files", () => { assert.equal(resolveStaticPath("/srv/public", "../app.ts"), null); assert.equal(resolveStaticPath("/srv/public", "a\x00b"), null); diff --git a/src/app.ts b/src/app.ts index 33594e8..16abb39 100644 --- a/src/app.ts +++ b/src/app.ts @@ -3,8 +3,9 @@ import { createServer, type Server, type ServerResponse } from "node:http"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import * as ejs from "ejs"; -import { ADMIN_GROUPS_BASE, ADMIN_USERS_BASE } from "./admin-nav.ts"; +import { ADMIN_GROUPS_BASE, ADMIN_ROLES_BASE, ADMIN_USERS_BASE } from "./admin-nav.ts"; import { type AdminGroupsDeps, handleAdminGroups } from "./admin-groups.ts"; +import { type AdminRolesDeps, handleAdminRoles } from "./admin-roles.ts"; import { type AdminUsersDeps, handleAdminUsers } from "./admin-users.ts"; import { readFormBody } from "./body.ts"; import { buildContext, type User } from "./context.ts"; @@ -79,6 +80,7 @@ export function createApp(options: AppOptions = {}): Server { // Users writes to Kratos; Groups writes to Keto and reads users from Kratos for the pickers. const adminDeps: AdminUsersDeps | null = kratosAdmin ? { csrfSecret, kratosAdmin, menu, render } : null; const adminGroupsDeps: AdminGroupsDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render } : null; + const adminRolesDeps: AdminRolesDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render } : null; const sendHtml = (res: ServerResponse, status: number, html: string): void => { res.writeHead(status, { "content-type": "text/html; charset=utf-8" }); @@ -166,6 +168,14 @@ export function createApp(options: AppOptions = {}): Server { return; } } + if (adminRolesDeps && pathname.startsWith(ADMIN_ROLES_BASE)) { + const result = await handleAdminRoles(ctx, csrf.token, adminRolesDeps); + if (result) { + if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies })); + await sendResult(res, result, () => Promise.reject(new Error("admin screens return html, not view"))); + return; + } + } // Themed Kratos self-service pages (login/registration/recovery/verification/settings). const flowType = AUTH_FLOWS[pathname]; diff --git a/src/keto-client.test.ts b/src/keto-client.test.ts index 3a2bc54..a61269e 100644 --- a/src/keto-client.test.ts +++ b/src/keto-client.test.ts @@ -72,7 +72,7 @@ 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: [{ subject_id: USER, type: "leaf" }], 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: "Role", object: "admin", relation: "members" } }, type: "union" }; const { calls, fetchImpl } = recorder(() => res(200, tree)); const out = await keto(fetchImpl).expand({ namespace: "Role", object: "admin", relation: "members" }, { maxDepth: 3 }); assert.deepEqual(out, tree); diff --git a/src/keto-client.ts b/src/keto-client.ts index 592967e..3d6e297 100644 --- a/src/keto-client.ts +++ b/src/keto-client.ts @@ -30,12 +30,12 @@ export interface RelationList { tuples: RelationTuple[]; } -// Keto's expand tree: a node is a set operation (union/…) or a leaf, with the resolved -// subject(s). Shape kept loose — callers walk it as needed (§5 "effective access" view). +// Keto's expand tree: a node is a set operation (union/…) or a leaf. The resolved subject +// (subject_id xor subject_set) rides on `tuple`, not the node itself — verified against Keto +// v26.2.0. A `subject_set` node carries its members as `children` (§5 "effective access" view). export interface ExpandTree { children?: ExpandTree[]; - subject_id?: string; - subject_set?: SubjectSet; + tuple?: RelationTuple; type: string; } diff --git a/todo.md b/todo.md index 00888d2..3c15138 100644 --- a/todo.md +++ b/todo.md @@ -1,7 +1,5 @@ # Plainpages — implementation TODO -General instruction - always run the stability reviewer after each todo is done. - Build order is top → bottom; each phase is roughly independent and testable. Conventions: **write tests first** (node --test for units, Playwright for E2E), tear down test containers after runs, keep deps minimal, pin all versions, run @@ -97,7 +95,7 @@ everything via Docker. ## 5. Built-in admin screens (writes go only to Keto/Kratos) - [x] Users: list (Kratos identities) with filter/sort/pagination; create/edit/deactivate/delete; trigger recovery. → `src/admin-users.ts`: pure view-model + Kratos-payload builders (`toUserView`, `buildUsersListModel`, `buildUserFormModel`, `create/updateIdentityPayload`, `setStatePayload`) + `handleAdminUsers` (the imperative shell app.ts dispatches `/admin/users*` to). Routes: `GET /admin/users` (list — filter by q/status, sortable headers, paginate; in-memory over one fetched Kratos page since the admin API has no search/sort), `GET|POST /admin/users/new`+`/` (create), `GET|POST /admin/users/:id` (edit; email is the read-only login identifier, name editable, optional initial password), `POST …/:id/state` (deactivate↔reactivate), `…/delete`, `…/recovery` (mints a code via the new `kratosAdmin.createRecoveryCode` admin endpoint, renders the link). Writes go **only to Kratos** (README "stateless"). Gated **admin-only** (anonymous→/login, non-admin→403 via `GuardError`) and every mutation is **CSRF-guarded** (signed double-submit, like logout); reuses the §1 building blocks (filter-bar/data-table/pagination/field) around the app shell. Reviewer's §5 opener done too: extracted `src/shell-context.ts` (`buildShellContext`/`shellUser`) shared by the dashboard + admin screens — kills the hardcoded "Sam Rivers" demo profile, threads the **real** signed-in user (email/derived initials; anonymous→Guest); `dashboard.ts` + `app.ts` now pass `ctx.user`. Added `readonly` to `field.ejs`, `admin` to `RESERVED_PLUGIN_IDS` (a plugin folder can't shadow the screens), `views:[viewsDir]` to the core renderer (so a subfolder view includes the shared `partials/` by root-relative name). Tests-first: `admin-users.test.ts` (mapping/selection/payload matrix), `app.test.ts` HTTP integration (gate/list-filter/create/edit/state/delete/recovery + CSRF reject), `shell-context.test.ts`, `kratos-admin.test.ts` (recovery endpoint), `discovery.test.ts` (reserved `admin`). typecheck + 228 units + 8 visual E2E green. Boot-verified live on the full Ory stack: seeded-admin login → JWT `roles:["admin"]` → `/admin/users` lists identities; create→303→listed, recovery→real Kratos code/link, state→inactive, delete→absent, forged CSRF→403; torn down. Groups/roles/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:#members`; a member is a user (`subject_id=user:`) or a nested group (`subject_set=Group:#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` `` 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. -- [ ] Roles & permissions: Keto relations — assign roles to users/groups; "effective access" view via Keto expand. +- [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:#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:#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. - [ ] Wire into the menu (admin section, permission-gated). - [ ] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. - [ ] 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. diff --git a/views/admin/role-detail.ejs b/views/admin/role-detail.ejs new file mode 100644 index 0000000..773cc86 --- /dev/null +++ b/views/admin/role-detail.ejs @@ -0,0 +1,16 @@ +<%# + Role admin detail page (todo §5): the role-detail body (members · effective access) in the shell. +%><% + const nav = include("partials/nav-tree", { nodes: model.nav }); + const body = include("partials/role-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, effective: model.effective, error: model.error, members: model.members, role: model.role }); +-%> +<%- 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, +}) %> diff --git a/views/admin/role-form.ejs b/views/admin/role-form.ejs new file mode 100644 index 0000000..653bbfe --- /dev/null +++ b/views/admin/role-form.ejs @@ -0,0 +1,16 @@ +<%# + Role admin create page (todo §5): the role-form body captured into the app shell. +%><% + const nav = include("partials/nav-tree", { nodes: model.nav }); + const body = include("partials/role-form-body", { error: model.error, form: model.form }); +-%> +<%- 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, +}) %> diff --git a/views/admin/roles.ejs b/views/admin/roles.ejs new file mode 100644 index 0000000..76648fc --- /dev/null +++ b/views/admin/roles.ejs @@ -0,0 +1,21 @@ +<%# + Roles admin list (todo §5): the same building blocks as the Groups screen, around the shell, backed + by live Keto Role subject sets (src/admin-roles.ts). Filter/sort/page round-trip the URL. +%><% + const nav = include("partials/nav-tree", { nodes: model.nav }); + const filters = include("partials/filter-bar", model.filterBar); + const table = include("partials/data-table", model.table); + const pager = include("partials/pagination", model.pagination); + const actions = 'Add role'; +-%> +<%- include("partials/shell", { + actions, + body: filters + table + pager, + 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, +}) %> diff --git a/views/partials/role-detail-body.ejs b/views/partials/role-detail-body.ejs new file mode 100644 index 0000000..66c6469 --- /dev/null +++ b/views/partials/role-detail-body.ejs @@ -0,0 +1,57 @@ +<%# + Admin role detail body (todo §5), captured into the shell content slot. Config: + role { name } + members { action, rows: { kind:"group"|"user", label, subject }[] } action = revoke endpoint + effective { label }[] users who hold the role (expand) + add { action, options: {label,value}[] } action = assign endpoint + del { action } delete the whole role + csrfToken, error? +%><% + const role = locals.role; + const members = locals.members; + const effective = locals.effective; + const add = locals.add; + const del = locals.del; + const csrf = locals.csrfToken; +-%> +
    +<% if (locals.error) { -%> +<%- include("alert", { text: locals.error, tone: "neg" }) %> +<% } -%> +
    +

    Assigned to

    +<% if (members.rows.length) { -%> +
    +<% members.rows.forEach((m) => { -%> + +<% }) -%> +
    Members of <%= role.name %>
    MemberTypeActions
    <%= m.label %><%= m.kind === "group" ? "Group" : "User" %>
    +<% } else { -%> +

    Not assigned to anyone yet.

    +<% } -%> +
    +
    +

    Effective access

    +

    Everyone who holds this role — directly or through a group (resolved by Keto).

    +<% if (effective.length) { -%> +
      +<% effective.forEach((u) => { -%> +
    • <%= u.label %>
    • +<% }) -%> +
    +<% } else { -%> +

    No users hold this role yet.

    +<% } -%> +
    +
    +

    Assign the role

    +<% if (add.options.length) { -%> +
    +<% } else { -%> +

    All users and groups already have this role.

    +<% } -%> +
    +
    +
    +
    +
    diff --git a/views/partials/role-form-body.ejs b/views/partials/role-form-body.ejs new file mode 100644 index 0000000..f679996 --- /dev/null +++ b/views/partials/role-form-body.ejs @@ -0,0 +1,26 @@ +<%# + Admin role create form body (todo §5), captured into the shell content slot. Config: + form { action, csrfToken, submitLabel, cancelHref, nameField: field.ejs config, + memberOptions: {label,value}[], selectedMember } + error? string shown when a write was rejected +%><% + const form = locals.form; +-%> +
    +<% if (locals.error) { -%> +<%- include("alert", { text: locals.error, tone: "neg" }) %> +<% } -%> +
    + + <%- include("field", form.nameField) %> +
    + + + A role exists once assigned; add more users or groups after creating it. +
    +
    + Cancel + +
    +
    +