Permissions are a fixed list from plugin code; grant them on Users and Groups
CI / full-gate (push) Successful in 2m38s

This commit is contained in:
2026-08-05 14:29:15 +02:00
parent b6f4e5366a
commit 0011182f16
35 changed files with 431 additions and 873 deletions
+12 -6
View File
@@ -1,6 +1,6 @@
# Admin — the system-administration plugin
The Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. These used to be
The Users / Groups / OAuth2-clients screens for running Plainpages itself. These used to be
built into the core; they now ship as a **drop-in example plugin** so a fresh clone has no admin GUI
until you opt in. Copy this folder into `plugins/` (it keeps the id and mount path `admin`, so the
screens live at `/admin/*`) and restart:
@@ -25,7 +25,7 @@ reference](../scheduling/README.md)). The admin screens instead administer **Pla
stack**, so they use the privileged **`ctx.system`** surface the host exposes to a system plugin:
- **`ctx.system.kratosAdmin`** — create/edit/deactivate/delete Kratos identities (Users).
- **`ctx.system.keto`** — read/write the Keto relationship graph (Groups, Permissions).
- **`ctx.system.keto`** — read/write the Keto relationship graph (group membership, permission grants).
- **`ctx.system.hydra`** — register/list/delete Ory Hydra OAuth2 clients.
- **`ctx.system.revoke(sub)`** — the optional instant-revoke hook: a deactivate/delete or a
user's permission change kills that subject's live tokens at once instead of waiting out the JWT TTL.
@@ -36,16 +36,22 @@ than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin:
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.
Each screen is its own resource — `users`, `groups`, `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 three hides the Admin section entirely.
There is **no Permissions screen**. Permission names are declared in plugin code, not created in a
GUI, so the host's catalog (`ctx.declaredPermissions`) is the fixed list — and holding one is a
property of a user or a group, edited as a checkbox list on those two screens (`admin-grants.ts`).
## Layout
- `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
- `admin-grants.ts` — the permission picker and the grant diff, shared by the Users and Groups
screens: what a submitted checkbox set grants and revokes, against the host's declared catalog.
- `admin-users.ts` · `admin-groups.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
screen's permission gate + the needed `ctx.system` clients once.
@@ -0,0 +1,51 @@
// The pure half of permission granting: what a submitted checkbox set changes, and the picker the
// two screens render from it. The Keto writes and the HTTP round trip are covered in app.test.ts.
import assert from "node:assert/strict";
import { test } from "node:test";
import type { PermissionDecl } from "#plugin-api";
import { buildPermissionPicker, grantDiff, grantTuple, groupSubject, userSubject } from "./admin-grants.ts";
const declared: PermissionDecl[] = [
{ description: "View users", name: "users:read" },
{ description: "Edit users", name: "users:write" },
{ name: "groups:read" },
];
test("grantTuple targets a user by subject_id and a group by subject_set", () => {
assert.deepEqual(grantTuple("users:read", userSubject("u1")), { namespace: "Permission", object: "users:read", relation: "granted", subject_id: "user:u1" });
assert.deepEqual(grantTuple("users:read", groupSubject("eng")), {
namespace: "Permission", object: "users:read", relation: "granted",
subject_set: { namespace: "Group", object: "eng", relation: "members" },
});
});
test("grantDiff: the submitted set is the desired state — tick grants, untick revokes, unchanged is a no-op", () => {
assert.deepEqual(grantDiff(declared, ["users:read"], ["users:read", "users:write"]), { grant: ["users:write"], revoke: [] });
assert.deepEqual(grantDiff(declared, ["users:read", "users:write"], ["users:read"]), { grant: [], revoke: ["users:write"] });
assert.deepEqual(grantDiff(declared, ["users:read"], ["users:read"]), { grant: [], revoke: [] });
assert.deepEqual(grantDiff(declared, ["users:read"], []), { grant: [], revoke: ["users:read"] }); // every box cleared
});
test("grantDiff ignores anything the plugins don't declare, in both directions", () => {
// A crafted POST can't grant a name no plugin gates on…
assert.deepEqual(grantDiff(declared, [], ["superuser:all"]), { grant: [], revoke: [] });
// …and a held name that is no longer declared (its plugin was uninstalled) is left alone rather
// than silently revoked by an unrelated save — this screen only speaks for what it offered.
assert.deepEqual(grantDiff(declared, ["legacy:thing"], ["users:read"]), { grant: ["users:read"], revoke: [] });
});
test("buildPermissionPicker ticks what is held and carries each declaration's description", () => {
const picker = buildPermissionPicker({ action: "/admin/users/u1/permissions", declared, held: ["users:write"] });
assert.equal(picker.action, "/admin/users/u1/permissions");
assert.deepEqual(picker.choices.map((c) => c.name), ["users:read", "users:write", "groups:read"]);
assert.deepEqual(picker.choices.map((c) => c.checked), [false, true, false]);
assert.equal(picker.choices[0]?.description, "View users");
assert.equal(picker.choices[2]?.description, ""); // a declaration may omit one
assert.equal(picker.empty, undefined);
});
test("buildPermissionPicker says so when no plugin declares a permission, rather than rendering an empty box", () => {
const picker = buildPermissionPicker({ action: "/x", declared: [], held: [] });
assert.deepEqual(picker.choices, []);
assert.ok(picker.empty);
});
+89
View File
@@ -0,0 +1,89 @@
// Permission grants, shared by the Users and Groups screens. A permission is held by a user
// (`Permission:<name>#granted@user:<id>`) or by a whole group (`…@Group:<name>#members`), and Keto
// resolves a group's grant transitively at login.
//
// The set of permissions that *exist* is `ctx.declaredPermissions` — the host's catalog, built from
// what the installed plugins declare in code. Nothing here invents a name, which is why the old
// Permissions screen is gone: a grant is a property of a user or a group, edited where they are.
import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "#plugin-api";
const PERMISSION_NS = "Permission";
const GRANTED = "granted";
export const PERMISSIONS_FIELD = "permission"; // the checkbox name the two forms post
export type GrantSubject = { subject_id: string } | { subject_set: SubjectSet };
export const userSubject = (id: string): GrantSubject => ({ subject_id: `user:${id}` });
export const groupSubject = (name: string): GrantSubject => ({ subject_set: { namespace: "Group", object: name, relation: "members" } });
export function grantTuple(permission: string, subject: GrantSubject): RelationTuple {
return { namespace: PERMISSION_NS, object: permission, relation: GRANTED, ...subject };
}
// The permissions this subject holds *directly* — one Keto read filtered by the subject, not one per
// declared name. A group's members hold them transitively; that expansion is Keto's job at login,
// and this screen edits the direct edge only.
export async function heldPermissions(keto: KetoClient, subject: GrantSubject): Promise<string[]> {
const held = new Set<string>();
let pageToken: string | undefined;
do {
const page = await keto.listRelations({ namespace: PERMISSION_NS, relation: GRANTED, ...subject, ...(pageToken ? { pageToken } : {}) });
for (const tuple of page.tuples) held.add(tuple.object);
pageToken = page.nextPageToken ?? undefined;
} while (pageToken);
return [...held].sort();
}
export interface PermissionChoice {
checked: boolean;
description: string;
name: string;
}
export interface PermissionPicker {
action: string;
choices: PermissionChoice[];
empty: string | undefined; // set when no plugin declares a permission — the picker has nothing to offer
field: string;
legend: string;
submit: string;
}
// The checkbox list: every declared permission, ticked where this subject already holds it. A fixed
// list means the form is the whole truth — what it posts back *is* the desired set (applyGrants).
export function buildPermissionPicker(opts: {
action: string;
declared: PermissionDecl[];
held: string[];
t?: Translate;
}): PermissionPicker {
const t = opts.t ?? ((k: string) => k);
const heldSet = new Set(opts.held);
return {
action: opts.action,
choices: opts.declared.map((decl) => ({ checked: heldSet.has(decl.name), description: decl.description ?? "", name: decl.name })),
empty: opts.declared.length === 0 ? t("admin.grants.none") : undefined,
field: PERMISSIONS_FIELD,
legend: t("admin.grants.legend"),
submit: t("admin.grants.save"),
};
}
// What a submitted set changes. Pure so the diff is testable without Keto: only declared names are
// considered, so a crafted POST cannot grant something no plugin gates on, and a held-but-undeclared
// name (left over from an uninstalled plugin) is never silently revoked by an unrelated save.
export function grantDiff(declared: PermissionDecl[], held: string[], wanted: string[]): { grant: string[]; revoke: string[] } {
const offered = new Set(declared.map((d) => d.name));
const heldSet = new Set(held);
const wantedSet = new Set(wanted.filter((name) => offered.has(name)));
return {
grant: [...wantedSet].filter((name) => !heldSet.has(name)).sort(),
revoke: [...heldSet].filter((name) => offered.has(name) && !wantedSet.has(name)).sort(),
};
}
export async function applyGrants(keto: KetoClient, subject: GrantSubject, diff: { grant: string[]; revoke: string[] }): Promise<void> {
for (const name of diff.grant) await keto.writeTuple(grantTuple(name, subject));
for (const name of diff.revoke) await keto.deleteTuple(grantTuple(name, subject));
}
+24 -1
View File
@@ -7,6 +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 { applyGrants, buildPermissionPicker, grantDiff, groupSubject, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD } from "./admin-grants.ts";
import { ADMIN_EN, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, requirePermission, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts";
@@ -229,6 +230,7 @@ export function buildGroupDetailModel(opts: {
error?: string;
group: { name: string };
members: MemberView[];
permissions?: PermissionPicker;
t?: Translate;
}) {
const t = opts.t ?? ADMIN_EN;
@@ -245,6 +247,7 @@ export function buildGroupDetailModel(opts: {
error: opts.error,
group: { name },
members: { action: `${base}/members/delete`, rows: opts.members },
permissions: opts.permissions,
title: name,
};
}
@@ -343,7 +346,27 @@ export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}));
export const groupsDetail = withGroupName(async ({ ctx, keto, kratosAdmin }, name) => {
const { emailById, options } = await memberCandidates(keto, kratosAdmin);
const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById));
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members, t: ctx.t }) }, view: "group-detail" };
const permissions = buildPermissionPicker({
action: `${detailHref(name)}/permissions`,
declared: ctx.declaredPermissions,
held: await heldPermissions(keto, groupSubject(name)),
t: ctx.t,
});
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members, permissions, t: ctx.t }) }, view: "group-detail" };
});
// POST /admin/groups/:name/permissions — the submitted checkboxes are the desired set. Members hold
// a group's permissions transitively, so the change reaches them at their next login or re-mint —
// the documented instant-revoke tradeoff for anything held through a group.
export const groupsPermissions = withGroupName(async ({ ctx, keto, user }, name) => {
const form = (await guardedForm(ctx))!;
const subject = groupSubject(name);
const diff = grantDiff(ctx.declaredPermissions, await heldPermissions(keto, subject), form.getAll(PERMISSIONS_FIELD));
await applyGrants(keto, subject, diff);
if (diff.grant.length > 0 || diff.revoke.length > 0) {
ctx.log.info("admin: group permissions changed", { actor: user.id, granted: diff.grant.join(","), group: name, revoked: diff.revoke.join(",") });
}
return { redirect: detailHref(name) };
});
// POST /admin/groups/:name/members — add a member (skip an invalid member or a self-nest).
@@ -1,112 +0,0 @@
// Built-in Roles admin screen: the pure view-model + Keto builders. A permission is a
// Keto subject set (Permission:<name>#members); members are users (subject_id) or groups (subject_set) —
// "assign permissions to users/groups". The "effective access" view flattens a Keto `expand` tree into the
// distinct set of users who hold the permission 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 {
buildPermissionDetailModel,
buildPermissionFormModel,
buildPermissionsListModel,
expandToEffectiveUsers,
isPermissionPathSegment,
permissionGrantTuple,
} from "./admin-permissions.ts";
import type { ExpandTree, RelationTuple } from "#plugin-api";
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
const userTuple = (permission: string, n: number): RelationTuple =>
({ namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${uid(n)}` });
const groupTuple = (permission: string, group: string): RelationTuple =>
({ namespace: "Permission", object: permission, relation: "granted", subject_set: { namespace: "Group", object: group, relation: "members" } });
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", () => {
// 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: "Permission", object: "admin", relation: "granted" } },
type: "union",
};
assert.deepEqual(expandToEffectiveUsers(tree), [uid(1), uid(2)]);
assert.deepEqual(expandToEffectiveUsers(null), []);
assert.deepEqual(expandToEffectiveUsers({ type: "leaf" }), []); // an empty permission
});
test("buildPermissionsListModel filters by search, sorts, paginates; the name links to the detail page", () => {
const permissions = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `permission-${String(i).padStart(2, "0")}` }));
const all = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions" });
assert.equal(all.pagination.summary.total, 30);
assert.equal(all.table.rows.length, 25); // default page size
assert.equal(all.title, "Permissions");
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
assert.equal(first.rowHeader.text, "permission-00");
assert.equal(first.rowHeader.href, "/admin/permissions/permission-00");
const one = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions?q=permission-07" });
assert.equal(one.pagination.summary.total, 1);
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
const desc = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions?sort=-members" });
assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "permission-29");
});
test("buildPermissionFormModel: 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 = buildPermissionFormModel({ csrfToken: "tok.sig", memberOptions: options });
assert.equal(m.title, "New permission");
assert.equal(m.form.action, "/admin/permissions");
assert.equal(m.form.submitLabel, "Create permission");
assert.equal(m.form.csrfToken, "tok.sig");
assert.equal(m.form.nameField.required, true);
assert.deepEqual(m.form.memberOptions, options);
const err = buildPermissionFormModel({ 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("buildPermissionDetailModel: members → rows, add-options exclude current members, effective access listed, actions wired", () => {
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)}` },
{ 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 = buildPermissionDetailModel({ candidates, effective, members, permission: { name: "users:read" } });
assert.equal(m.title, "users:read");
assert.equal(m.members.rows.length, 2);
// 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/users%3Aread/delete");
});
-389
View File
@@ -1,389 +0,0 @@
// Permissions admin screen: list / create / delete Keto permissions and assign
// them to users and groups. A permission is a Keto subject set `Permission:<name>#members` (OPL: members are users
// or groups, resolved transitively) — the source of truth for the JWT `permissions` claim. It shares the
// Groups screen's membership model, so the pure helpers (parseSubject, member pickers, tuple paging)
// are reused from admin-groups. The permission-specific piece is the **effective access** view:
// `keto.expand(Permission:<name>#members)` flattened to the distinct users who hold the permission directly or via
// a group — matching what login projects into the JWT (login.ts readPermissions). Writes go only to Keto;
// Kratos is read only to label members. Below the builders are thin per-route handlers (keyed on
// ctx.params) over a shared `withRoles` gate — admin-only, CSRF-guarded.
import { type ExpandTree, isValidPermissionName, 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_PERMISSIONS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import {
type GroupView,
groupsFromTuples,
memberCandidates,
type MemberOption,
type MemberView,
memberView,
pagedTuples,
parseSubject,
} from "./admin-groups.ts";
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 = permissionName("permissions", "write");
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 user|group membership model, but not the name rule: a
// permission is `<resource>:<action>` (README → Users, groups & permissions).
export type PermissionView = GroupView;
export const permissionsFromTuples = groupsFromTuples;
const PERMISSION_SEGMENT = /^[a-z0-9][a-z0-9_:-]*$/;
// 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.
// Minting one goes through the host's `isValidPermissionName`, the same rule discovery enforces.
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)
}
// The full membership tuple for assigning/revoking `value` to/from `permission` (null if value is invalid).
export function permissionGrantTuple(permission: string, value: string): RelationTuple | null {
const subject = parseSubject(value);
return subject ? { namespace: PERMISSION_NS, object: permission, relation: GRANTED, ...subject } : null;
}
// Flatten a Keto `expand` tree → the sorted, distinct user ids that effectively hold the permission
// (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<string>();
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<string, (r: PermissionView) => number | string> = {
members: (r) => r.memberCount,
name: (r) => r.name,
};
const COLUMNS = [
{ key: "name", label: "admin.permissions.column.name" },
{ key: "members", label: "admin.permissions.column.members" },
];
function detailHref(name: string): string {
return `${ADMIN_PERMISSIONS_BASE}/${encodeURIComponent(name)}`;
}
function listHref(state: ListState, overrides: Partial<ListState> = {}): 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_PERMISSIONS_BASE}?${qs}` : ADMIN_PERMISSIONS_BASE;
}
export function buildPermissionsListModel(opts: {
csrfToken?: string;
permissions: PermissionView[];
t?: Translate;
url: URL | URLSearchParams | string;
}) {
const t = opts.t ?? ADMIN_EN;
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.permissions.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 {
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: t("admin.nav.section") }, { label: t("admin.permissions.title") }],
filterBar: listFilterBar(state, t),
pagination: listPagination(state, page, t),
table: listTable(rows, state, sort, t),
title: t("admin.permissions.title"),
};
}
function listTable(rows: PermissionView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate) {
return {
caption: t("admin.permissions.title"),
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: t(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, t: Translate) {
const pills: { label: string; remove: string; value: string }[] = [];
if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
return {
applyLabel: t("filter.apply"),
clearHref: ADMIN_PERMISSIONS_BASE,
label: t("admin.permissions.filter"),
pills,
rows: [[
{ label: t("admin.permissions.searchLabel"), name: "q", placeholder: t("admin.permissions.searchPlaceholder"), type: "search", value: state.q },
{ type: "spacer" },
]],
};
}
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
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: t("admin.permissions.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: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
summary: { from: page.from, to: page.to, total: page.total },
};
}
// ---- create form + detail view models ----
export function buildPermissionFormModel(opts: {
csrfToken?: string;
error?: string;
memberOptions: MemberOption[];
t?: Translate;
values?: { member?: string; name?: string };
}) {
const t = opts.t ?? ADMIN_EN;
const nameField: FieldConfig = {
autocomplete: "off", hint: t("admin.permissions.field.nameHint"), icon: "i-shield",
id: "name", label: t("admin.permissions.field.name"), name: "name", required: true, value: opts.values?.name ?? "",
};
return {
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: t("admin.permissions.title") }, { label: t("common.new") }],
error: opts.error,
form: {
action: ADMIN_PERMISSIONS_BASE,
cancelHref: ADMIN_PERMISSIONS_BASE,
csrfToken: opts.csrfToken ?? "",
memberOptions: opts.memberOptions,
nameField,
selectedMember: opts.values?.member ?? "",
submitLabel: t("admin.permissions.create"),
},
title: t("admin.permissions.new"),
};
}
export function buildPermissionDetailModel(opts: {
candidates: MemberOption[];
csrfToken?: string;
effective: EffectiveUser[];
error?: string;
members: MemberView[];
permission: { name: string };
t?: Translate;
}) {
const t = opts.t ?? ADMIN_EN;
const name = opts.permission.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 permission itself
return {
add: { action: `${base}/members`, options },
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: t("admin.permissions.title") }, { label: name }],
csrfToken: opts.csrfToken ?? "",
delete: { action: `${base}/delete` },
effective: opts.effective,
error: opts.error,
members: { action: `${base}/members/delete`, rows: opts.members },
permission: { name },
title: name,
};
}
// ---- request handler (imperative shell) ----
// instant-revoke: a permission change for a `user:<id>` member must take effect now, so revoke that
// user's live tokens (a re-mint then re-reads permissions from Keto). A `group:<name>` change is
// transitive across many users — left to lag (documented), so only direct user members revoke.
function revokeUserMember(revoke: ((sub: string) => void) | undefined, member: string): void {
if (revoke && member.startsWith("user:")) revoke(member.slice("user:".length));
}
// A permission exists exactly while it has ≥1 member (Keto has no create-object).
async function roleExists(keto: KetoClient, name: string): Promise<boolean> {
const page = await keto.listRelations({ namespace: PERMISSION_NS, object: name, relation: GRANTED, pageSize: 1 });
return page.tuples.length > 0;
}
// The distinct users who effectively hold the permission (expand → flatten → label by email). Skipped for
// an empty permission (no member tuples) so we don't expand a non-existent Keto object.
async function effectiveUsers(keto: KetoClient, name: string, hasMembers: boolean, emailById: Map<string, string>): Promise<EffectiveUser[]> {
if (!hasMembers) return [];
const tree = await keto.expand({ namespace: PERMISSION_NS, object: name, relation: GRANTED }, { maxDepth: EXPAND_MAX_DEPTH });
return expandToEffectiveUsers(tree)
.map((id) => ({ label: emailById.get(id) ?? `user:${id}` }))
.sort((a, b) => a.label.localeCompare(b.label));
}
// Shared per-request deps for the Roles screen, resolved by `withRoles`: the gate + the Keto and
// Kratos capabilities (else a themed 503). Each route below is a thin handler over these.
interface RolesDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; }
function withRoles(inner: (deps: RolesDeps) => Promise<RouteResult>): RouteHandler {
return async (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"));
return inner({ ctx, keto, kratosAdmin, revoke: ctx.system?.revoke, user });
};
}
// Same, plus the validated :name from ctx.params (an invalid permission name → themed 404).
function withRoleName(inner: (deps: RolesDeps, name: string) => Promise<RouteResult>): RouteHandler {
return withRoles((deps) => {
const name = deps.ctx.params["name"] ?? "";
if (!isPermissionPathSegment(name)) return Promise.resolve(notFound(deps.ctx));
return inner(deps, name);
});
}
const roleFormResult = async (deps: RolesDeps, extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
const { options } = await memberCandidates(deps.keto, deps.kratosAdmin);
return { data: { chrome: deps.ctx.chrome, model: buildPermissionFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, t: deps.ctx.t, ...extra }) }, view: "permission-form" };
};
// The permission detail (members + effective access). With `error` set it's a 400 (a rejected action).
const permissionDetailResult = async (deps: RolesDeps, name: string, error?: string): Promise<RouteResult> => {
const { emailById, options } = await memberCandidates(deps.keto, deps.kratosAdmin);
const tuples = await pagedTuples(deps.keto, { namespace: PERMISSION_NS, object: name, relation: GRANTED });
const members = tuples.map((t) => memberView(t, emailById));
const effective = await effectiveUsers(deps.keto, name, tuples.length > 0, emailById);
const result: RouteResult = { data: { chrome: deps.ctx.chrome, model: buildPermissionDetailModel({ candidates: options, csrfToken: deps.ctx.chrome.csrfToken, effective, members, permission: { name }, t: deps.ctx.t, ...(error ? { error } : {}) }) }, view: "permission-detail" };
return error ? { ...result, status: 400 } : result;
};
// GET /admin/permissions — the list.
export const rolesList = withRoles(async ({ ctx, keto }) => {
const permissions = permissionsFromTuples(await pagedTuples(keto, { namespace: PERMISSION_NS, relation: GRANTED }));
return { data: { chrome: ctx.chrome, model: buildPermissionsListModel({ csrfToken: ctx.chrome.csrfToken, permissions, t: ctx.t, url: ctx.url }) }, view: "permissions" };
});
// POST /admin/permissions — create + assign the first member (a *user* grant revokes their live tokens).
export const rolesCreate = withRoles(async (deps) => {
const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!;
const name = (form.get("name") ?? "").trim();
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 (!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);
revokeUserMember(revoke, member);
ctx.log.info("admin: permission created + first member assigned", { actor: user.id, member, permission: name });
return { redirect: detailHref(name) };
});
// GET /admin/permissions/new — the create form.
export const rolesNewForm = withRoles((deps) => roleFormResult(deps, {}));
// GET /admin/permissions/:name — the detail (members + effective access via Keto expand).
export const rolesDetail = withRoleName((deps, name) => permissionDetailResult(deps, name));
// POST /admin/permissions/:name/members — assign a user/group; a *user* grant revokes their live tokens.
export const rolesAddMember = withRoleName(async (deps, name) => {
const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!;
// A permission exists only while a tuple carries it, so this write would *create* one under a
// hand-typed name — the second mint point, and the one that would slip past the create form's
// <resource>:<action> rule. Assigning to something that doesn't exist is a 404, not a create.
if (!(await roleExists(keto, name))) return notFound(ctx);
const member = (form.get("member") ?? "").trim();
const tuple = permissionGrantTuple(name, member); // the picker only offers real users/groups
if (tuple) { await keto.writeTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: permission assigned", { actor: user.id, member, permission: name }); }
return { redirect: detailHref(name) };
});
// GET /admin/permissions/:name/delete — confirm, except the lockout permission can't be deleted.
export const rolesDeleteConfirm = withRoleName((deps, name) => {
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({
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: tt("admin.permissions.title") }, { href: base, label: name }, { label: tt("common.delete") }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.permissions.delete"),
message: tt("admin.permissions.deleteMessage", { name }), title: tt("admin.permissions.delete"),
}) }, view: "confirm" });
});
// POST /admin/permissions/:name/delete — remove every member tuple (a whole-permission delete lags per the
// 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 === 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: 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 === 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) };
});
+6 -6
View File
@@ -19,7 +19,7 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
req.method = opts.method ?? "GET";
return {
chrome: CHROME, user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {},
chrome: CHROME, declaredPermissions: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {},
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.user?.permissions ?? [], t: ADMIN_EN, url,
verifyCsrf: opts.verifyCsrf ?? (() => true),
};
@@ -27,7 +27,7 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver
// ---- nav fragment ----
test("ADMIN_NAV: an ungated Admin header whose four screens each gate on their own read permission", () => {
test("ADMIN_NAV: an ungated Admin header whose three screens each gate on their own read permission", () => {
assert.equal(ADMIN_NAV.id, "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.
@@ -36,12 +36,12 @@ test("ADMIN_NAV: an ungated Admin header whose four screens each gate on their o
assert.equal(ADMIN_NAV.permission, undefined);
assert.equal(ADMIN_NAV.href, 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"]);
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/clients"]);
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups: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.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["admin.nav.users", "admin.nav.groups", "admin.nav.clients"]);
assert.deepEqual(ADMIN_NAV.children?.map((c) => ADMIN_EN(c.label)), ["Users", "Groups", "OAuth2 clients"]);
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined));
});
+6 -6
View File
@@ -13,12 +13,13 @@ export const ADMIN_EN: Translate = englishTranslator(enUS);
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";
// 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";
// There is no `permissions` resource: permissions are declared in plugin code, not created here, so
// holding a grant is a property of a user or a group and is edited on those two screens.
export type AdminResource = "groups" | "oauth2-clients" | "users";
export type AdminAction = "read" | "write";
@@ -43,10 +44,9 @@ export function actionForMethod(method: string): AdminAction {
// 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", 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" },
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users", permission: permissionName("users", "read") },
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups", permission: permissionName("groups", "read") },
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients", permission: permissionName("oauth2-clients", "read") },
],
icon: "i-shield",
id: "admin",
+42 -4
View File
@@ -4,7 +4,8 @@
// models; below them are thin per-route handlers (keyed on ctx.params) over a shared `withUser` gate
// — 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 { type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api";
import { applyGrants, buildPermissionPicker, grantDiff, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD, userSubject } from "./admin-grants.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
@@ -219,6 +220,7 @@ export function buildUserFormModel(opts: {
csrfToken?: string;
error?: string;
identity?: Identity | null;
permissions?: PermissionPicker; // editing only — a user that doesn't exist yet can hold nothing
recovery?: RecoveryCode;
t?: Translate;
values?: Partial<UserInput>;
@@ -250,6 +252,7 @@ export function buildUserFormModel(opts: {
} : undefined,
error: opts.error,
form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? t("admin.users.save") : t("admin.users.create") },
permissions: editing ? opts.permissions : undefined,
recovery: opts.recovery,
title: editing ? t("admin.users.edit") : t("admin.users.new"),
};
@@ -269,7 +272,9 @@ function readUserInput(form: URLSearchParams): UserInput {
// 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; }
// `keto` is optional the way every other capability here is: without it the page still lists and
// edits users, it just can't show the permission picker.
interface UsersDeps { ctx: RequestContext; keto: KetoClient | undefined; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; }
// 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.
@@ -278,7 +283,7 @@ function withUser(inner: (deps: UsersDeps) => Promise<RouteResult>): RouteHandle
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 });
return inner({ ctx, keto: ctx.system?.keto, kratosAdmin, revoke: ctx.system?.revoke, user });
};
}
@@ -319,7 +324,40 @@ export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx, {})));
// GET /admin/users/:id — the edit form, prefilled.
export const usersEditForm = withTarget((deps, identity) => Promise.resolve(formResult(deps.ctx, { identity })));
export const usersEditForm = withTarget(async (deps, identity, id) => {
const permissions = await userPermissionPicker(deps, id);
return formResult(deps.ctx, { identity, ...(permissions ? { permissions } : {}) });
});
// The checkbox list of declared permissions, ticked where this user holds one directly. Undefined
// when Keto isn't wired — the rest of the edit page still works.
async function userPermissionPicker(deps: UsersDeps, id: string): Promise<PermissionPicker | undefined> {
if (!deps.keto) return undefined;
const held = await heldPermissions(deps.keto, userSubject(id));
return buildPermissionPicker({
action: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}/permissions`,
declared: deps.ctx.declaredPermissions,
held,
t: deps.ctx.t,
});
}
// POST /admin/users/:id/permissions — the submitted checkboxes are the desired set; grant what's
// newly ticked, revoke what's newly unticked. A change to a user's own grants revokes their live
// tokens so it lands now rather than at the next re-mint.
export const usersPermissions = withTarget(async (deps, _identity, id) => {
const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!;
if (!keto) return unavailable(ctx, ctx.t("admin.capability.keto"));
const subject = userSubject(id);
const diff = grantDiff(ctx.declaredPermissions, await heldPermissions(keto, subject), form.getAll(PERMISSIONS_FIELD));
await applyGrants(keto, subject, diff);
if (diff.grant.length > 0 || diff.revoke.length > 0) {
revoke?.(id);
ctx.log.info("admin: user permissions changed", { actor: user.id, granted: diff.grant.join(","), revoked: diff.revoke.join(","), target: id });
}
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
});
// POST /admin/users/:id — save edits; a Kratos 4xx re-renders the form (400).
export const usersUpdate = withTarget(async ({ ctx, kratosAdmin }, identity, id) => {
+4 -29
View File
@@ -48,6 +48,10 @@ const messages = {
"admin.common.type": "Type",
"admin.common.user": "User",
"admin.grants.legend": "Permissions",
"admin.grants.none": "No installed plugin declares a permission, so there is nothing to grant.",
"admin.grants.save": "Save permissions",
"admin.groups.actions": "Group actions",
"admin.groups.addMember": "Add a member",
"admin.groups.allMembers": "All users and groups are already members.",
@@ -74,41 +78,12 @@ const messages = {
"admin.nav.clients": "OAuth2 clients",
"admin.nav.groups": "Groups",
"admin.nav.permissions": "Permissions",
"admin.nav.section": "Admin",
"admin.nav.users": "Users",
"admin.notFound.message": "That item doesn't exist.",
"admin.notFound.title": "Not found",
"admin.permissions.actions": "Permission actions",
"admin.permissions.allAssigned": "All users and groups already have this permission.",
"admin.permissions.assign": "Assign the permission",
"admin.permissions.assignAction": "Assign",
"admin.permissions.assignTo": "Assign to",
"admin.permissions.assignedTo": "Assigned to",
"admin.permissions.column.members": "Members",
"admin.permissions.column.name": "Permission",
"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.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",
"admin.permissions.field.nameHint": "Lowercase letters, digits, dashes and underscores.",
"admin.permissions.filter": "Filter permissions",
"admin.permissions.new": "New permission",
"admin.permissions.noEffective": "No users hold this permission yet.",
"admin.permissions.noMembers": "Not assigned to anyone yet.",
"admin.permissions.pagination": "Permissions pagination",
"admin.permissions.revoke": "Revoke",
"admin.permissions.searchLabel": "Search permissions",
"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 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",
+4 -29
View File
@@ -48,6 +48,10 @@ const messages: AdminMessages = {
"admin.common.type": "Typ",
"admin.common.user": "Användare",
"admin.grants.legend": "Behörigheter",
"admin.grants.none": "Ingen installerad plugin deklarerar någon behörighet, så det finns inget att tilldela.",
"admin.grants.save": "Spara behörigheter",
"admin.groups.actions": "Gruppåtgärder",
"admin.groups.addMember": "Lägg till en medlem",
"admin.groups.allMembers": "Alla användare och grupper är redan medlemmar.",
@@ -74,41 +78,12 @@ const messages: AdminMessages = {
"admin.nav.clients": "OAuth2-klienter",
"admin.nav.groups": "Grupper",
"admin.nav.permissions": "Behörigheter",
"admin.nav.section": "Administration",
"admin.nav.users": "Användare",
"admin.notFound.message": "Objektet finns inte.",
"admin.notFound.title": "Hittades inte",
"admin.permissions.actions": "Behörighetsåtgärder",
"admin.permissions.allAssigned": "Alla användare och grupper har redan den här behörigheten.",
"admin.permissions.assign": "Tilldela behörigheten",
"admin.permissions.assignAction": "Tilldela",
"admin.permissions.assignTo": "Tilldela till",
"admin.permissions.assignedTo": "Tilldelad till",
"admin.permissions.column.members": "Medlemmar",
"admin.permissions.column.name": "Behörighet",
"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.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",
"admin.permissions.field.nameHint": "Små bokstäver, siffror, bindestreck och understreck.",
"admin.permissions.filter": "Filtrera behörigheter",
"admin.permissions.new": "Ny behörighet",
"admin.permissions.noEffective": "Ingen användare har den här behörigheten ännu.",
"admin.permissions.noMembers": "Inte tilldelad till någon ännu.",
"admin.permissions.pagination": "Sidnavigering för behörigheter",
"admin.permissions.revoke": "Återkalla",
"admin.permissions.searchLabel": "Sök behörigheter",
"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 ä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",
+18 -1
View File
@@ -23,12 +23,29 @@ test("the manifest declares no permission it never gates on", () => {
for (const name of declared) assert.ok(gated.has(name), `declared but unused: ${name}`);
});
// A nav permission is a plain string the host matches against the JWT claim: a typo ("user:read")
// passes discovery's shape check and silently hides that menu item forever. Same silent-failure
// class the route checks above close, so close it on the nav side too.
test("every nav permission is one the manifest declares", () => {
const navPermissions: string[] = [];
const walk = (nodes: typeof manifest.nav): void => {
for (const node of nodes ?? []) {
if (node.permission != null) navPermissions.push(node.permission);
walk(node.children);
}
};
walk(manifest.nav);
assert.equal(navPermissions.length, 3);
for (const name of navPermissions) assert.ok(declared.includes(name), `nav gates on undeclared ${name}`);
});
test("every declared permission is <resource>:<action>, and reads and writes are split per resource", () => {
for (const name of declared) assert.ok(isValidPermissionName(name), name); // the host's rule, not a copy of it
// Three screens × read/write. There is deliberately no `permissions:` pair: permissions are
// declared in plugin code, so holding one is edited on the user or group that holds it.
assert.deepEqual([...declared].sort(), [
"groups:read", "groups:write",
"oauth2-clients:read", "oauth2-clients:write",
"permissions:read", "permissions:write",
"users:read", "users:write",
]);
});
+8 -19
View File
@@ -8,9 +8,8 @@
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "#plugin-api";
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
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 { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts";
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
import { ADMIN_NAV, actionForMethod, type AdminResource, permissionName } from "./admin-shared.ts";
// One route factory per screen: a GET gates on `<resource>:read` and a POST on `<resource>:write`,
@@ -23,7 +22,6 @@ const on = (resource: AdminResource) => (method: HttpMethod, path: string, handl
const users = on("users");
const groups = on("groups");
const permissions = on("permissions");
const clients = on("oauth2-clients");
export default definePlugin({
@@ -32,12 +30,10 @@ export default definePlugin({
nav: [ADMIN_NAV],
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 users and the permissions they hold", name: "users:read" },
{ description: "Create, edit and delete users, and grant them permissions", name: "users:write" },
{ description: "View groups, their members and the permissions they hold", name: "groups:read" },
{ description: "Create and delete groups, and change their members and permissions", name: "groups:write" },
{ description: "View OAuth2 clients", name: "oauth2-clients:read" },
{ description: "Register and delete OAuth2 clients", name: "oauth2-clients:write" },
],
@@ -53,6 +49,7 @@ export default definePlugin({
users("GET", "/users/:id/delete", usersDeleteConfirm),
users("POST", "/users/:id/delete", usersDelete),
users("POST", "/users/:id/recovery", usersRecovery),
users("POST", "/users/:id/permissions", usersPermissions),
// Groups
groups("GET", "/groups", groupsList),
groups("POST", "/groups", groupsCreate),
@@ -62,15 +59,7 @@ export default definePlugin({
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),
groups("POST", "/groups/:name/permissions", groupsPermissions),
// OAuth2 clients
clients("GET", "/clients", clientsList),
clients("POST", "/clients", clientsCreate),
@@ -2,7 +2,7 @@
Group admin detail / membership page: the group-detail body in the app shell.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/group-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, error: model.error, group: model.group, members: model.members });
const body = include("partials/group-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, error: model.error, group: model.group, members: model.members, permissions: model.permissions });
-%>
<%- include("partials/shell", {
body,
@@ -36,6 +36,9 @@
<p class="cell-muted"><%= t("admin.groups.allMembers") %></p>
<% } -%>
</section>
<% if (locals.permissions) { -%>
<%- include("partials/permission-picker", { csrfToken: csrf, permissions: locals.permissions }) %>
<% } -%>
<section class="form-card admin-actions" aria-label="<%= t("admin.groups.actions") %>">
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.groups.delete") %></a>
</section>
@@ -1,57 +0,0 @@
<%#
Admin permission detail body, captured into the shell content slot. Config:
permission { name }
members { action, rows: { kind:"group"|"identity", label, subject }[] } action = revoke endpoint
effective { label }[] users who hold the permission (expand)
add { action, options: {label,value}[] } action = assign endpoint
del { action } delete the whole permission
csrfToken, error?
%><%
const permission = locals.permission;
const members = locals.members;
const effective = locals.effective;
const add = locals.add;
const del = locals.del;
const csrf = locals.csrfToken;
-%>
<div class="form-page">
<% if (locals.error) { -%>
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<section class="form-card" aria-labelledby="members-h">
<h2 class="card-title" id="members-h"><%= t("admin.permissions.assignedTo") %></h2>
<% if (members.rows.length) { -%>
<div class="table-wrap"><table class="table"><caption class="sr-only"><%= t("admin.groups.membersOf", { name: permission.name }) %></caption><thead><tr><th scope="col"><%= t("admin.common.member") %></th><th scope="col"><%= t("admin.common.type") %></th><th class="col-actions" scope="col"><span class="sr-only"><%= t("table.actions") %></span></th></tr></thead><tbody>
<% members.rows.forEach((m) => { -%>
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %></span></td><td class="col-actions"><form method="post" action="<%= localeHref(members.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg><%= t("admin.permissions.revoke") %></button></form></td></tr>
<% }) -%>
</tbody></table></div>
<% } else { -%>
<p class="cell-muted"><%= t("admin.permissions.noMembers") %></p>
<% } -%>
</section>
<section class="form-card" aria-labelledby="effective-h">
<h2 class="card-title" id="effective-h"><%= t("admin.permissions.effective") %></h2>
<p class="field-hint"><%= t("admin.permissions.effectiveHint") %></p>
<% if (effective.length) { -%>
<ul class="plain-list">
<% effective.forEach((u) => { -%>
<li><span class="cell-strong"><%= u.label %></span></li>
<% }) -%>
</ul>
<% } else { -%>
<p class="cell-muted"><%= t("admin.permissions.noEffective") %></p>
<% } -%>
</section>
<section class="form-card" aria-labelledby="add-h">
<h2 class="card-title" id="add-h"><%= t("admin.permissions.assign") %></h2>
<% if (add.options.length) { -%>
<form class="inline-form" method="post" action="<%= localeHref(add.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member"><%= t("admin.common.member") %></label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected><%= t("admin.common.chooseMember") %></option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg><%= t("admin.permissions.assignAction") %></button></form>
<% } else { -%>
<p class="cell-muted"><%= t("admin.permissions.allAssigned") %></p>
<% } -%>
</section>
<section class="form-card admin-actions" aria-label="<%= t("admin.permissions.actions") %>">
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.permissions.delete") %></a>
</section>
</div>
@@ -1,26 +0,0 @@
<%#
Admin permission create form body, 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;
-%>
<div class="form-page">
<% if (locals.error) { -%>
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<%- include("partials/field", form.nameField) %>
<div class="field">
<label for="member"><%= t("admin.permissions.assignTo") %></label>
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>><%= t("admin.common.chooseMember") %></option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
<span class="field-hint">A permission exists once assigned; add more users or groups after creating it.</span>
</div>
<div class="form-actions">
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("common.cancel") %></a>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
</div>
</form>
</div>
@@ -0,0 +1,26 @@
<%#
The permission picker, shared by the user-edit and group-detail pages. A fieldset of checkboxes —
one per permission the installed plugins declare — ticked where this user/group already holds it.
The whole set posts back, so what is submitted IS the desired state (see admin-grants.ts).
Locals: csrfToken, permissions ({ action, choices, empty, field, legend, submit }).
%>
<section class="form-card" aria-labelledby="permissions-h">
<h2 class="card-title" id="permissions-h"><%= permissions.legend %></h2>
<% if (permissions.empty) { -%>
<p class="cell-muted"><%= permissions.empty %></p>
<% } else { -%>
<form method="post" action="<%= localeHref(permissions.action) %>">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<fieldset class="check-list">
<legend class="sr-only"><%= permissions.legend %></legend>
<% permissions.choices.forEach((c, i) => { -%>
<div class="check-row">
<input type="checkbox" id="perm-<%= i %>" name="<%= permissions.field %>" value="<%= c.name %>"<%= c.checked ? " checked" : "" %>>
<label for="perm-<%= i %>"><span class="cell-strong"><%= c.name %></span><% if (c.description) { %><span class="cell-muted"><%= c.description %></span><% } %></label>
</div>
<% }) -%>
</fieldset>
<button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><%= permissions.submit %></button>
</form>
<% } -%>
</section>
@@ -26,6 +26,9 @@
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
</div>
</form>
<% if (edit && locals.permissions) { -%>
<%- include("partials/permission-picker", { csrfToken: form.csrfToken, permissions: locals.permissions }) %>
<% } -%>
<% if (edit) { -%>
<section class="form-card admin-actions" aria-label="<%= t("admin.users.actions") %>">
<form method="post" action="<%= localeHref(edit.recoveryAction) %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg><%= t("admin.users.recovery.generate") %></button></form>
@@ -1,16 +0,0 @@
<%#
Permission admin detail page: the permission-detail body (members · effective access) in the shell.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/permission-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, effective: model.effective, error: model.error, members: model.members, permission: model.permission });
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
@@ -1,16 +0,0 @@
<%#
Permission admin create page: the permission-form body captured into the app shell.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/permission-form-body", { error: model.error, form: model.form });
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
@@ -1,21 +0,0 @@
<%#
Permissions admin list: the same building blocks as the Groups screen, around the shell, backed
by live Keto Permission subject sets (admin-permissions.ts). Filter/sort/page round-trip the URL.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.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 = '<a class="btn btn-primary" href="' + localeHref("/admin/permissions/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.permissions.new") + '</a>';
-%>
<%- include("partials/shell", {
actions,
body: filters + table + pager,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
+1 -1
View File
@@ -2,7 +2,7 @@
Users admin create/edit page: the user-form body captured into the app shell.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/user-form-body", { edit: model.edit, error: model.error, form: model.form, recovery: model.recovery });
const body = include("partials/user-form-body", { edit: model.edit, error: model.error, form: model.form, permissions: model.permissions, recovery: model.recovery });
-%>
<%- include("partials/shell", {
body,
+1 -1
View File
@@ -18,7 +18,7 @@ function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; ve
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
return {
chrome: CHROME, user: null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
chrome: CHROME, declaredPermissions: [], user: null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url,
verifyCsrf: opts.verifyCsrf ?? (() => true),
};