Rename the coarse gate from role to permission, matching RBAC
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Admin — the system-administration plugin
|
||||
|
||||
The Users / Groups / Roles / OAuth2-clients screens for running Plainpages itself. These used to be
|
||||
The Users / Groups / Permissions / 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:
|
||||
@@ -10,7 +10,7 @@ cp -r examples/plugins/admin plugins/admin
|
||||
docker compose restart web
|
||||
```
|
||||
|
||||
The seeded `admin@plainpages.local` already holds the `admin` role, so the section appears in the
|
||||
The seeded `admin@plainpages.local` already holds the `admin` permission, so the section appears in the
|
||||
menu and the screens work immediately.
|
||||
|
||||
## What it demonstrates — a *system* plugin
|
||||
@@ -20,21 +20,21 @@ 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, Roles).
|
||||
- **`ctx.system.keto`** — read/write the Keto relationship graph (Groups, Permissions).
|
||||
- **`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 role change kills that subject's live tokens at once instead of waiting out the JWT TTL.
|
||||
user's permission change kills that subject's live tokens at once instead of waiting out the JWT TTL.
|
||||
|
||||
`ctx.system` is populated only when the host wired those services (the dev stack wires Kratos + Keto,
|
||||
and Hydra when configured). Where a capability is absent the screen degrades to a themed 503 rather
|
||||
than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin: folder-discovered,
|
||||
gated per route by `role: "admin"`, rendering the core building blocks in `views/`.
|
||||
gated per route by `permission: "admin"`, rendering the core building blocks in `views/`.
|
||||
|
||||
## Layout
|
||||
|
||||
- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` role, and the
|
||||
route table — one thin handler per method+path, all gated by `role: "admin"`.
|
||||
- `admin-users.ts` · `admin-groups.ts` · `admin-roles.ts` · `admin-clients.ts` — each a set of pure
|
||||
- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` permission, and the
|
||||
route table — one thin handler per method+path, all gated by `permission: "admin"`.
|
||||
- `admin-users.ts` · `admin-groups.ts` · `admin-permissions.ts` · `admin-clients.ts` — each a set of pure
|
||||
view-model builders (unit-tested in the matching `*.test.ts`) plus thin per-route handlers keyed on
|
||||
`ctx.params` (the host extracts `:id`/`:name`), sharing a small `withX` wrapper that resolves the
|
||||
admin gate + the needed `ctx.system` clients once.
|
||||
|
||||
+39
-39
@@ -1,34 +1,34 @@
|
||||
// Built-in Roles admin screen: the pure view-model + Keto builders. A role is a
|
||||
// Keto subject set (Role:<name>#members); members are users (subject_id) or groups (subject_set) —
|
||||
// "assign roles to users/groups". The "effective access" view flattens a Keto `expand` tree into the
|
||||
// distinct set of users who hold the role directly or transitively via a group. The HTTP
|
||||
// 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 {
|
||||
buildRoleDetailModel,
|
||||
buildRoleFormModel,
|
||||
buildRolesListModel,
|
||||
buildPermissionDetailModel,
|
||||
buildPermissionFormModel,
|
||||
buildPermissionsListModel,
|
||||
expandToEffectiveUsers,
|
||||
isValidRoleName,
|
||||
roleMemberTuple,
|
||||
} from "./admin-roles.ts";
|
||||
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 = (role: string, n: number): RelationTuple =>
|
||||
({ namespace: "Role", object: role, relation: "members", subject_id: `identity:${uid(n)}` });
|
||||
const groupTuple = (role: string, group: string): RelationTuple =>
|
||||
({ namespace: "Role", object: role, relation: "members", subject_set: { namespace: "Group", object: group, relation: "members" } });
|
||||
const userTuple = (permission: string, n: number): RelationTuple =>
|
||||
({ namespace: "Permission", object: permission, relation: "granted", subject_id: `identity:${uid(n)}` });
|
||||
const groupTuple = (permission: string, group: string): RelationTuple =>
|
||||
({ namespace: "Permission", object: permission, relation: "granted", subject_set: { namespace: "Group", object: group, relation: "members" } });
|
||||
|
||||
test("isValidRoleName + roleMemberTuple map the form value to a Role tuple over a user/group (else null)", () => {
|
||||
test("isValidRoleName + permissionGrantTuple map the form value to a Permission tuple over a user/group (else null)", () => {
|
||||
for (const ok of ["admin", "editor", "team-a", "a1_b9"]) assert.equal(isValidRoleName(ok), true, ok);
|
||||
for (const bad of ["", "Admin", "a b", "-bad", "a".repeat(65)]) assert.equal(isValidRoleName(bad), false, bad);
|
||||
|
||||
assert.deepEqual(roleMemberTuple("editor", `identity:${uid(2)}`), { namespace: "Role", object: "editor", relation: "members", subject_id: `identity:${uid(2)}` });
|
||||
assert.deepEqual(roleMemberTuple("editor", "group:eng"), { namespace: "Role", object: "editor", relation: "members", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
|
||||
for (const bad of ["", "identity:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(roleMemberTuple("editor", bad), null, bad);
|
||||
assert.deepEqual(permissionGrantTuple("editor", `identity:${uid(2)}`), { namespace: "Permission", object: "editor", relation: "granted", subject_id: `identity:${uid(2)}` });
|
||||
assert.deepEqual(permissionGrantTuple("editor", "group:eng"), { namespace: "Permission", object: "editor", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
|
||||
for (const bad of ["", "identity:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(permissionGrantTuple("editor", bad), null, bad);
|
||||
});
|
||||
|
||||
test("expandToEffectiveUsers flattens an expand tree → sorted distinct user ids, transitive through groups", () => {
|
||||
@@ -43,50 +43,50 @@ test("expandToEffectiveUsers flattens an expand tree → sorted distinct user id
|
||||
type: "union",
|
||||
},
|
||||
],
|
||||
tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Role", object: "admin", relation: "members" } },
|
||||
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 role
|
||||
assert.deepEqual(expandToEffectiveUsers({ type: "leaf" }), []); // an empty permission
|
||||
});
|
||||
|
||||
test("buildRolesListModel filters by search, sorts, paginates; the name links to the detail page", () => {
|
||||
const roles = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `role-${String(i).padStart(2, "0")}` }));
|
||||
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 = buildRolesListModel({ roles, url: "http://x/admin/roles" });
|
||||
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, "Roles");
|
||||
assert.equal(all.title, "Permissions");
|
||||
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
|
||||
assert.equal(first.rowHeader.text, "role-00");
|
||||
assert.equal(first.rowHeader.href, "/admin/roles/role-00");
|
||||
assert.equal(first.rowHeader.text, "permission-00");
|
||||
assert.equal(first.rowHeader.href, "/admin/permissions/permission-00");
|
||||
|
||||
const one = buildRolesListModel({ roles, url: "http://x/admin/roles?q=role-07" });
|
||||
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 = buildRolesListModel({ roles, url: "http://x/admin/roles?sort=-members" });
|
||||
assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "role-29");
|
||||
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("buildRoleFormModel: a create form with a required name field + member options (user or group)", () => {
|
||||
test("buildPermissionFormModel: a create form with a required name field + member options (user or group)", () => {
|
||||
const options = [{ label: "ada@example.com", value: `identity:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
|
||||
const m = buildRoleFormModel({ csrfToken: "tok.sig", memberOptions: options });
|
||||
assert.equal(m.title, "New role");
|
||||
assert.equal(m.form.action, "/admin/roles");
|
||||
assert.equal(m.form.submitLabel, "Create role");
|
||||
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 = buildRoleFormModel({ error: "That name is taken.", memberOptions: options, values: { member: "group:eng", name: "Admin" } });
|
||||
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("buildRoleDetailModel: members → rows, add-options exclude current members, effective access listed, actions wired", () => {
|
||||
test("buildPermissionDetailModel: members → rows, add-options exclude current members, effective access listed, actions wired", () => {
|
||||
const members = [memberView(userTuple("admin", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("admin", "eng"), new Map())];
|
||||
const candidates = [
|
||||
{ label: "ada@example.com", value: `identity:${uid(1)}` }, // already a member → excluded
|
||||
@@ -95,12 +95,12 @@ test("buildRoleDetailModel: members → rows, add-options exclude current member
|
||||
{ label: "ops (group)", value: "group:ops" },
|
||||
];
|
||||
const effective = [{ label: "ada@example.com" }, { label: "grace@example.com" }]; // ada direct, grace via eng
|
||||
const m = buildRoleDetailModel({ candidates, effective, members, role: { name: "admin" } });
|
||||
const m = buildPermissionDetailModel({ candidates, effective, members, permission: { name: "admin" } });
|
||||
assert.equal(m.title, "admin");
|
||||
assert.equal(m.members.rows.length, 2);
|
||||
assert.equal(m.members.action, "/admin/roles/admin/members/delete");
|
||||
assert.equal(m.add.action, "/admin/roles/admin/members");
|
||||
assert.equal(m.members.action, "/admin/permissions/admin/members/delete");
|
||||
assert.equal(m.add.action, "/admin/permissions/admin/members");
|
||||
assert.deepEqual(m.add.options.map((o) => o.value), [`identity:${uid(2)}`, "group:ops"]);
|
||||
assert.deepEqual(m.effective.map((e) => e.label), ["ada@example.com", "grace@example.com"]);
|
||||
assert.equal(m.delete.action, "/admin/roles/admin/delete");
|
||||
assert.equal(m.delete.action, "/admin/permissions/admin/delete");
|
||||
});
|
||||
+84
-84
@@ -1,15 +1,15 @@
|
||||
// Roles admin screen: list / create / delete Keto roles and assign
|
||||
// them to users and groups. A role is a Keto subject set `Role:<name>#members` (OPL: members are users
|
||||
// or groups, resolved transitively) — the source of truth for the JWT `roles` claim. It shares the
|
||||
// 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 role-specific piece is the **effective access** view:
|
||||
// `keto.expand(Role:<name>#members)` flattened to the distinct users who hold the role directly or via
|
||||
// a group — matching what login projects into the JWT (login.ts readRoles). Writes go only to Keto;
|
||||
// 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, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SessionIdentity } from "#plugin-api";
|
||||
import { ADMIN_ROLE, ADMIN_ROLES_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
||||
import { ADMIN_PERMISSION, ADMIN_PERMISSIONS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
||||
import {
|
||||
type GroupView,
|
||||
groupsFromTuples,
|
||||
@@ -23,29 +23,29 @@ import {
|
||||
} from "./admin-groups.ts";
|
||||
import type { FieldConfig } from "./admin-users.ts";
|
||||
|
||||
const ROLE_NS = "Role";
|
||||
const MEMBERS = "members";
|
||||
const PERMISSION_NS = "Permission";
|
||||
const GRANTED = "granted";
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
const PAGE_SIZES = [25, 50, 100];
|
||||
// Expand far past any sane group-nesting depth so the effective-access view never silently
|
||||
// under-reports the deepest members (Keto's own default is shallow).
|
||||
const EXPAND_MAX_DEPTH = 50;
|
||||
|
||||
// A role and a group share the URL-safe name rule and the user|group membership model.
|
||||
export type RoleView = GroupView;
|
||||
// A permission and a group share the URL-safe name rule and the user|group membership model.
|
||||
export type PermissionView = GroupView;
|
||||
export const isValidRoleName = isValidGroupName;
|
||||
export const rolesFromTuples = groupsFromTuples;
|
||||
export const permissionsFromTuples = groupsFromTuples;
|
||||
export interface EffectiveUser {
|
||||
label: string; // email (or the raw id when unresolved)
|
||||
}
|
||||
|
||||
// The full membership tuple for assigning/revoking `value` to/from `role` (null if value is invalid).
|
||||
export function roleMemberTuple(role: string, value: string): RelationTuple | null {
|
||||
// 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: ROLE_NS, object: role, relation: MEMBERS, ...subject } : null;
|
||||
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 role
|
||||
// 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.
|
||||
@@ -70,17 +70,17 @@ interface ListState {
|
||||
sort: string | null;
|
||||
}
|
||||
|
||||
const SORT: Record<string, (r: RoleView) => number | string> = {
|
||||
const SORT: Record<string, (r: PermissionView) => number | string> = {
|
||||
members: (r) => r.memberCount,
|
||||
name: (r) => r.name,
|
||||
};
|
||||
const COLUMNS = [
|
||||
{ key: "name", label: "Role" },
|
||||
{ key: "name", label: "Permission" },
|
||||
{ key: "members", label: "Members" },
|
||||
];
|
||||
|
||||
function detailHref(name: string): string {
|
||||
return `${ADMIN_ROLES_BASE}/${encodeURIComponent(name)}`;
|
||||
return `${ADMIN_PERMISSIONS_BASE}/${encodeURIComponent(name)}`;
|
||||
}
|
||||
|
||||
function listHref(state: ListState, overrides: Partial<ListState> = {}): string {
|
||||
@@ -91,12 +91,12 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
|
||||
if (s.page > 1) p.set("page", String(s.page));
|
||||
if (s.pageSize !== DEFAULT_PAGE_SIZE) p.set("pageSize", String(s.pageSize));
|
||||
const qs = p.toString();
|
||||
return qs ? `${ADMIN_ROLES_BASE}?${qs}` : ADMIN_ROLES_BASE;
|
||||
return qs ? `${ADMIN_PERMISSIONS_BASE}?${qs}` : ADMIN_PERMISSIONS_BASE;
|
||||
}
|
||||
|
||||
export function buildRolesListModel(opts: {
|
||||
export function buildPermissionsListModel(opts: {
|
||||
csrfToken?: string;
|
||||
roles: RoleView[];
|
||||
permissions: PermissionView[];
|
||||
url: URL | URLSearchParams | string;
|
||||
}) {
|
||||
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
||||
@@ -104,7 +104,7 @@ export function buildRolesListModel(opts: {
|
||||
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
|
||||
const needle = query.q.toLowerCase();
|
||||
|
||||
let list = opts.roles.filter((r) => !needle || r.name.toLowerCase().includes(needle));
|
||||
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;
|
||||
@@ -121,17 +121,17 @@ export function buildRolesListModel(opts: {
|
||||
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken };
|
||||
|
||||
return {
|
||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Admin" }, { label: "Roles" }],
|
||||
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Admin" }, { label: "Permissions" }],
|
||||
filterBar: listFilterBar(state),
|
||||
pagination: listPagination(state, page),
|
||||
table: listTable(rows, state, sort),
|
||||
title: "Roles",
|
||||
title: "Permissions",
|
||||
};
|
||||
}
|
||||
|
||||
function listTable(rows: RoleView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null) {
|
||||
function listTable(rows: PermissionView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null) {
|
||||
return {
|
||||
caption: "Roles",
|
||||
caption: "Permissions",
|
||||
columns: COLUMNS.map((c) => {
|
||||
const dir = sort && sort.field === c.key ? sort.dir : undefined;
|
||||
const next = dir === "asc" ? `-${c.key}` : c.key;
|
||||
@@ -149,11 +149,11 @@ function listFilterBar(state: ListState) {
|
||||
if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||
return {
|
||||
applyLabel: "Apply",
|
||||
clearHref: ADMIN_ROLES_BASE,
|
||||
label: "Filter roles",
|
||||
clearHref: ADMIN_PERMISSIONS_BASE,
|
||||
label: "Filter permissions",
|
||||
pills,
|
||||
rows: [[
|
||||
{ label: "Search roles", name: "q", placeholder: "Search role name…", type: "search", value: state.q },
|
||||
{ label: "Search permissions", name: "q", placeholder: "Search permission name…", type: "search", value: state.q },
|
||||
{ type: "spacer" },
|
||||
]],
|
||||
};
|
||||
@@ -178,7 +178,7 @@ function listPagination(state: ListState, page: ReturnType<typeof paginate>) {
|
||||
|
||||
// ---- create form + detail view models ----
|
||||
|
||||
export function buildRoleFormModel(opts: {
|
||||
export function buildPermissionFormModel(opts: {
|
||||
csrfToken?: string;
|
||||
error?: string;
|
||||
memberOptions: MemberOption[];
|
||||
@@ -186,69 +186,69 @@ export function buildRoleFormModel(opts: {
|
||||
}) {
|
||||
const nameField: FieldConfig = {
|
||||
autocomplete: "off", hint: "Lowercase letters, digits, dashes and underscores.", icon: "i-shield",
|
||||
id: "name", label: "Role name", name: "name", required: true, value: opts.values?.name ?? "",
|
||||
id: "name", label: "Permission name", name: "name", required: true, value: opts.values?.name ?? "",
|
||||
};
|
||||
return {
|
||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: "New" }],
|
||||
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Permissions" }, { label: "New" }],
|
||||
error: opts.error,
|
||||
form: {
|
||||
action: ADMIN_ROLES_BASE,
|
||||
cancelHref: ADMIN_ROLES_BASE,
|
||||
action: ADMIN_PERMISSIONS_BASE,
|
||||
cancelHref: ADMIN_PERMISSIONS_BASE,
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
memberOptions: opts.memberOptions,
|
||||
nameField,
|
||||
selectedMember: opts.values?.member ?? "",
|
||||
submitLabel: "Create role",
|
||||
submitLabel: "Create permission",
|
||||
},
|
||||
title: "New role",
|
||||
title: "New permission",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRoleDetailModel(opts: {
|
||||
export function buildPermissionDetailModel(opts: {
|
||||
candidates: MemberOption[];
|
||||
csrfToken?: string;
|
||||
effective: EffectiveUser[];
|
||||
error?: string;
|
||||
members: MemberView[];
|
||||
role: { name: string };
|
||||
permission: { name: string };
|
||||
}) {
|
||||
const name = opts.role.name;
|
||||
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 role itself
|
||||
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_ROLES_BASE, label: "Roles" }, { label: name }],
|
||||
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Permissions" }, { label: name }],
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
delete: { action: `${base}/delete` },
|
||||
effective: opts.effective,
|
||||
error: opts.error,
|
||||
members: { action: `${base}/members/delete`, rows: opts.members },
|
||||
role: { name },
|
||||
permission: { name },
|
||||
title: name,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- request handler (imperative shell) ----
|
||||
|
||||
// instant-revoke: a role change for a `identity:<id>` member must take effect now, so revoke that
|
||||
// user's live tokens (a re-mint then re-reads roles from Keto). A `group:<name>` change is
|
||||
// instant-revoke: a permission change for a `identity:<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("identity:")) revoke(member.slice("identity:".length));
|
||||
}
|
||||
|
||||
// A role exists exactly while it has ≥1 member (Keto has no create-object).
|
||||
// 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: ROLE_NS, object: name, relation: MEMBERS, pageSize: 1 });
|
||||
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 role (expand → flatten → label by email). Skipped for
|
||||
// an empty role (no member tuples) so we don't expand a non-existent Keto object.
|
||||
// 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: ROLE_NS, object: name, relation: MEMBERS }, { maxDepth: EXPAND_MAX_DEPTH });
|
||||
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) ?? `identity:${id}` }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
@@ -268,7 +268,7 @@ function withRoles(inner: (deps: RolesDeps) => Promise<RouteResult>): RouteHandl
|
||||
};
|
||||
}
|
||||
|
||||
// Same, plus the validated :name from ctx.params (an invalid role name → themed 404).
|
||||
// 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"] ?? "";
|
||||
@@ -279,89 +279,89 @@ function withRoleName(inner: (deps: RolesDeps, name: string) => Promise<RouteRes
|
||||
|
||||
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: buildRoleFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, ...extra }) }, view: "role-form" };
|
||||
return { data: { chrome: deps.ctx.chrome, model: buildPermissionFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, ...extra }) }, view: "permission-form" };
|
||||
};
|
||||
|
||||
// The role detail (members + effective access). With `error` set it's a 400 (a rejected action).
|
||||
// The permission detail (members + effective access). With `error` set it's a 400 (a rejected action).
|
||||
const roleDetailResult = 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: ROLE_NS, object: name, relation: MEMBERS });
|
||||
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: buildRoleDetailModel({ candidates: options, csrfToken: deps.ctx.chrome.csrfToken, effective, members, role: { name }, ...(error ? { error } : {}) }) }, view: "role-detail" };
|
||||
const result: RouteResult = { data: { chrome: deps.ctx.chrome, model: buildPermissionDetailModel({ candidates: options, csrfToken: deps.ctx.chrome.csrfToken, effective, members, permission: { name }, ...(error ? { error } : {}) }) }, view: "permission-detail" };
|
||||
return error ? { ...result, status: 400 } : result;
|
||||
};
|
||||
|
||||
// GET /admin/roles — the list.
|
||||
// GET /admin/permissions — the list.
|
||||
export const rolesList = withRoles(async ({ ctx, keto }) => {
|
||||
const roles = rolesFromTuples(await pagedTuples(keto, { namespace: ROLE_NS, relation: MEMBERS }));
|
||||
return { data: { chrome: ctx.chrome, model: buildRolesListModel({ csrfToken: ctx.chrome.csrfToken, roles, url: ctx.url }) }, view: "roles" };
|
||||
const permissions = permissionsFromTuples(await pagedTuples(keto, { namespace: PERMISSION_NS, relation: GRANTED }));
|
||||
return { data: { chrome: ctx.chrome, model: buildPermissionsListModel({ csrfToken: ctx.chrome.csrfToken, permissions, url: ctx.url }) }, view: "permissions" };
|
||||
});
|
||||
|
||||
// POST /admin/roles — create + assign the first member (a *user* grant revokes their live tokens).
|
||||
// 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 = roleMemberTuple(name, member);
|
||||
const tuple = permissionGrantTuple(name, member);
|
||||
const reject = async (error: string): Promise<RouteResult> => ({ ...(await roleFormResult(deps, { error, values: { member, name } })), status: 400 });
|
||||
if (!isValidRoleName(name)) return reject("Role names use lowercase letters, digits, dashes and underscores.");
|
||||
if (!tuple) return reject("Pick a user or group to assign the role to.");
|
||||
if (await roleExists(keto, name)) return reject("A role with that name already exists.");
|
||||
if (!isValidRoleName(name)) return reject("Permission names use lowercase letters, digits, dashes and underscores.");
|
||||
if (!tuple) return reject("Pick a user or group to assign the permission to.");
|
||||
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: role created + first member assigned", { actor: user.id, member, role: name });
|
||||
ctx.log.info("admin: permission created + first member assigned", { actor: user.id, member, permission: name });
|
||||
return { redirect: detailHref(name) };
|
||||
});
|
||||
|
||||
// GET /admin/roles/new — the create form.
|
||||
// GET /admin/permissions/new — the create form.
|
||||
export const rolesNewForm = withRoles((deps) => roleFormResult(deps, {}));
|
||||
|
||||
// GET /admin/roles/:name — the detail (members + effective access via Keto expand).
|
||||
// GET /admin/permissions/:name — the detail (members + effective access via Keto expand).
|
||||
export const rolesDetail = withRoleName((deps, name) => roleDetailResult(deps, name));
|
||||
|
||||
// POST /admin/roles/:name/members — assign a user/group; a *user* grant revokes their live tokens.
|
||||
// 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))!;
|
||||
const member = (form.get("member") ?? "").trim();
|
||||
const tuple = roleMemberTuple(name, member); // the picker only offers real users/groups
|
||||
if (tuple) { await keto.writeTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: role assigned", { actor: user.id, member, role: name }); }
|
||||
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/roles/:name/delete — confirm, except the admin role can't be deleted.
|
||||
// GET /admin/permissions/:name/delete — confirm, except the admin permission can't be deleted.
|
||||
export const rolesDeleteConfirm = withRoleName((deps, name) => {
|
||||
if (name === ADMIN_ROLE) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access.");
|
||||
if (name === ADMIN_PERMISSION) return roleDetailResult(deps, name, "The admin permission can't be deleted — it would remove all admin access.");
|
||||
const base = detailHref(name);
|
||||
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { href: base, label: name }, { label: "Delete" }],
|
||||
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete role",
|
||||
message: `Delete role ${name}? This revokes it from everyone it's assigned to.`, title: "Delete role",
|
||||
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Permissions" }, { href: base, label: name }, { label: "Delete" }],
|
||||
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete permission",
|
||||
message: `Delete permission ${name}? This revokes it from everyone it's assigned to.`, title: "Delete permission",
|
||||
}) }, view: "confirm" });
|
||||
});
|
||||
|
||||
// POST /admin/roles/:name/delete — remove every member tuple (a whole-role delete lags per the
|
||||
// documented instant-revoke tradeoff; the admin role is protected).
|
||||
// POST /admin/permissions/:name/delete — remove every member tuple (a whole-permission delete lags per the
|
||||
// documented instant-revoke tradeoff; the admin permission is protected).
|
||||
export const rolesDelete = withRoleName(async (deps, name) => {
|
||||
const { ctx, keto, user } = deps;
|
||||
await guardedForm(ctx); // CSRF-verify the POST
|
||||
if (name === ADMIN_ROLE) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access.");
|
||||
await keto.deleteTuple({ namespace: ROLE_NS, object: name, relation: MEMBERS });
|
||||
ctx.log.info("admin: role deleted", { actor: user.id, role: name });
|
||||
return { redirect: ADMIN_ROLES_BASE };
|
||||
if (name === ADMIN_PERMISSION) return roleDetailResult(deps, name, "The admin permission can't be deleted — it would remove all admin access.");
|
||||
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/roles/:name/members/delete — unassign; a *user* unassign revokes their live tokens.
|
||||
// POST /admin/permissions/:name/members/delete — unassign; a *user* unassign revokes their live tokens.
|
||||
// Self-protection: an admin can't revoke their own *direct* admin grant (a group-held admin isn't
|
||||
// covered — the robust "last effective admin" check is deferred).
|
||||
export const rolesRemoveMember = withRoleName(async (deps, name) => {
|
||||
const { ctx, keto, revoke, user } = deps;
|
||||
const form = (await guardedForm(ctx))!;
|
||||
const member = (form.get("member") ?? "").trim();
|
||||
if (name === ADMIN_ROLE && member === `identity:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access.");
|
||||
const tuple = roleMemberTuple(name, member);
|
||||
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: role unassigned", { actor: user.id, member, role: name }); }
|
||||
if (name === ADMIN_PERMISSION && member === `identity:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access.");
|
||||
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) };
|
||||
});
|
||||
@@ -7,10 +7,10 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Readable } from "node:stream";
|
||||
import { test } from "node:test";
|
||||
import { GuardError, type Log, type PageChrome, type RequestContext, type SessionIdentity } from "#plugin-api";
|
||||
import { ADMIN_NAV, ADMIN_ROLE, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts";
|
||||
import { ADMIN_NAV, ADMIN_PERMISSION, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts";
|
||||
|
||||
const admin: SessionIdentity = { email: "ada@x.io", id: "u1", roles: ["admin"] };
|
||||
const member: SessionIdentity = { email: "bo@x.io", id: "u2", roles: ["scheduling:read"] };
|
||||
const admin: SessionIdentity = { email: "ada@x.io", id: "u1", permissions: ["admin"] };
|
||||
const member: SessionIdentity = { email: "bo@x.io", id: "u2", permissions: ["scheduling:read"] };
|
||||
const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome;
|
||||
|
||||
function fakeCtx(opts: { body?: string; method?: string; user?: SessionIdentity | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
||||
@@ -19,7 +19,7 @@ function fakeCtx(opts: { body?: string; method?: string; user?: SessionIdentity
|
||||
req.method = opts.method ?? "GET";
|
||||
return {
|
||||
chrome: CHROME, identity: opts.user ?? null, log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse,
|
||||
roles: opts.user?.roles ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||
permissions: opts.user?.permissions ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,11 +27,11 @@ function fakeCtx(opts: { body?: string; method?: string; user?: SessionIdentity
|
||||
|
||||
test("ADMIN_NAV: a gated Admin header over the four screens; no per-request current/open state", () => {
|
||||
assert.equal(ADMIN_NAV.id, "admin");
|
||||
assert.equal(ADMIN_NAV.role, ADMIN_ROLE); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin
|
||||
assert.equal(ADMIN_NAV.permission, ADMIN_PERMISSION); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin
|
||||
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/roles", "/admin/clients"]);
|
||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["Users", "Groups", "Roles", "OAuth2 clients"]);
|
||||
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.role === undefined)); // the header's gate covers the subtree
|
||||
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.label), ["Users", "Groups", "Permissions", "OAuth2 clients"]);
|
||||
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.permission === undefined)); // the header's gate covers the subtree
|
||||
});
|
||||
|
||||
// ---- auth gates ----
|
||||
|
||||
@@ -5,36 +5,36 @@
|
||||
|
||||
import { can, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type SessionIdentity } from "#plugin-api";
|
||||
|
||||
export const ADMIN_ROLE = "admin"; // the role gating the whole admin section
|
||||
export const ADMIN_PERMISSION = "admin"; // the permission gating the whole admin section
|
||||
export const ADMIN_USERS_BASE = "/admin/users";
|
||||
export const ADMIN_GROUPS_BASE = "/admin/groups";
|
||||
export const ADMIN_ROLES_BASE = "/admin/roles";
|
||||
export const ADMIN_PERMISSIONS_BASE = "/admin/permissions";
|
||||
export const ADMIN_CLIENTS_BASE = "/admin/clients";
|
||||
|
||||
export type AdminScreen = "clients" | "groups" | "roles" | "users";
|
||||
export type AdminScreen = "clients" | "groups" | "permissions" | "users";
|
||||
|
||||
// The plugin's nav fragment: the gated "Admin" header + its four screens. The host composes it into
|
||||
// the one global menu, filters per user (the header's `role` drops the whole subtree for a
|
||||
// the one global menu, filters per user (the header's `permission` drops the whole subtree for a
|
||||
// non-admin), and current-marks the active item — so there is no `current`/`open` state here.
|
||||
export const ADMIN_NAV: NavNode = {
|
||||
children: [
|
||||
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "Users" },
|
||||
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "Groups" },
|
||||
{ href: ADMIN_ROLES_BASE, icon: "i-shield", id: "roles", label: "Roles" },
|
||||
{ href: ADMIN_PERMISSIONS_BASE, icon: "i-shield", id: "permissions", label: "Permissions" },
|
||||
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "OAuth2 clients" },
|
||||
],
|
||||
icon: "i-shield",
|
||||
id: "admin",
|
||||
label: "Admin",
|
||||
role: ADMIN_ROLE,
|
||||
permission: ADMIN_PERMISSION,
|
||||
};
|
||||
|
||||
// The admin gate: a signed-in admin only. Each route already declares `role: "admin"`, so the
|
||||
// The admin gate: a signed-in admin only. Each route already declares `permission: "admin"`, so the
|
||||
// host enforces this before the handler runs; this is defence-in-depth and what a direct unit test
|
||||
// relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403.
|
||||
export function requireAdmin(ctx: RequestContext): SessionIdentity {
|
||||
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
|
||||
if (!can(ctx, ADMIN_ROLE)) throw new GuardError(403, "admin role required");
|
||||
if (!can(ctx, ADMIN_PERMISSION)) throw new GuardError(403, "admin permission required");
|
||||
return user;
|
||||
}
|
||||
|
||||
|
||||
@@ -268,7 +268,7 @@ function readUserInput(form: URLSearchParams): UserInput {
|
||||
// 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: SessionIdentity; }
|
||||
|
||||
// Resolve the shared deps, then run `inner`. The route's `role: "admin"` already gated at the
|
||||
// Resolve the shared deps, then run `inner`. The route's `permission: "admin"` already gated at the
|
||||
// host; `requireAdmin` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it.
|
||||
function withUser(inner: (deps: UsersDeps) => Promise<RouteResult>): RouteHandler {
|
||||
return async (ctx) => {
|
||||
|
||||
@@ -9,21 +9,21 @@
|
||||
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-roles.ts";
|
||||
import { rolesAddMember, rolesCreate, rolesDelete, rolesDeleteConfirm, rolesDetail, rolesList, rolesNewForm, rolesRemoveMember } from "./admin-permissions.ts";
|
||||
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
|
||||
import { ADMIN_NAV, ADMIN_ROLE } from "./admin-shared.ts";
|
||||
import { ADMIN_NAV, ADMIN_PERMISSION } from "./admin-shared.ts";
|
||||
|
||||
// Every admin route is gated by the one `admin` role — the host redirects an anonymous visitor
|
||||
// Every admin route is gated by the one `admin` permission — the host redirects an anonymous visitor
|
||||
// to /login, gives a signed-in non-admin the 403 page, and filters the nav the same way. Handlers are
|
||||
// thin and keyed on ctx.params (the host extracts :id / :name), the idiomatic per-route style.
|
||||
const r = (method: HttpMethod, path: string, handler: RouteHandler): Route => ({ handler, method, path, role: ADMIN_ROLE });
|
||||
const r = (method: HttpMethod, path: string, handler: RouteHandler): Route => ({ handler, method, path, permission: ADMIN_PERMISSION });
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||
|
||||
nav: [ADMIN_NAV],
|
||||
|
||||
roles: [{ description: "Administer users, groups, roles, and OAuth2 clients", name: ADMIN_ROLE }],
|
||||
permissions: [{ description: "Administer users, groups, permissions, and OAuth2 clients", name: ADMIN_PERMISSION }],
|
||||
|
||||
routes: [
|
||||
// Users
|
||||
@@ -46,14 +46,14 @@ export default definePlugin({
|
||||
r("POST", "/groups/:name/delete", groupsDelete),
|
||||
r("POST", "/groups/:name/members/delete", groupsRemoveMember),
|
||||
// Roles
|
||||
r("GET", "/roles", rolesList),
|
||||
r("POST", "/roles", rolesCreate),
|
||||
r("GET", "/roles/new", rolesNewForm),
|
||||
r("GET", "/roles/:name", rolesDetail),
|
||||
r("POST", "/roles/:name/members", rolesAddMember),
|
||||
r("GET", "/roles/:name/delete", rolesDeleteConfirm),
|
||||
r("POST", "/roles/:name/delete", rolesDelete),
|
||||
r("POST", "/roles/:name/members/delete", rolesRemoveMember),
|
||||
r("GET", "/permissions", rolesList),
|
||||
r("POST", "/permissions", rolesCreate),
|
||||
r("GET", "/permissions/new", rolesNewForm),
|
||||
r("GET", "/permissions/:name", rolesDetail),
|
||||
r("POST", "/permissions/:name/members", rolesAddMember),
|
||||
r("GET", "/permissions/:name/delete", rolesDeleteConfirm),
|
||||
r("POST", "/permissions/:name/delete", rolesDelete),
|
||||
r("POST", "/permissions/:name/members/delete", rolesRemoveMember),
|
||||
// OAuth2 clients
|
||||
r("GET", "/clients", clientsList),
|
||||
r("POST", "/clients", clientsCreate),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<%#
|
||||
OAuth2 clients admin list: apps that log in *through* us (Hydra). Same building blocks as
|
||||
the Roles screen, around the shell, backed by live Hydra OAuth2 clients (admin-clients.ts).
|
||||
the Permissions screen, around the shell, backed by live Hydra OAuth2 clients (admin-clients.ts).
|
||||
%><%
|
||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||
const filters = include("partials/filter-bar", model.filterBar);
|
||||
|
||||
+12
-12
@@ -1,13 +1,13 @@
|
||||
<%#
|
||||
Admin role detail body, captured into the shell content slot. Config:
|
||||
role { name }
|
||||
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 role (expand)
|
||||
effective { label }[] users who hold the permission (expand)
|
||||
add { action, options: {label,value}[] } action = assign endpoint
|
||||
del { action } delete the whole role
|
||||
del { action } delete the whole permission
|
||||
csrfToken, error?
|
||||
%><%
|
||||
const role = locals.role;
|
||||
const permission = locals.permission;
|
||||
const members = locals.members;
|
||||
const effective = locals.effective;
|
||||
const add = locals.add;
|
||||
@@ -21,7 +21,7 @@
|
||||
<section class="form-card" aria-labelledby="members-h">
|
||||
<h2 class="card-title" id="members-h">Assigned to</h2>
|
||||
<% if (members.rows.length) { -%>
|
||||
<div class="table-wrap"><table class="table"><caption class="sr-only">Members of <%= role.name %></caption><thead><tr><th scope="col">Member</th><th scope="col">Type</th><th class="col-actions" scope="col"><span class="sr-only">Actions</span></th></tr></thead><tbody>
|
||||
<div class="table-wrap"><table class="table"><caption class="sr-only">Members of <%= permission.name %></caption><thead><tr><th scope="col">Member</th><th scope="col">Type</th><th class="col-actions" scope="col"><span class="sr-only">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" ? "Group" : "User" %></span></td><td class="col-actions"><form method="post" action="<%= 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>Revoke</button></form></td></tr>
|
||||
<% }) -%>
|
||||
@@ -32,7 +32,7 @@
|
||||
</section>
|
||||
<section class="form-card" aria-labelledby="effective-h">
|
||||
<h2 class="card-title" id="effective-h">Effective access</h2>
|
||||
<p class="field-hint">Everyone who holds this role — directly or through a group (resolved by Keto).</p>
|
||||
<p class="field-hint">Everyone who holds this permission — directly or through a group (resolved by Keto).</p>
|
||||
<% if (effective.length) { -%>
|
||||
<ul class="plain-list">
|
||||
<% effective.forEach((u) => { -%>
|
||||
@@ -40,18 +40,18 @@
|
||||
<% }) -%>
|
||||
</ul>
|
||||
<% } else { -%>
|
||||
<p class="cell-muted">No users hold this role yet.</p>
|
||||
<p class="cell-muted">No users hold this permission yet.</p>
|
||||
<% } -%>
|
||||
</section>
|
||||
<section class="form-card" aria-labelledby="add-h">
|
||||
<h2 class="card-title" id="add-h">Assign the role</h2>
|
||||
<h2 class="card-title" id="add-h">Assign the permission</h2>
|
||||
<% if (add.options.length) { -%>
|
||||
<form class="inline-form" method="post" action="<%= add.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member">Member</label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected>Choose a user or group…</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>Assign</button></form>
|
||||
<% } else { -%>
|
||||
<p class="cell-muted">All users and groups already have this role.</p>
|
||||
<p class="cell-muted">All users and groups already have this permission.</p>
|
||||
<% } -%>
|
||||
</section>
|
||||
<section class="form-card admin-actions" aria-label="Role actions">
|
||||
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete role</a>
|
||||
<section class="form-card admin-actions" aria-label="Permission actions">
|
||||
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete permission</a>
|
||||
</section>
|
||||
</div>
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
<%#
|
||||
Admin role create form body, captured into the shell content slot. Config:
|
||||
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
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="field">
|
||||
<label for="member">Assign to</label>
|
||||
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>>Choose a user or group…</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 role exists once assigned; add more users or groups after creating it.</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="<%= form.cancelHref %>">Cancel</a>
|
||||
@@ -0,0 +1,16 @@
|
||||
<%#
|
||||
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,
|
||||
}) %>
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
<%#
|
||||
Role admin create page: the role-form body captured into the app shell.
|
||||
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/role-form-body", { error: model.error, form: model.form });
|
||||
const body = include("partials/permission-form-body", { error: model.error, form: model.form });
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
body,
|
||||
+3
-3
@@ -1,12 +1,12 @@
|
||||
<%#
|
||||
Roles admin list: the same building blocks as the Groups screen, around the shell, backed
|
||||
by live Keto Role subject sets (admin-roles.ts). Filter/sort/page round-trip the URL.
|
||||
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="/admin/roles/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add role</a>';
|
||||
const actions = '<a class="btn btn-primary" href="/admin/permissions/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add permission</a>';
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
actions,
|
||||
@@ -1,16 +0,0 @@
|
||||
<%#
|
||||
Role admin detail page: the role-detail body (members · effective access) in the shell.
|
||||
%><%
|
||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||
const body = include("partials/role-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, effective: model.effective, error: model.error, members: model.members, role: model.role });
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
body,
|
||||
brand: chrome.brand,
|
||||
breadcrumbs: model.breadcrumbs,
|
||||
csrfToken: chrome.csrfToken,
|
||||
nav,
|
||||
theme: chrome.theme,
|
||||
title: model.title,
|
||||
user: chrome.user,
|
||||
}) %>
|
||||
Reference in New Issue
Block a user