Permissions are a fixed list from plugin code; grant them on Users and Groups
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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) };
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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,
|
||||
}) %>
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user