Rename the plugin-API permission gate to role
This commit is contained in:
@@ -28,12 +28,12 @@ stack**, so they use the privileged **`ctx.system`** surface the host exposes to
|
||||
`ctx.system` is populated only when the host wired those services (the dev stack wires Kratos + Keto,
|
||||
and Hydra when configured). Where a capability is absent the screen degrades to a themed 503 rather
|
||||
than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin: folder-discovered,
|
||||
gated per route by `permission: "admin"`, rendering the core building blocks in `views/`.
|
||||
gated per route by `role: "admin"`, rendering the core building blocks in `views/`.
|
||||
|
||||
## Layout
|
||||
|
||||
- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` permission token, and the
|
||||
route table — one thin handler per method+path, all gated by `permission: "admin"`.
|
||||
- `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
|
||||
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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Built-in Roles & permissions admin screen: the pure view-model + Keto builders. A role is a
|
||||
// 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Roles & permissions admin screen: list / create / delete Keto roles and assign
|
||||
// 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
|
||||
// Groups screen's membership model, so the pure helpers (parseSubject, member pickers, tuple paging)
|
||||
@@ -9,7 +9,7 @@
|
||||
// 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 User } from "#plugin-api";
|
||||
import { ADMIN_PERMISSION, ADMIN_ROLES_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
||||
import { ADMIN_ROLE, ADMIN_ROLES_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
||||
import {
|
||||
type GroupView,
|
||||
groupsFromTuples,
|
||||
@@ -333,7 +333,7 @@ export const rolesAddMember = withRoleName(async (deps, name) => {
|
||||
|
||||
// GET /admin/roles/:name/delete — confirm, except the admin role can't be deleted.
|
||||
export const rolesDeleteConfirm = withRoleName((deps, name) => {
|
||||
if (name === ADMIN_PERMISSION) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access.");
|
||||
if (name === ADMIN_ROLE) return roleDetailResult(deps, name, "The admin role 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" }],
|
||||
@@ -347,7 +347,7 @@ export const rolesDeleteConfirm = withRoleName((deps, name) => {
|
||||
export const rolesDelete = withRoleName(async (deps, name) => {
|
||||
const { ctx, keto, user } = deps;
|
||||
await guardedForm(ctx); // CSRF-verify the POST
|
||||
if (name === ADMIN_PERMISSION) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access.");
|
||||
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 };
|
||||
@@ -360,7 +360,7 @@ export const rolesRemoveMember = withRoleName(async (deps, name) => {
|
||||
const { ctx, keto, revoke, user } = deps;
|
||||
const form = (await guardedForm(ctx))!;
|
||||
const member = (form.get("member") ?? "").trim();
|
||||
if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access.");
|
||||
if (name === ADMIN_ROLE && member === `user:${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 }); }
|
||||
return { redirect: detailHref(name) };
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Readable } from "node:stream";
|
||||
import { test } from "node:test";
|
||||
import { GuardError, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api";
|
||||
import { ADMIN_NAV, ADMIN_PERMISSION, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts";
|
||||
import { ADMIN_NAV, ADMIN_ROLE, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts";
|
||||
|
||||
const admin: User = { email: "ada@x.io", id: "u1", roles: ["admin"] };
|
||||
const member: User = { email: "bo@x.io", id: "u2", roles: ["scheduling:read"] };
|
||||
@@ -27,11 +27,11 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver
|
||||
|
||||
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.permission, ADMIN_PERMISSION); // gate on the header ⇒ composeNav drops the whole subtree for a non-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.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.permission === undefined)); // the header's gate covers the subtree
|
||||
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.role === undefined)); // the header's gate covers the subtree
|
||||
});
|
||||
|
||||
// ---- auth gates ----
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { can, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type User } from "#plugin-api";
|
||||
|
||||
export const ADMIN_PERMISSION = "admin"; // role token gating the whole admin section
|
||||
export const ADMIN_ROLE = "admin"; // role token 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";
|
||||
@@ -14,7 +14,7 @@ export const ADMIN_CLIENTS_BASE = "/admin/clients";
|
||||
export type AdminScreen = "clients" | "groups" | "roles" | "users";
|
||||
|
||||
// The plugin's nav fragment: the gated "Admin" header + its four screens. The host composes it into
|
||||
// the one global menu, filters per user (the header's `permission` drops the whole subtree for a
|
||||
// the one global menu, filters per user (the header's `role` 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: [
|
||||
@@ -26,15 +26,15 @@ export const ADMIN_NAV: NavNode = {
|
||||
icon: "i-shield",
|
||||
id: "admin",
|
||||
label: "Admin",
|
||||
permission: ADMIN_PERMISSION,
|
||||
role: ADMIN_ROLE,
|
||||
};
|
||||
|
||||
// The admin gate: a signed-in admin only. Each route already declares `permission: "admin"`, so the
|
||||
// The admin gate: a signed-in admin only. Each route already declares `role: "admin"`, so the
|
||||
// host enforces this before the handler runs; this is defence-in-depth and what a direct unit test
|
||||
// relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403.
|
||||
export function requireAdmin(ctx: RequestContext): User {
|
||||
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
|
||||
if (!can(ctx, ADMIN_PERMISSION)) throw new GuardError(403, "admin role required");
|
||||
if (!can(ctx, ADMIN_ROLE)) throw new GuardError(403, "admin role 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: User; }
|
||||
|
||||
// Resolve the shared deps, then run `inner`. The route's `permission: "admin"` already gated at the
|
||||
// Resolve the shared deps, then run `inner`. The route's `role: "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) => {
|
||||
|
||||
@@ -11,19 +11,19 @@ import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clie
|
||||
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsRemoveMember } from "./admin-groups.ts";
|
||||
import { rolesAddMember, rolesCreate, rolesDelete, rolesDeleteConfirm, rolesDetail, rolesList, rolesNewForm, rolesRemoveMember } from "./admin-roles.ts";
|
||||
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
|
||||
import { ADMIN_NAV, ADMIN_PERMISSION } from "./admin-shared.ts";
|
||||
import { ADMIN_NAV, ADMIN_ROLE } from "./admin-shared.ts";
|
||||
|
||||
// Every admin route is gated by the one `admin` permission — the host redirects an anonymous visitor
|
||||
// Every admin route is gated by the one `admin` role — the host redirects an anonymous visitor
|
||||
// to /login, gives a signed-in non-admin the 403 page, and filters the nav the same way. Handlers are
|
||||
// thin and keyed on ctx.params (the host extracts :id / :name), the idiomatic per-route style.
|
||||
const r = (method: HttpMethod, path: string, handler: RouteHandler): Route => ({ handler, method, path, permission: ADMIN_PERMISSION });
|
||||
const r = (method: HttpMethod, path: string, handler: RouteHandler): Route => ({ handler, method, path, role: ADMIN_ROLE });
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||
|
||||
nav: [ADMIN_NAV],
|
||||
|
||||
permissions: [{ description: "Administer users, groups, roles, and OAuth2 clients", token: ADMIN_PERMISSION }],
|
||||
roles: [{ description: "Administer users, groups, roles, and OAuth2 clients", name: ADMIN_ROLE }],
|
||||
|
||||
routes: [
|
||||
// Users
|
||||
|
||||
Reference in New Issue
Block a user