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

This commit is contained in:
2026-08-05 12:45:06 +02:00
parent 9412b90946
commit 27fee5f8a3
23 changed files with 388 additions and 180 deletions
+14 -7
View File
@@ -10,8 +10,8 @@ cp -r examples/plugins/admin plugins/admin
docker compose restart web
```
The seeded `admin@plainpages.local` already holds the `admin` permission, so the section appears in the
menu and the screens work immediately.
The bootstrap grants the seeded `admin@plainpages.local` every permission this plugin declares, so the
section appears in the menu and the screens work immediately.
Every string it renders comes from its own catalogs (`i18n/en-US.ts`, `i18n/sv-SE.ts`) — the nav
labels included, which are catalog keys in `admin-shared.ts`. Each pure view-model builder takes an
@@ -33,17 +33,24 @@ stack**, so they use the privileged **`ctx.system`** surface the host exposes to
`ctx.system` is populated only when the host wired those services (the dev stack wires Kratos + Keto,
and Hydra when configured). Where a capability is absent the screen degrades to a themed 503 rather
than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin: folder-discovered,
gated per route by `permission: "admin"`, rendering the core building blocks in `views/`.
gated per route by its screen's `<resource>:<action>` permission, rendering the core building blocks
in `views/`.
Each screen is its own resource — `users`, `groups`, `permissions`, `oauth2-clients` — and each
splits into `:read` and `:write`, so a helpdesk account can be given `users:read` alone. The nav is
filtered by the same permissions: holding none of the four hides the Admin section entirely.
## Layout
- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` permission, and the
route table — one thin handler per method+path, all gated by `permission: "admin"`.
- `plugin.ts` — the manifest: the Admin nav fragment, the eight permissions the plugin declares, and
the route table — one thin handler per method+path, gated via `adminPermission(resource, method)`
so a GET needs `:read` and a POST `:write`.
- `admin-users.ts` · `admin-groups.ts` · `admin-permissions.ts` · `admin-clients.ts` — each a set of pure
view-model builders (unit-tested in the matching `*.test.ts`) plus thin per-route handlers keyed on
`ctx.params` (the host extracts `:id`/`:name`), sharing a small `withX` wrapper that resolves the
admin gate + the needed `ctx.system` clients once.
- `admin-shared.ts` — the shared gate (`requireAdmin`), CSRF form reader (`guardedForm`), confirm
screen's permission gate + the needed `ctx.system` clients once.
- `admin-shared.ts` — the permission naming (`adminPermission`), the shared gate
(`requirePermission`), CSRF form reader (`guardedForm`), confirm
model, nav fragment, and the not-found / unavailable helpers.
- `views/` — the screens' EJS, plus the admin-specific body partials under `views/partials/`. They
`include()` the core building-block partials (shell, data-table, filter-bar, field, …).
+2 -2
View File
@@ -6,7 +6,7 @@
// per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded.
import { type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api";
import { ADMIN_CLIENTS_BASE, ADMIN_EN, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
import { ADMIN_CLIENTS_BASE, ADMIN_EN, buildConfirmModel, guardedForm, notFound, requirePermission, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts";
const DEFAULT_PAGE_SIZE = 25;
@@ -245,7 +245,7 @@ interface ClientsDeps { ctx: RequestContext; hydra: HydraAdmin; user: User; }
function withClients(inner: (deps: ClientsDeps) => Promise<RouteResult>): RouteHandler {
return async (ctx) => {
const user = requireAdmin(ctx);
const user = requirePermission(ctx, "oauth2-clients");
const hydra = ctx.system?.hydra;
if (!hydra) return unavailable(ctx, ctx.t("admin.capability.hydra"));
return inner({ ctx, hydra, user });
+5 -4
View File
@@ -7,7 +7,7 @@
// each returning a RouteResult.
import { type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "#plugin-api";
import { ADMIN_EN, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
import { ADMIN_EN, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, requirePermission, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts";
const GROUP_NS = "Group";
@@ -285,13 +285,14 @@ async function groupExists(keto: KetoClient, name: string): Promise<boolean> {
return page.tuples.length > 0;
}
// Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate + the Keto and
// Kratos capabilities (else a themed 503). Each route below is a thin handler over these.
// Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate (`groups:read` on
// a GET, `groups:write` on a POST) + the Keto and Kratos capabilities (else a themed 503). Each route
// below is a thin handler over these.
interface GroupsDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; user: User; }
function withGroups(inner: (deps: GroupsDeps) => Promise<RouteResult>): RouteHandler {
return async (ctx) => {
const user = requireAdmin(ctx);
const user = requirePermission(ctx, "groups");
const keto = ctx.system?.keto;
const kratosAdmin = ctx.system?.kratosAdmin;
if (!keto || !kratosAdmin) return unavailable(ctx, ctx.t("admin.capability.keto"));
@@ -11,7 +11,8 @@ import {
buildPermissionFormModel,
buildPermissionsListModel,
expandToEffectiveUsers,
isValidRoleName,
isPermissionPathSegment,
isValidPermissionName,
permissionGrantTuple,
} from "./admin-permissions.ts";
import type { ExpandTree, RelationTuple } from "#plugin-api";
@@ -22,13 +23,25 @@ const userTuple = (permission: string, n: number): RelationTuple =>
const groupTuple = (permission: string, group: string): RelationTuple =>
({ namespace: "Permission", object: permission, relation: "granted", subject_set: { namespace: "Group", object: group, relation: "members" } });
test("isValidRoleName + permissionGrantTuple map the form value to a Permission 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);
test("isValidPermissionName requires <resource>:<action> so the convention holds for anything created here", () => {
for (const ok of ["users:read", "scheduling:write", "oauth2-clients:read", "team-a:a1_b9"]) assert.equal(isValidPermissionName(ok), true, ok);
// A bare word is what this rule exists to stop — "admin" says who you are, not what you may do.
for (const bad of ["admin", "", "Users:read", "users:", ":read", "users:read:extra", "a b:read", "-bad:read", `${"a".repeat(60)}:read`]) {
assert.equal(isValidPermissionName(bad), false, bad);
}
});
assert.deepEqual(permissionGrantTuple("editor", `user:${uid(2)}`), { namespace: "Permission", object: "editor", relation: "granted", subject_id: `user:${uid(2)}` });
assert.deepEqual(permissionGrantTuple("editor", "group:eng"), { namespace: "Permission", object: "editor", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
for (const bad of ["", "user:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(permissionGrantTuple("editor", bad), null, bad);
test("isPermissionPathSegment stays loose enough to address a permission that predates the rule", () => {
// Addressing is not creating: an "admin" tuple left in Keto must still open and delete, or it is
// stranded. It only has to be a safe URL/Keto object name.
for (const ok of ["admin", "users:read", "legacy_name"]) assert.equal(isPermissionPathSegment(ok), true, ok);
for (const bad of ["", "Admin", "a b", "-bad", "a/b", "a".repeat(65)]) assert.equal(isPermissionPathSegment(bad), false, bad);
});
test("permissionGrantTuple maps the form value to a Permission tuple over a user/group (else null)", () => {
assert.deepEqual(permissionGrantTuple("things:read", `user:${uid(2)}`), { namespace: "Permission", object: "things:read", relation: "granted", subject_id: `user:${uid(2)}` });
assert.deepEqual(permissionGrantTuple("things:read", "group:eng"), { namespace: "Permission", object: "things:read", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
for (const bad of ["", "user:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(permissionGrantTuple("things:read", bad), null, bad);
});
test("expandToEffectiveUsers flattens an expand tree → sorted distinct user ids, transitive through groups", () => {
@@ -87,7 +100,7 @@ test("buildPermissionFormModel: a create form with a required name field + membe
});
test("buildPermissionDetailModel: 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 members = [memberView(userTuple("users:read", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("users:read", "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)}` },
@@ -95,12 +108,14 @@ test("buildPermissionDetailModel: members → rows, add-options exclude current
{ label: "ops (group)", value: "group:ops" },
];
const effective = [{ label: "ada@example.com" }, { label: "grace@example.com" }]; // ada direct, grace via eng
const m = buildPermissionDetailModel({ candidates, effective, members, permission: { name: "admin" } });
assert.equal(m.title, "admin");
const m = buildPermissionDetailModel({ candidates, effective, members, permission: { name: "users:read" } });
assert.equal(m.title, "users:read");
assert.equal(m.members.rows.length, 2);
assert.equal(m.members.action, "/admin/permissions/admin/members/delete");
assert.equal(m.add.action, "/admin/permissions/admin/members");
// Every permission name now carries a colon, so the percent-encoding in these action URLs is
// load-bearing: the host's router decodes the segment back to "users:read" for ctx.params.
assert.equal(m.members.action, "/admin/permissions/users%3Aread/members/delete");
assert.equal(m.add.action, "/admin/permissions/users%3Aread/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/permissions/admin/delete");
assert.equal(m.delete.action, "/admin/permissions/users%3Aread/delete");
});
+30 -14
View File
@@ -9,11 +9,10 @@
// ctx.params) over a shared `withRoles` gate — admin-only, CSRF-guarded.
import { type ExpandTree, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api";
import { ADMIN_EN, ADMIN_PERMISSION, ADMIN_PERMISSIONS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
import { ADMIN_EN, ADMIN_PERMISSIONS_BASE, adminPermission, buildConfirmModel, guardedForm, notFound, requirePermission, unavailable } from "./admin-shared.ts";
import {
type GroupView,
groupsFromTuples,
isValidGroupName,
memberCandidates,
type MemberOption,
type MemberView,
@@ -25,16 +24,33 @@ import type { FieldConfig } from "./admin-users.ts";
const PERMISSION_NS = "Permission";
const GRANTED = "granted";
// The one irreversible move on this screen: delete this permission, or revoke your own grant of it,
// and nobody can grant anything ever again. Guarded like the `admin` permission it replaces.
const LOCKOUT_PERMISSION = adminPermission("permissions", "POST");
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 permission and a group share the URL-safe name rule and the user|group membership model.
// A permission and a group share the user|group membership model, but not the name rule: a
// permission is `<resource>:<action>` (README → Users, groups & permissions).
export type PermissionView = GroupView;
export const isValidRoleName = isValidGroupName;
export const permissionsFromTuples = groupsFromTuples;
const PERMISSION_NAME = /^[a-z0-9][a-z0-9_-]*:[a-z0-9][a-z0-9_-]*$/;
const PERMISSION_SEGMENT = /^[a-z0-9][a-z0-9_:-]*$/;
// Creating one enforces the convention, so it holds going forward.
export function isValidPermissionName(name: string): boolean {
return name.length <= 64 && PERMISSION_NAME.test(name);
}
// Addressing one only has to recognise a name Keto can already hold: a permission written before
// this rule — or by another tool — stays viewable and deletable instead of 404ing out of reach.
export function isPermissionPathSegment(name: string): boolean {
return name.length <= 64 && PERMISSION_SEGMENT.test(name);
}
export interface EffectiveUser {
label: string; // email (or the raw id when unresolved)
}
@@ -266,7 +282,7 @@ interface RolesDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: Kratos
function withRoles(inner: (deps: RolesDeps) => Promise<RouteResult>): RouteHandler {
return async (ctx) => {
const user = requireAdmin(ctx);
const user = requirePermission(ctx, "permissions");
const keto = ctx.system?.keto;
const kratosAdmin = ctx.system?.kratosAdmin;
if (!keto || !kratosAdmin) return unavailable(ctx, ctx.t("admin.capability.keto"));
@@ -278,7 +294,7 @@ function withRoles(inner: (deps: RolesDeps) => Promise<RouteResult>): RouteHandl
function withRoleName(inner: (deps: RolesDeps, name: string) => Promise<RouteResult>): RouteHandler {
return withRoles((deps) => {
const name = deps.ctx.params["name"] ?? "";
if (!isValidRoleName(name)) return Promise.resolve(notFound(deps.ctx));
if (!isPermissionPathSegment(name)) return Promise.resolve(notFound(deps.ctx));
return inner(deps, name);
});
}
@@ -312,7 +328,7 @@ export const rolesCreate = withRoles(async (deps) => {
const member = (form.get("member") ?? "").trim();
const tuple = permissionGrantTuple(name, member);
const reject = async (error: string): Promise<RouteResult> => ({ ...(await roleFormResult(deps, { error, values: { member, name } })), status: 400 });
if (!isValidRoleName(name)) return reject(ctx.t("admin.permissions.validation.name"));
if (!isValidPermissionName(name)) return reject(ctx.t("admin.permissions.validation.name"));
if (!tuple) return reject(ctx.t("admin.permissions.validation.member"));
if (await roleExists(keto, name)) return reject("A permission with that name already exists.");
await keto.writeTuple(tuple);
@@ -337,9 +353,9 @@ export const rolesAddMember = withRoleName(async (deps, name) => {
return { redirect: detailHref(name) };
});
// GET /admin/permissions/:name/delete — confirm, except the admin permission can't be deleted.
// GET /admin/permissions/:name/delete — confirm, except the lockout permission can't be deleted.
export const rolesDeleteConfirm = withRoleName((deps, name) => {
if (name === ADMIN_PERMISSION) return permissionDetailResult(deps, name, deps.ctx.t("admin.permissions.error.adminUndeletable"));
if (name === LOCKOUT_PERMISSION) return permissionDetailResult(deps, name, deps.ctx.t("admin.permissions.error.lockoutUndeletable"));
const base = detailHref(name);
const tt = deps.ctx.t;
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
@@ -350,24 +366,24 @@ export const rolesDeleteConfirm = withRoleName((deps, name) => {
});
// POST /admin/permissions/:name/delete — remove every member tuple (a whole-permission delete lags per the
// documented instant-revoke tradeoff; the admin permission is protected).
// documented instant-revoke tradeoff; the lockout permission is protected).
export const rolesDelete = withRoleName(async (deps, name) => {
const { ctx, keto, user } = deps;
await guardedForm(ctx); // CSRF-verify the POST
if (name === ADMIN_PERMISSION) return permissionDetailResult(deps, name, deps.ctx.t("admin.permissions.error.adminUndeletable"));
if (name === LOCKOUT_PERMISSION) return permissionDetailResult(deps, name, deps.ctx.t("admin.permissions.error.lockoutUndeletable"));
await keto.deleteTuple({ namespace: PERMISSION_NS, object: name, relation: GRANTED });
ctx.log.info("admin: permission deleted", { actor: user.id, permission: name });
return { redirect: ADMIN_PERMISSIONS_BASE };
});
// POST /admin/permissions/:name/members/delete — unassign; a *user* unassign revokes their live tokens.
// Self-protection: an admin can't revoke their own *direct* admin grant (a group-held admin isn't
// covered — the robust "last effective admin" check is deferred).
// Self-protection: you can't revoke your own *direct* grant of the lockout permission (a group-held
// one isn't covered — the robust "last effective holder" check is deferred).
export const rolesRemoveMember = withRoleName(async (deps, name) => {
const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!;
const member = (form.get("member") ?? "").trim();
if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return permissionDetailResult(deps, name, deps.ctx.t("admin.permissions.error.selfRevoke"));
if (name === LOCKOUT_PERMISSION && member === `user:${user.id}`) return permissionDetailResult(deps, name, deps.ctx.t("admin.permissions.error.selfRevoke"));
const tuple = permissionGrantTuple(name, member);
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: permission unassigned", { actor: user.id, member, permission: name }); }
return { redirect: detailHref(name) };
+29 -10
View File
@@ -1,5 +1,5 @@
// Direct units for the admin plugin's shared nav + auth helpers. They're security-critical
// (requireAdmin/guardedForm gate every admin write) and reused across all four screens, so pin the
// (requirePermission/guardedForm gate every admin write) and reused across all four screens, so pin the
// contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app.test.ts.
// Import only from the #plugin-api barrel — the same contract boundary the plugin code uses.
import assert from "node:assert/strict";
@@ -7,9 +7,10 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream";
import { test } from "node:test";
import { GuardError, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api";
import { ADMIN_EN, ADMIN_NAV, ADMIN_PERMISSION, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts";
import { ADMIN_EN, ADMIN_NAV, ADMIN_USERS_BASE, adminPermission, buildConfirmModel, guardedForm, requirePermission } from "./admin-shared.ts";
const admin: User = { email: "ada@x.io", id: "u1", permissions: ["admin"] };
const reader: User = { email: "ada@x.io", id: "u1", permissions: ["users:read"] };
const writer: User = { email: "cy@x.io", id: "u3", permissions: ["users:read", "users:write"] };
const member: User = { email: "bo@x.io", id: "u2", permissions: ["scheduling:read"] };
const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome;
@@ -26,24 +27,42 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver
// ---- nav fragment ----
test("ADMIN_NAV: a gated Admin header over the four screens; no per-request current/open state", () => {
test("ADMIN_NAV: an ungated Admin header whose four screens each gate on their own read permission", () => {
assert.equal(ADMIN_NAV.id, "admin");
assert.equal(ADMIN_NAV.permission, ADMIN_PERMISSION); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin
// No gate on the header: a user may hold one screen's permission and not another's. composeNav
// drops a header left with no visible children, so holding none of the four hides the section.
assert.equal(ADMIN_NAV.permission, undefined);
assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/permissions", "/admin/clients"]);
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "permissions:read", "oauth2-clients:read"]);
// Labels are catalog keys; the host translates them with this plugin's catalog when it composes
// the menu, so what a visitor sees is the en-US (or sv-SE …) wording behind these keys.
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["admin.nav.users", "admin.nav.groups", "admin.nav.permissions", "admin.nav.clients"]);
assert.deepEqual(ADMIN_NAV.children?.map((c) => ADMIN_EN(c.label)), ["Users", "Groups", "Permissions", "OAuth2 clients"]);
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.permission === undefined)); // the header's gate covers the subtree
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined));
});
// ---- permission naming ----
test("adminPermission builds <resource>:<action> — read for GET/HEAD, write for every mutation", () => {
assert.equal(adminPermission("users", "GET"), "users:read");
assert.equal(adminPermission("users", "HEAD"), "users:read"); // a GET route also answers HEAD
assert.equal(adminPermission("users", "POST"), "users:write");
assert.equal(adminPermission("groups", "DELETE"), "groups:write"); // anything that isn't a read is a write
assert.equal(adminPermission("oauth2-clients", "get"), "oauth2-clients:read"); // method case is the caller's
});
// ---- auth gates ----
test("requireAdmin: anonymous → 401→/login, signed-in non-admin → 403, admin → the user", () => {
assert.throws(() => requireAdmin(fakeCtx({ user: null })), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fadmin%2Fusers"); // bounce remembers the page
assert.throws(() => requireAdmin(fakeCtx({ user: member })), (e: unknown) => e instanceof GuardError && e.status === 403);
assert.equal(requireAdmin(fakeCtx({ user: admin })), admin);
test("requirePermission: anonymous → 401→/login, wrong permission → 403, and read never grants write", () => {
assert.throws(() => requirePermission(fakeCtx({ user: null }), "users"), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fadmin%2Fusers"); // bounce remembers the page
assert.throws(() => requirePermission(fakeCtx({ user: member }), "users"), (e: unknown) => e instanceof GuardError && e.status === 403);
assert.equal(requirePermission(fakeCtx({ user: reader }), "users"), reader);
// The whole point of the split: users:read opens the list but not the create/delete POSTs.
assert.throws(() => requirePermission(fakeCtx({ method: "POST", user: reader }), "users"), (e: unknown) => e instanceof GuardError && e.status === 403);
assert.equal(requirePermission(fakeCtx({ method: "POST", user: writer }), "users"), writer);
// Resources don't leak into each other: a users holder is not a groups holder.
assert.throws(() => requirePermission(fakeCtx({ user: writer }), "groups"), (e: unknown) => e instanceof GuardError && e.status === 403);
});
test("guardedForm: valid double-submit → the parsed body, bad token → 403, non-POST → undefined", async () => {
+26 -15
View File
@@ -11,36 +11,47 @@ import enUS from "./i18n/en-US.ts";
// ctx.t, which reads this catalog in the visitor's locale first, then the host's.
export const ADMIN_EN: Translate = englishTranslator(enUS);
export const ADMIN_PERMISSION = "admin"; // the permission gating the whole admin section
export const ADMIN_USERS_BASE = "/admin/users";
export const ADMIN_GROUPS_BASE = "/admin/groups";
export const ADMIN_PERMISSIONS_BASE = "/admin/permissions";
export const ADMIN_CLIENTS_BASE = "/admin/clients";
export type AdminScreen = "clients" | "groups" | "permissions" | "users";
// One resource per screen — the `<resource>` half of every permission this plugin gates on.
// `oauth2-clients` rather than `clients` because permission names are one global namespace.
export type AdminResource = "groups" | "oauth2-clients" | "permissions" | "users";
// The plugin's nav fragment: the gated "Admin" header + its four screens. The host composes it into
// the one global menu, filters per user (the header's `permission` drops the whole subtree for a
// non-admin), and current-marks the active item — so there is no `current`/`open` state here.
// `<resource>:<action>` (README → Users, groups & permissions). Every screen reads on GET/HEAD and
// mutates on POST, so the manifest's route table and the in-handler guard both derive the name here
// rather than each spelling it out — they cannot drift into gating on different permissions.
export function adminPermission(resource: AdminResource, method: string): string {
const verb = method.toUpperCase();
return `${resource}:${verb === "GET" || verb === "HEAD" ? "read" : "write"}`;
}
// The plugin's nav fragment: an ungated "Admin" header + its four screens, each gated on its own
// read permission. The header carries no `permission` because a user may hold one screen's and not
// another's; composeNav drops a header left with no visible children, so a user holding none of the
// four never sees the section. The host current-marks the active item — no `current`/`open` here.
export const ADMIN_NAV: NavNode = {
children: [
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users" },
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups" },
{ href: ADMIN_PERMISSIONS_BASE, icon: "i-shield", id: "permissions", label: "admin.nav.permissions" },
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients" },
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users", permission: "users:read" },
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups", permission: "groups:read" },
{ href: ADMIN_PERMISSIONS_BASE, icon: "i-shield", id: "permissions", label: "admin.nav.permissions", permission: "permissions:read" },
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients", permission: "oauth2-clients:read" },
],
icon: "i-shield",
id: "admin",
label: "admin.nav.section", // a key in this plugin's catalog; the host translates nav labels
permission: ADMIN_PERMISSION,
};
// The admin gate: a signed-in admin only. Each route already declares `permission: "admin"`, so the
// host enforces this before the handler runs; this is defence-in-depth and what a direct unit test
// relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403.
export function requireAdmin(ctx: RequestContext): User {
// The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already
// declares the same permission, so the host enforces it before the handler runs; this is
// defence-in-depth and what a direct unit test relies on. Returns the (non-null) user for the
// handler to thread on. GuardError → /login or 403.
export function requirePermission(ctx: RequestContext, resource: AdminResource): User {
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
if (!can(ctx, ADMIN_PERMISSION)) throw new GuardError(403, "admin permission required");
const permission = adminPermission(resource, ctx.req.method ?? "GET");
if (!can(ctx, permission)) throw new GuardError(403, `${permission} required`);
return user;
}
+7 -6
View File
@@ -5,7 +5,7 @@
// — admin-only, CSRF-guarded, each returning a RouteResult (a view, or a redirect after a write — PRG).
import { type Identity, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api";
import { ADMIN_EN, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
import { ADMIN_EN, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, requirePermission, unavailable } from "./admin-shared.ts";
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
const DEFAULT_PAGE_SIZE = 25;
@@ -266,15 +266,16 @@ function readUserInput(form: URLSearchParams): UserInput {
};
}
// Shared per-request deps for the Users screen, resolved by `withUser`: the gate (admin only) and
// the Kratos capability (else a themed 503). Each route below is a thin handler over these.
// Shared per-request deps for the Users screen, resolved by `withUser`: the gate (`users:read` on a
// GET, `users:write` on a POST) and the Kratos capability (else a themed 503). Each route below is a
// thin handler over these.
interface UsersDeps { ctx: RequestContext; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; }
// Resolve the shared deps, then run `inner`. The route's `permission: "admin"` already gated at the
// host; `requireAdmin` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it.
// Resolve the shared deps, then run `inner`. The route's own `permission` already gated at the host;
// `requirePermission` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it.
function withUser(inner: (deps: UsersDeps) => Promise<RouteResult>): RouteHandler {
return async (ctx) => {
const user = requireAdmin(ctx);
const user = requirePermission(ctx, "users");
const kratosAdmin = ctx.system?.kratosAdmin;
if (!kratosAdmin) return unavailable(ctx, ctx.t("admin.capability.kratos"));
return inner({ ctx, kratosAdmin, revoke: ctx.system?.revoke, user });
+3 -3
View File
@@ -92,8 +92,8 @@ const messages = {
"admin.permissions.create": "Create permission",
"admin.permissions.delete": "Delete permission",
"admin.permissions.deleteMessage": "Delete permission {{name}}? This revokes it from everyone it's assigned to.",
"admin.permissions.error.adminUndeletable": "The admin permission can't be deleted — it would remove all admin access.",
"admin.permissions.error.selfRevoke": "You can't revoke your own admin access.",
"admin.permissions.error.lockoutUndeletable": "The permissions:write permission can't be deleted — nobody could grant a permission again.",
"admin.permissions.error.selfRevoke": "You can't revoke your own permissions:write grant.",
"admin.permissions.effective": "Effective access",
"admin.permissions.effectiveHint": "Everyone who holds this permission — directly or through a group (resolved by Keto).",
"admin.permissions.field.name": "Permission name",
@@ -108,7 +108,7 @@ const messages = {
"admin.permissions.searchPlaceholder": "Search permission name…",
"admin.permissions.title": "Permissions",
"admin.permissions.validation.member": "Pick a user or group to assign the permission to.",
"admin.permissions.validation.name": "Permission names use lowercase letters, digits, dashes and underscores.",
"admin.permissions.validation.name": "Permission names are <resource>:<action>, like scheduling:read — lowercase letters, digits, dashes and underscores on each side of the colon.",
"admin.unavailable.message": "{{what}} is not configured on this deployment.",
"admin.unavailable.title": "Admin unavailable",
+3 -3
View File
@@ -92,8 +92,8 @@ const messages: AdminMessages = {
"admin.permissions.create": "Skapa behörighet",
"admin.permissions.delete": "Radera behörighet",
"admin.permissions.deleteMessage": "Ta bort behörigheten {{name}}? Den återkallas från alla den är tilldelad till.",
"admin.permissions.error.adminUndeletable": "Behörigheten admin kan inte tas bort — det skulle ta bort all administratörsåtkomst.",
"admin.permissions.error.selfRevoke": "Du kan inte återkalla din egen administratörsåtkomst.",
"admin.permissions.error.lockoutUndeletable": "Behörigheten permissions:write kan inte tas bort — ingen skulle kunna tilldela behörigheter igen.",
"admin.permissions.error.selfRevoke": "Du kan inte återkalla din egen tilldelning av permissions:write.",
"admin.permissions.effective": "Faktisk åtkomst",
"admin.permissions.effectiveHint": "Alla som har behörigheten — direkt eller via en grupp (uppslaget av Keto).",
"admin.permissions.field.name": "Behörighetens namn",
@@ -108,7 +108,7 @@ const messages: AdminMessages = {
"admin.permissions.searchPlaceholder": "Sök på behörighetens namn…",
"admin.permissions.title": "Behörigheter",
"admin.permissions.validation.member": "Välj en användare eller grupp att tilldela behörigheten till.",
"admin.permissions.validation.name": "Behörighetsnamn använder små bokstäver, siffror, bindestreck och understreck.",
"admin.permissions.validation.name": "Behörighetsnamn är <resurs>:<åtgärd>, som scheduling:read — små bokstäver, siffror, bindestreck och understreck på var sida om kolonet.",
"admin.unavailable.message": "{{what}} är inte konfigurerat i den här installationen.",
"admin.unavailable.title": "Administrationen är otillgänglig",
+40
View File
@@ -0,0 +1,40 @@
// The manifest's own invariants. A route gating on a permission the manifest doesn't declare is
// silent: bootstrap seeds only declared names, so the demo admin would simply 403 on that screen
// with nothing in the logs to explain it. Pin the two halves against each other here.
import assert from "node:assert/strict";
import { test } from "node:test";
import manifest from "./plugin.ts";
const routes = manifest.routes ?? [];
const declared = (manifest.permissions ?? []).map((p) => p.name);
test("every route is gated, and gates on a permission the manifest declares", () => {
assert.ok(routes.length > 0);
for (const route of routes) {
assert.equal(route.public, undefined, `${route.method} ${route.path} must not be public`);
assert.ok(route.permission, `${route.method} ${route.path} has no permission`);
assert.ok(declared.includes(route.permission!), `${route.method} ${route.path} gates on undeclared ${route.permission}`);
}
});
test("the manifest declares no permission it never gates on", () => {
const gated = new Set(routes.map((r) => r.permission));
for (const name of declared) assert.ok(gated.has(name), `declared but unused: ${name}`);
});
test("every declared permission is <resource>:<action>, and reads and writes are split per resource", () => {
for (const name of declared) assert.match(name, /^[a-z0-9][a-z0-9_-]*:(read|write)$/, name);
assert.deepEqual([...declared].sort(), [
"groups:read", "groups:write",
"oauth2-clients:read", "oauth2-clients:write",
"permissions:read", "permissions:write",
"users:read", "users:write",
]);
});
test("GET routes gate on read and mutations on write, so a reader can open a screen but not change it", () => {
for (const route of routes) {
const action = route.method === "GET" ? "read" : "write";
assert.ok(route.permission?.endsWith(`:${action}`), `${route.method} ${route.path}${route.permission}`);
}
});
+55 -38
View File
@@ -11,55 +11,72 @@ import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clie
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsRemoveMember } from "./admin-groups.ts";
import { rolesAddMember, rolesCreate, rolesDelete, rolesDeleteConfirm, rolesDetail, rolesList, rolesNewForm, rolesRemoveMember } from "./admin-permissions.ts";
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
import { ADMIN_NAV, ADMIN_PERMISSION } from "./admin-shared.ts";
import { ADMIN_NAV, adminPermission, type AdminResource } from "./admin-shared.ts";
// Every admin route is gated by the one `admin` permission — the host redirects an anonymous visitor
// to /login, gives a signed-in non-admin the 403 page, and filters the nav the same way. Handlers are
// thin and keyed on ctx.params (the host extracts :id / :name), the idiomatic per-route style.
const r = (method: HttpMethod, path: string, handler: RouteHandler): Route => ({ handler, method, path, permission: ADMIN_PERMISSION });
// One route factory per screen: `permission` is derived by `adminPermission`, so a GET gates on
// `<resource>:read` and a POST on `<resource>:write` and the table below cannot drift from the guard
// each handler runs. The host redirects an anonymous visitor to /login, gives a signed-in user
// missing the permission the 403 page, and filters the nav the same way. Handlers are thin and keyed
// on ctx.params (the host extracts :id / :name), the idiomatic per-route style.
const on = (resource: AdminResource) => (method: HttpMethod, path: string, handler: RouteHandler): Route =>
({ handler, method, path, permission: adminPermission(resource, method) });
const users = on("users");
const groups = on("groups");
const permissions = on("permissions");
const clients = on("oauth2-clients");
export default definePlugin({
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
nav: [ADMIN_NAV],
permissions: [{ description: "Administer users, groups, permissions, and OAuth2 clients", name: ADMIN_PERMISSION }],
permissions: [
{ description: "View users", name: "users:read" },
{ description: "Create, edit and delete users", name: "users:write" },
{ description: "View groups and their members", name: "groups:read" },
{ description: "Create, delete and change the membership of groups", name: "groups:write" },
{ description: "View permissions and who holds them", name: "permissions:read" },
{ description: "Create, delete and grant permissions", name: "permissions:write" },
{ description: "View OAuth2 clients", name: "oauth2-clients:read" },
{ description: "Register and delete OAuth2 clients", name: "oauth2-clients:write" },
],
routes: [
// Users
r("GET", "/users", usersList),
r("POST", "/users", usersCreate),
r("GET", "/users/new", usersNewForm),
r("GET", "/users/:id", usersEditForm),
r("POST", "/users/:id", usersUpdate),
r("POST", "/users/:id/state", usersState),
r("GET", "/users/:id/delete", usersDeleteConfirm),
r("POST", "/users/:id/delete", usersDelete),
r("POST", "/users/:id/recovery", usersRecovery),
users("GET", "/users", usersList),
users("POST", "/users", usersCreate),
users("GET", "/users/new", usersNewForm),
users("GET", "/users/:id", usersEditForm),
users("POST", "/users/:id", usersUpdate),
users("POST", "/users/:id/state", usersState),
users("GET", "/users/:id/delete", usersDeleteConfirm),
users("POST", "/users/:id/delete", usersDelete),
users("POST", "/users/:id/recovery", usersRecovery),
// Groups
r("GET", "/groups", groupsList),
r("POST", "/groups", groupsCreate),
r("GET", "/groups/new", groupsNewForm),
r("GET", "/groups/:name", groupsDetail),
r("POST", "/groups/:name/members", groupsAddMember),
r("GET", "/groups/:name/delete", groupsDeleteConfirm),
r("POST", "/groups/:name/delete", groupsDelete),
r("POST", "/groups/:name/members/delete", groupsRemoveMember),
// Roles
r("GET", "/permissions", rolesList),
r("POST", "/permissions", rolesCreate),
r("GET", "/permissions/new", rolesNewForm),
r("GET", "/permissions/:name", rolesDetail),
r("POST", "/permissions/:name/members", rolesAddMember),
r("GET", "/permissions/:name/delete", rolesDeleteConfirm),
r("POST", "/permissions/:name/delete", rolesDelete),
r("POST", "/permissions/:name/members/delete", rolesRemoveMember),
groups("GET", "/groups", groupsList),
groups("POST", "/groups", groupsCreate),
groups("GET", "/groups/new", groupsNewForm),
groups("GET", "/groups/:name", groupsDetail),
groups("POST", "/groups/:name/members", groupsAddMember),
groups("GET", "/groups/:name/delete", groupsDeleteConfirm),
groups("POST", "/groups/:name/delete", groupsDelete),
groups("POST", "/groups/:name/members/delete", groupsRemoveMember),
// Permissions
permissions("GET", "/permissions", rolesList),
permissions("POST", "/permissions", rolesCreate),
permissions("GET", "/permissions/new", rolesNewForm),
permissions("GET", "/permissions/:name", rolesDetail),
permissions("POST", "/permissions/:name/members", rolesAddMember),
permissions("GET", "/permissions/:name/delete", rolesDeleteConfirm),
permissions("POST", "/permissions/:name/delete", rolesDelete),
permissions("POST", "/permissions/:name/members/delete", rolesRemoveMember),
// OAuth2 clients
r("GET", "/clients", clientsList),
r("POST", "/clients", clientsCreate),
r("GET", "/clients/new", clientsNewForm),
r("GET", "/clients/:id", clientsDetail),
r("GET", "/clients/:id/delete", clientsDeleteConfirm),
r("POST", "/clients/:id/delete", clientsDelete),
clients("GET", "/clients", clientsList),
clients("POST", "/clients", clientsCreate),
clients("GET", "/clients/new", clientsNewForm),
clients("GET", "/clients/:id", clientsDetail),
clients("GET", "/clients/:id/delete", clientsDeleteConfirm),
clients("POST", "/clients/:id/delete", clientsDelete),
],
});