Model the read/write split in the UI: read-only views, self-revoke and inherited-grant guards
CI / full-gate (push) Successful in 2m39s
CI / full-gate (push) Successful in 2m39s
This commit is contained in:
@@ -134,6 +134,16 @@ them. Revisit only if the stated reason stops holding.
|
||||
example it buys one thing: the route table and the in-handler guard derive from one function, so
|
||||
29 routes × 2 gate sites cannot drift. As a general mechanism it would make authorization a
|
||||
function of the transport verb, and a route table must answer "what does this need?" on its own.
|
||||
- **A `:read`-only holder must never be shown a write affordance.** The split created a real read-only
|
||||
operator (a helpdesk account with `users:read`), and the host's 403 is the backstop, not the UX: the
|
||||
list/detail models carry `canWrite` and the views drop create/save/delete/add/remove, while the
|
||||
permission picker still renders — disabled — because *seeing* who holds what is the point of `:read`.
|
||||
Two grant-specific guards go with it, both restoring behaviour the deleted Permissions screen had:
|
||||
you cannot revoke your own grants (self-lockout would need a `curl` against Keto to undo, which the
|
||||
operator persona can't do — same shape as the self-deactivate/self-delete guards), and a permission
|
||||
held *through a group* renders ticked-but-disabled rather than unticked, because showing it unticked
|
||||
stated the opposite of the truth and unticking it wrote nothing while looking like a successful
|
||||
revoke. Raised by the architecture + product reviews 2026-08-05.
|
||||
- **`users:write` and `groups:write` are equivalent to full administrative access**, and the split
|
||||
does not change that: `groups:write` adds you to any group, including one holding every permission;
|
||||
`users:write` mints a recovery code for any account. The containment the split buys is real on the
|
||||
|
||||
@@ -126,7 +126,7 @@ or gated**, so the same foundation serves a purely public site, a fully locked-d
|
||||
tool, or the common middle: a public front with an authenticated area behind it. Its **sweet
|
||||
spot** is the **back-office and operational tooling** you'd otherwise hand-roll for the tenth
|
||||
time, but nothing ties it to internal-only use. The core itself ships **no domain screens at
|
||||
all** — even the screens for running the system (**users, groups, permissions**) are a **drop-in
|
||||
all** — even the screens for running the system (**users, groups, OAuth2 clients**) are a **drop-in
|
||||
plugin** you opt into ([`examples/plugins/admin/`](examples/plugins/admin/)). Everything is a plugin.
|
||||
|
||||
**Who it's for.** Experienced developers building server-rendered web products — back-office
|
||||
@@ -160,7 +160,7 @@ audience above, and three of them shape the design more than any feature request
|
||||
|
||||
- **Included in the core:** themed sign-in / register / reset (Kratos-backed), the design
|
||||
system + app shell, the config-driven menu, sessions, and access control. No domain screens.
|
||||
- **Opt-in admin plugin:** the **users, groups, permissions, and OAuth2-clients** screens (users via
|
||||
- **Opt-in admin plugin:** the **users, groups, and OAuth2-clients** screens (users via
|
||||
Kratos, the relationship graph via Keto, OAuth2 clients via Hydra) ship as
|
||||
[`examples/plugins/admin/`](examples/plugins/admin/) — copy it into `plugins/` to get a GUI for
|
||||
user & group admin. It's an ordinary plugin, using the privileged
|
||||
@@ -735,7 +735,7 @@ are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its luci
|
||||
subtree disappears with it. When the children need *different* permissions, leave the header ungated
|
||||
and gate each child: `composeNav` drops a header whose children all filtered out. That second form
|
||||
only works while the header carries **no `href`** — give it one and it survives the filter as an
|
||||
ungated leaf, visible to everyone. The admin example uses it (four screens, four permissions).
|
||||
ungated leaf, visible to everyone. The admin example uses it (three screens, six permissions).
|
||||
|
||||
#### Public pages & menu items
|
||||
|
||||
@@ -1332,7 +1332,7 @@ deactivate the user, or use a direct user-permission change, for an instant effe
|
||||
is for. Reserve it for those; don't pay its tuple-sync cost for rules a service can already
|
||||
answer from its own data.
|
||||
|
||||
The admin plugin's users / groups / permissions screens write authorization **only to Keto** — coarse
|
||||
The admin plugin's users / groups screens write authorization **only to Keto** — coarse
|
||||
permissions and fine-grained relationships alike.
|
||||
|
||||
### OAuth2 provider (Hydra)
|
||||
|
||||
@@ -20,7 +20,7 @@ export default defineMenu({
|
||||
// Operator override (rename → group → order → hide), keyed by node id.
|
||||
override: {
|
||||
// rename: { people: "Staff" }, // node id → new label (or a catalog key)
|
||||
// groups: [{ id: "admin", label: "Admin", children: ["users", "permissions"] }],
|
||||
// groups: [{ id: "admin", label: "Admin", children: ["users", "groups"] }],
|
||||
// order: ["people", "reports"], // top-level order by id
|
||||
// hide: ["teams"], // remove nodes (any depth)
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ screens live at `/admin/*`) and restart:
|
||||
|
||||
```bash
|
||||
cp -r examples/plugins/admin plugins/admin
|
||||
docker compose restart web
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The bootstrap grants the seeded `admin@plainpages.local` every permission this plugin declares, so the
|
||||
@@ -46,8 +46,8 @@ property of a user or a group, edited as a checkbox list on those two screens (`
|
||||
|
||||
## 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)`
|
||||
- `plugin.ts` — the manifest: the Admin nav fragment, the six permissions the plugin declares, and
|
||||
the route table — one thin handler per method+path, gated via `permissionName(resource, actionForMethod(method))`
|
||||
so a GET needs `:read` and a POST `:write`.
|
||||
- `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.
|
||||
@@ -55,12 +55,12 @@ property of a user or a group, edited as a checkbox list on those two screens (`
|
||||
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.
|
||||
- `admin-shared.ts` — the permission naming (`adminPermission`), the shared gate
|
||||
- `admin-shared.ts` — the permission naming (`permissionName` / `actionForMethod`), the shared gate
|
||||
(`requirePermission`), CSRF form reader (`guardedForm`), confirm
|
||||
model, nav fragment, and the not-found / unavailable helpers.
|
||||
- `views/` — the screens' EJS, plus the admin-specific body partials under `views/partials/`. They
|
||||
`include()` the core building-block partials (shell, data-table, filter-bar, field, …).
|
||||
|
||||
The four screens hold **no state** — everything lives in Ory. Handlers are thin, so their builders
|
||||
The three screens hold **no state** — everything lives in Ory. Handlers are thin, so their builders
|
||||
unit-test as pure functions with no host; the HTTP routing/gate/CSRF is covered in
|
||||
`src/http/app.test.ts` (which mounts this plugin) and end-to-end in `e2e-tests/full-flow.spec.ts`.
|
||||
|
||||
@@ -35,17 +35,38 @@ test("grantDiff ignores anything the plugins don't declare, in both directions",
|
||||
});
|
||||
|
||||
test("buildPermissionPicker ticks what is held and carries each declaration's description", () => {
|
||||
const picker = buildPermissionPicker({ action: "/admin/users/u1/permissions", declared, held: ["users:write"] });
|
||||
const picker = buildPermissionPicker({ action: "/admin/users/u1/permissions", declared, direct: ["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);
|
||||
assert.equal(picker.readOnly, false);
|
||||
assert.equal(picker.inheritedNote, undefined); // nothing is group-held here
|
||||
});
|
||||
|
||||
// The failure this prevents: a permission held through a group used to render unticked, so the page
|
||||
// said "not held" about a grant that reaches the JWT — and unticking it wrote nothing, which read as
|
||||
// a successful revoke. Inherited rows are ticked, disabled, and never posted.
|
||||
test("buildPermissionPicker distinguishes a direct grant from one inherited through a group", () => {
|
||||
const picker = buildPermissionPicker({ action: "/x", declared, direct: ["users:write"], effective: ["users:read", "users:write"] });
|
||||
assert.deepEqual(picker.choices.map((c) => [c.name, c.checked, c.inherited]), [
|
||||
["users:read", true, true], // effective but not direct → shown as held, not editable here
|
||||
["users:write", true, false], // direct → editable
|
||||
["groups:read", false, false],
|
||||
]);
|
||||
assert.ok(picker.inheritedNote, "the disabled row needs an explanation");
|
||||
});
|
||||
|
||||
test("buildPermissionPicker in read-only mode still shows the state, and marks itself unwritable", () => {
|
||||
const picker = buildPermissionPicker({ action: "/x", declared, direct: ["users:read"], readOnly: true });
|
||||
assert.equal(picker.readOnly, true);
|
||||
assert.deepEqual(picker.choices.map((c) => c.checked), [true, false, false]); // a reader still sees who holds what
|
||||
});
|
||||
|
||||
test("buildPermissionPicker says so when no plugin declares a permission, rather than rendering an empty box", () => {
|
||||
const picker = buildPermissionPicker({ action: "/x", declared: [], held: [] });
|
||||
const picker = buildPermissionPicker({ action: "/x", declared: [], direct: [] });
|
||||
assert.deepEqual(picker.choices, []);
|
||||
assert.ok(picker.empty);
|
||||
});
|
||||
|
||||
@@ -22,8 +22,7 @@ export function grantTuple(permission: string, subject: GrantSubject): RelationT
|
||||
}
|
||||
|
||||
// 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.
|
||||
// declared name. This is the edge the picker edits; `effectivePermissions` adds what a group confers.
|
||||
export async function heldPermissions(keto: KetoClient, subject: GrantSubject): Promise<string[]> {
|
||||
const held = new Set<string>();
|
||||
let pageToken: string | undefined;
|
||||
@@ -35,9 +34,20 @@ export async function heldPermissions(keto: KetoClient, subject: GrantSubject):
|
||||
return [...held].sort();
|
||||
}
|
||||
|
||||
// Every declared permission the subject effectively holds — direct grants *plus* anything reached
|
||||
// through a group, which is what actually lands in their JWT. One Keto check per declared name;
|
||||
// the catalog is small and this is an admin screen (login does the same walk).
|
||||
export async function effectivePermissions(keto: KetoClient, subject: GrantSubject, declared: PermissionDecl[]): Promise<string[]> {
|
||||
const held = await Promise.all(declared.map((decl) => keto.check({ namespace: PERMISSION_NS, object: decl.name, relation: GRANTED, ...subject })));
|
||||
return declared.filter((_, i) => held[i]).map((decl) => decl.name);
|
||||
}
|
||||
|
||||
export interface PermissionChoice {
|
||||
checked: boolean;
|
||||
checked: boolean; // held directly — the only state this form can change
|
||||
description: string;
|
||||
// Effective through a group, not granted directly. Rendered ticked but disabled: the grant is real
|
||||
// (it reaches the JWT), and it is removed by editing the group, not this subject.
|
||||
inherited: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@@ -45,27 +55,44 @@ export interface PermissionPicker {
|
||||
action: string;
|
||||
choices: PermissionChoice[];
|
||||
empty: string | undefined; // set when no plugin declares a permission — the picker has nothing to offer
|
||||
error?: string; // a rejected save (e.g. the self-revoke guard), rendered above the list
|
||||
field: string;
|
||||
hint: string;
|
||||
inheritedNote: string | undefined; // set when at least one choice is group-held, to explain the disabled row
|
||||
legend: string;
|
||||
readOnly: boolean; // the viewer holds :read but not :write — show the state, offer no save
|
||||
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).
|
||||
// The checkbox list: every declared permission, ticked where this subject holds it. A fixed list
|
||||
// means the form is the whole truth — what it posts back *is* the desired set of *direct* grants
|
||||
// (grantDiff). An inherited row is disabled, so it never posts and can never be diffed into a revoke.
|
||||
export function buildPermissionPicker(opts: {
|
||||
action: string;
|
||||
declared: PermissionDecl[];
|
||||
held: string[];
|
||||
direct: string[];
|
||||
effective?: string[]; // omit when the caller can't resolve group-held grants; then only direct shows
|
||||
readOnly?: boolean;
|
||||
t?: Translate;
|
||||
}): PermissionPicker {
|
||||
const t = opts.t ?? ((k: string) => k);
|
||||
const heldSet = new Set(opts.held);
|
||||
const directSet = new Set(opts.direct);
|
||||
const effectiveSet = new Set(opts.effective ?? opts.direct);
|
||||
const choices = opts.declared.map((decl) => ({
|
||||
checked: directSet.has(decl.name) || effectiveSet.has(decl.name),
|
||||
description: decl.description ?? "",
|
||||
inherited: !directSet.has(decl.name) && effectiveSet.has(decl.name),
|
||||
name: decl.name,
|
||||
}));
|
||||
return {
|
||||
action: opts.action,
|
||||
choices: opts.declared.map((decl) => ({ checked: heldSet.has(decl.name), description: decl.description ?? "", name: decl.name })),
|
||||
choices,
|
||||
empty: opts.declared.length === 0 ? t("admin.grants.none") : undefined,
|
||||
field: PERMISSIONS_FIELD,
|
||||
hint: t("admin.grants.hint"),
|
||||
inheritedNote: choices.some((c) => c.inherited) ? t("admin.grants.inherited") : undefined,
|
||||
legend: t("admin.grants.legend"),
|
||||
readOnly: opts.readOnly === true,
|
||||
submit: t("admin.grants.save"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
|
||||
// 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 { can, 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, effectivePermissions, grantDiff, groupSubject, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD } from "./admin-grants.ts";
|
||||
import { ADMIN_EN, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
||||
import type { FieldConfig } from "./admin-users.ts";
|
||||
|
||||
const GROUP_NS = "Group";
|
||||
@@ -111,6 +111,7 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
|
||||
}
|
||||
|
||||
export function buildGroupsListModel(opts: {
|
||||
canWrite?: boolean;
|
||||
csrfToken?: string;
|
||||
groups: GroupView[];
|
||||
t?: Translate;
|
||||
@@ -140,6 +141,7 @@ export function buildGroupsListModel(opts: {
|
||||
|
||||
return {
|
||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.nav.section") }, { label: t("admin.groups.title") }],
|
||||
canWrite: opts.canWrite !== false,
|
||||
filterBar: listFilterBar(state, t),
|
||||
pagination: listPagination(state, page, t),
|
||||
table: listTable(rows, state, sort, t),
|
||||
@@ -225,6 +227,7 @@ export function buildGroupFormModel(opts: {
|
||||
}
|
||||
|
||||
export function buildGroupDetailModel(opts: {
|
||||
canWrite?: boolean; // false ⇒ a `groups:read` holder: show the members, offer no edit
|
||||
candidates: MemberOption[];
|
||||
csrfToken?: string;
|
||||
error?: string;
|
||||
@@ -239,9 +242,11 @@ export function buildGroupDetailModel(opts: {
|
||||
const taken = new Set(opts.members.map((m) => m.subject));
|
||||
const self = `group:${name}`; // a group can't be a member of itself
|
||||
const options = opts.candidates.filter((c) => c.value !== self && !taken.has(c.value));
|
||||
const canWrite = opts.canWrite !== false;
|
||||
return {
|
||||
add: { action: `${base}/members`, options },
|
||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.groups.title") }, { label: name }],
|
||||
canWrite, // the view drops add/remove/delete when false; the host already 403s those POSTs
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
delete: { action: `${base}/delete` },
|
||||
error: opts.error,
|
||||
@@ -254,7 +259,7 @@ export function buildGroupDetailModel(opts: {
|
||||
|
||||
// ---- request handler (imperative shell) ----
|
||||
|
||||
// Drain every page of a relation-tuple query. (Reused by the Roles screen — same membership model.)
|
||||
// Drain every page of a relation-tuple query.
|
||||
export async function pagedTuples(keto: KetoClient, query: RelationQuery): Promise<RelationTuple[]> {
|
||||
const out: RelationTuple[] = [];
|
||||
let pageToken: string | undefined;
|
||||
@@ -320,7 +325,7 @@ const groupFormResult = async (deps: GroupsDeps, extra: { error?: string; values
|
||||
// GET /admin/groups — the list.
|
||||
export const groupsList = withGroups(async ({ ctx, keto }) => {
|
||||
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
|
||||
return { data: { chrome: ctx.chrome, model: buildGroupsListModel({ csrfToken: ctx.chrome.csrfToken, groups, t: ctx.t, url: ctx.url }) }, view: "groups" };
|
||||
return { data: { chrome: ctx.chrome, model: buildGroupsListModel({ canWrite: can(ctx, permissionName("groups", "write")), csrfToken: ctx.chrome.csrfToken, groups, t: ctx.t, url: ctx.url }) }, view: "groups" };
|
||||
});
|
||||
|
||||
// POST /admin/groups — create (a group exists once it has ≥1 member, so this writes the first tuple).
|
||||
@@ -346,13 +351,17 @@ 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));
|
||||
const subject = groupSubject(name);
|
||||
const [direct, effective] = await Promise.all([heldPermissions(keto, subject), effectivePermissions(keto, subject, ctx.declaredPermissions)]);
|
||||
const permissions = buildPermissionPicker({
|
||||
action: `${detailHref(name)}/permissions`,
|
||||
declared: ctx.declaredPermissions,
|
||||
held: await heldPermissions(keto, groupSubject(name)),
|
||||
direct,
|
||||
effective, // a group nested in another group inherits its permissions too
|
||||
readOnly: !can(ctx, permissionName("groups", "write")),
|
||||
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" };
|
||||
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ canWrite: !permissions.readOnly, 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Direct units for the admin plugin's shared nav + auth helpers. They're security-critical
|
||||
// (requirePermission/guardedForm gate every admin write) and reused across all four screens, so pin the
|
||||
// (requirePermission/guardedForm gate every admin write) and reused across all three screens, so pin the
|
||||
// contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app.test.ts.
|
||||
// Import only from the #plugin-api barrel — the same contract boundary the plugin code uses.
|
||||
import assert from "node:assert/strict";
|
||||
@@ -30,7 +30,7 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver
|
||||
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.
|
||||
// drops a header left with no visible children, so holding none of the three hides the section.
|
||||
// Both halves matter — give the header an `href` and it survives the filter as a visible leaf,
|
||||
// ungated, for anonymous visitors included.
|
||||
assert.equal(ADMIN_NAV.permission, undefined);
|
||||
|
||||
@@ -38,10 +38,10 @@ export function actionForMethod(method: string): AdminAction {
|
||||
return verb === "GET" || verb === "HEAD" ? "read" : "write";
|
||||
}
|
||||
|
||||
// The plugin's nav fragment: an ungated "Admin" header + its four screens, each gated on its own
|
||||
// The plugin's nav fragment: an ungated "Admin" header + its three screens, each gated on its own
|
||||
// read permission. The header carries no `permission` because a user may hold one screen's and not
|
||||
// another's; composeNav drops a header left with no visible children, so a user holding none of the
|
||||
// four never sees the section. The host current-marks the active item — no `current`/`open` here.
|
||||
// three 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: permissionName("users", "read") },
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
// 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 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";
|
||||
import { can, 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, effectivePermissions, grantDiff, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD, userSubject } from "./admin-grants.ts";
|
||||
import { ADMIN_EN, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
||||
|
||||
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
@@ -106,6 +106,7 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
|
||||
}
|
||||
|
||||
export function buildUsersListModel(opts: {
|
||||
canWrite?: boolean;
|
||||
csrfToken?: string;
|
||||
identities: Identity[];
|
||||
t?: Translate;
|
||||
@@ -135,6 +136,7 @@ export function buildUsersListModel(opts: {
|
||||
|
||||
return {
|
||||
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.nav.section") }, { label: t("admin.users.title") }],
|
||||
canWrite: opts.canWrite !== false,
|
||||
filterBar: listFilterBar(state, all.length, t),
|
||||
pagination: listPagination(state, page, t),
|
||||
table: listTable(rows, state, sort, t),
|
||||
@@ -217,6 +219,7 @@ export interface FieldConfig {
|
||||
}
|
||||
|
||||
export function buildUserFormModel(opts: {
|
||||
canWrite?: boolean; // false ⇒ a `users:read` holder: show the state, render no write affordance
|
||||
csrfToken?: string;
|
||||
error?: string;
|
||||
identity?: Identity | null;
|
||||
@@ -240,8 +243,10 @@ export function buildUserFormModel(opts: {
|
||||
];
|
||||
if (!editing) fields.push({ autocomplete: "new-password", hint: t("admin.users.field.passwordHint"), icon: "i-lock", id: "password", label: t("admin.users.field.password"), name: "password", optional: true, type: "password" });
|
||||
|
||||
const canWrite = opts.canWrite !== false;
|
||||
return {
|
||||
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.users.title") }, { label: editing ? t("common.edit") : t("common.new") }],
|
||||
canWrite, // the view drops every write affordance when false; the host already 403s the POSTs
|
||||
edit: editing ? {
|
||||
deleteAction: `${idPath}/delete`,
|
||||
id: view!.id,
|
||||
@@ -304,7 +309,7 @@ const formResult = (ctx: RequestContext, extra: Parameters<typeof buildUserFormM
|
||||
// GET /admin/users — the filtered/sorted/paged list.
|
||||
export const usersList = withUser(async ({ ctx, kratosAdmin }) => {
|
||||
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE });
|
||||
return { data: { chrome: ctx.chrome, model: buildUsersListModel({ csrfToken: ctx.chrome.csrfToken, identities, t: ctx.t, url: ctx.url }) }, view: "users" };
|
||||
return { data: { chrome: ctx.chrome, model: buildUsersListModel({ canWrite: canWriteUsers(ctx), csrfToken: ctx.chrome.csrfToken, identities, t: ctx.t, url: ctx.url }) }, view: "users" };
|
||||
});
|
||||
|
||||
// POST /admin/users — create; a Kratos 4xx re-renders the form (400), keeping the input.
|
||||
@@ -326,31 +331,50 @@ export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx
|
||||
// GET /admin/users/:id — the edit form, prefilled.
|
||||
export const usersEditForm = withTarget(async (deps, identity, id) => {
|
||||
const permissions = await userPermissionPicker(deps, id);
|
||||
return formResult(deps.ctx, { identity, ...(permissions ? { permissions } : {}) });
|
||||
return formResult(deps.ctx, { canWrite: canWriteUsers(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> {
|
||||
const canWriteUsers = (ctx: RequestContext): boolean => can(ctx, permissionName("users", "write"));
|
||||
|
||||
// The checkbox list of declared permissions: ticked where this user holds one, and disabled where
|
||||
// the grant comes from a group (real, but removed on that group). Undefined when Keto isn't wired —
|
||||
// the rest of the edit page still works.
|
||||
async function userPermissionPicker(deps: UsersDeps, id: string, error?: 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,
|
||||
});
|
||||
const subject = userSubject(id);
|
||||
const [direct, effective] = await Promise.all([
|
||||
heldPermissions(deps.keto, subject),
|
||||
effectivePermissions(deps.keto, subject, deps.ctx.declaredPermissions),
|
||||
]);
|
||||
return {
|
||||
...buildPermissionPicker({
|
||||
action: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}/permissions`,
|
||||
declared: deps.ctx.declaredPermissions,
|
||||
direct,
|
||||
effective,
|
||||
readOnly: !canWriteUsers(deps.ctx),
|
||||
t: deps.ctx.t,
|
||||
}),
|
||||
...(error ? { error } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
// POST /admin/users/:id/permissions — the submitted checkboxes are the desired set of *direct*
|
||||
// grants; 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));
|
||||
// Self-lockout guard, matching the self-deactivate/self-delete ones: revoking your own grants can
|
||||
// remove the last `users:write` on the deployment, and the instant-revoke hook lands it on the very
|
||||
// next request. Recovery would be a curl against Keto — not something the operator persona can do.
|
||||
if (id === user.id && diff.revoke.length > 0) {
|
||||
const permissions = await userPermissionPicker(deps, id, ctx.t("admin.grants.selfRevoke"));
|
||||
return { ...formResult(ctx, { canWrite: canWriteUsers(ctx), identity, ...(permissions ? { permissions } : {}) }), status: 400 };
|
||||
}
|
||||
await applyGrants(keto, subject, diff);
|
||||
if (diff.grant.length > 0 || diff.revoke.length > 0) {
|
||||
revoke?.(id);
|
||||
|
||||
@@ -48,9 +48,13 @@ const messages = {
|
||||
"admin.common.type": "Type",
|
||||
"admin.common.user": "User",
|
||||
|
||||
"admin.grants.hint": "Which permissions exist is set by the plugins installed on this system. Tick to grant, untick to revoke.",
|
||||
"admin.grants.inherited": "Greyed-out permissions come from a group. Change them on that group.",
|
||||
"admin.grants.legend": "Permissions",
|
||||
"admin.grants.none": "No installed plugin declares a permission, so there is nothing to grant.",
|
||||
"admin.grants.pending": "Members get this at their next sign-in (up to 10 minutes).",
|
||||
"admin.grants.save": "Save permissions",
|
||||
"admin.grants.selfRevoke": "You can't revoke your own permissions — ask another administrator, so you can't lock yourself out.",
|
||||
|
||||
"admin.groups.actions": "Group actions",
|
||||
"admin.groups.addMember": "Add a member",
|
||||
|
||||
@@ -48,9 +48,13 @@ const messages: AdminMessages = {
|
||||
"admin.common.type": "Typ",
|
||||
"admin.common.user": "Användare",
|
||||
|
||||
"admin.grants.hint": "Vilka behörigheter som finns bestäms av de plugins som är installerade. Kryssa i för att tilldela, ur för att återkalla.",
|
||||
"admin.grants.inherited": "Gråmarkerade behörigheter kommer från en grupp. Ändra dem på gruppen.",
|
||||
"admin.grants.legend": "Behörigheter",
|
||||
"admin.grants.none": "Ingen installerad plugin deklarerar någon behörighet, så det finns inget att tilldela.",
|
||||
"admin.grants.pending": "Medlemmar får detta vid nästa inloggning (upp till 10 minuter).",
|
||||
"admin.grants.save": "Spara behörigheter",
|
||||
"admin.grants.selfRevoke": "Du kan inte återkalla dina egna behörigheter — be en annan administratör, så att du inte låser ute dig själv.",
|
||||
|
||||
"admin.groups.actions": "Gruppåtgärder",
|
||||
"admin.groups.addMember": "Lägg till en medlem",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Admin example plugin: the Users / Groups / Roles / OAuth2-clients screens for running the system.
|
||||
// Admin example plugin: the Users / Groups / OAuth2-clients screens for running the system.
|
||||
// These used to ship inside the core; they were extracted here so a fresh clone has no built-in admin
|
||||
// GUI. Copy this folder to plugins/admin (then restart) to enable it — see README → Quick start.
|
||||
//
|
||||
|
||||
@@ -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, permissions: model.permissions });
|
||||
const body = include("partials/group-detail-body", { add: model.add, canWrite: model.canWrite, csrfToken: model.csrfToken, del: model.delete, error: model.error, group: model.group, members: model.members, permissions: model.permissions });
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
body,
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
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/groups/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.groups.new") + '</a>';
|
||||
// Only offer "New group" to a groups:write holder — a groups:read one would get the 403 page.
|
||||
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/groups/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.groups.new") + '</a>';
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
actions,
|
||||
|
||||
@@ -21,13 +21,14 @@
|
||||
<% if (members.rows.length) { -%>
|
||||
<div class="table-wrap"><table class="table"><caption class="sr-only"><%= t("admin.groups.membersOf", { name: group.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("common.remove") %></button></form></td></tr>
|
||||
<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"><% if (locals.canWrite !== false) { %><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("common.remove") %></button></form><% } %></td></tr>
|
||||
<% }) -%>
|
||||
</tbody></table></div>
|
||||
<% } else { -%>
|
||||
<p class="cell-muted"><%= t("admin.groups.noMembers") %></p>
|
||||
<% } -%>
|
||||
</section>
|
||||
<% if (locals.canWrite !== false) { -%>
|
||||
<section class="form-card" aria-labelledby="add-h">
|
||||
<h2 class="card-title" id="add-h"><%= t("admin.groups.addMember") %></h2>
|
||||
<% if (add.options.length) { -%>
|
||||
@@ -36,10 +37,13 @@
|
||||
<p class="cell-muted"><%= t("admin.groups.allMembers") %></p>
|
||||
<% } -%>
|
||||
</section>
|
||||
<% } -%>
|
||||
<% if (locals.permissions) { -%>
|
||||
<%- include("partials/permission-picker", { csrfToken: csrf, permissions: locals.permissions }) %>
|
||||
<% } -%>
|
||||
<% if (locals.canWrite !== false) { -%>
|
||||
<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>
|
||||
<% } -%>
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,46 @@
|
||||
<%#
|
||||
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 }).
|
||||
one per permission the installed plugins declare — ticked where this user/group holds it. The whole
|
||||
set posts back, so what is submitted IS the desired set of *direct* grants (see admin-grants.ts).
|
||||
|
||||
Two rows never post, by design: an `inherited` one (the grant comes from a group, so it is changed
|
||||
there) and every row when `readOnly` (the viewer holds :read but not :write). Neither can be diffed
|
||||
into an accidental revoke, because grantDiff compares against the direct grants only.
|
||||
|
||||
Locals: csrfToken, permissions ({ action, choices, empty, error, field, hint, inheritedNote, legend, readOnly, submit }).
|
||||
%>
|
||||
<section class="form-card" aria-labelledby="permissions-h">
|
||||
<h2 class="card-title" id="permissions-h"><%= permissions.legend %></h2>
|
||||
<% if (permissions.error) { -%>
|
||||
<%- include("partials/alert", { text: permissions.error, tone: "neg" }) %>
|
||||
<% } -%>
|
||||
<% if (permissions.empty) { -%>
|
||||
<p class="cell-muted"><%= permissions.empty %></p>
|
||||
<% } else { -%>
|
||||
<p class="cell-muted"><%= permissions.hint %></p>
|
||||
<% if (permissions.readOnly) { -%>
|
||||
<fieldset class="check-group">
|
||||
<legend class="sr-only"><%= permissions.legend %></legend>
|
||||
<% permissions.choices.forEach((c) => { -%>
|
||||
<label class="check"><input type="checkbox"<%= c.checked ? " checked" : "" %> disabled><span><%= c.description || c.name %></span><span class="cell-muted"><%= c.name %></span></label>
|
||||
<% }) -%>
|
||||
</fieldset>
|
||||
<% } else { -%>
|
||||
<form method="post" action="<%= localeHref(permissions.action) %>">
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||
<fieldset class="check-list">
|
||||
<fieldset class="check-group">
|
||||
<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>
|
||||
<% permissions.choices.forEach((c) => { -%>
|
||||
<label class="check"><input type="checkbox" name="<%= permissions.field %>" value="<%= c.name %>"<%= c.checked ? " checked" : "" %><%= c.inherited ? " disabled" : "" %>><span><%= c.description || c.name %></span><span class="cell-muted"><%= c.name %></span></label>
|
||||
<% }) -%>
|
||||
</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>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><%= permissions.submit %></button>
|
||||
</div>
|
||||
</form>
|
||||
<% } -%>
|
||||
<% if (permissions.inheritedNote) { -%>
|
||||
<p class="cell-muted"><%= permissions.inheritedNote %></p>
|
||||
<% } -%>
|
||||
<% } -%>
|
||||
</section>
|
||||
|
||||
@@ -23,13 +23,15 @@
|
||||
<% }) -%>
|
||||
<div class="form-actions">
|
||||
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("common.cancel") %></a>
|
||||
<% if (locals.canWrite !== false) { -%>
|
||||
<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) { -%>
|
||||
<% if (edit && locals.canWrite !== false) { -%>
|
||||
<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>
|
||||
<form method="post" action="<%= localeHref(edit.stateAction) %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form>
|
||||
|
||||
@@ -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, permissions: model.permissions, recovery: model.recovery });
|
||||
const body = include("partials/user-form-body", { canWrite: model.canWrite, edit: model.edit, error: model.error, form: model.form, permissions: model.permissions, recovery: model.recovery });
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
body,
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
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/users/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.users.new") + '</a>';
|
||||
// Only offer "New user" to a users:write holder — a users:read one would get the 403 page.
|
||||
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/users/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.users.new") + '</a>';
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
actions,
|
||||
|
||||
@@ -474,6 +474,14 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
.check input, .radio input { width: 15px; height: 15px; accent-color: var(--accent);
|
||||
margin: 0; cursor: pointer; }
|
||||
.check:hover, .radio:hover { color: var(--text); }
|
||||
/* A stacked group of .check rows in a <fieldset> — the right element for related checkboxes, but the
|
||||
UA gives it a groove border, so reset it like .filter-field and .menu-field do. A disabled row is
|
||||
still readable: it states a fact (a permission held through a group) rather than offering an edit. */
|
||||
.check-group { border: 0; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.check-group .check { align-items: baseline; }
|
||||
.check-group .check input:disabled { cursor: default; }
|
||||
.check-group .check:has(input:disabled) { opacity: .7; cursor: default; }
|
||||
.check-group .check .cell-muted { margin-left: auto; padding-left: 12px; font-size: var(--fz-xs); }
|
||||
|
||||
/* popover menu (language picker, profile, row kebab) — a <button popovertarget> and its [popover]
|
||||
panel, wrapped so the pair is one element in any layout. The browser owns open/close, and the top
|
||||
|
||||
@@ -41,6 +41,13 @@ test("seedPermissions unions ADMIN_PERMISSIONS (empty by default) with the disco
|
||||
assert.deepEqual(seedPermissions(",, ", [" scheduling:read ", ""]), ["scheduling:read"]); // blanks dropped, names trimmed (both sides)
|
||||
});
|
||||
|
||||
test("seedPermissions refuses an ADMIN_PERMISSIONS name that isn't <resource>:<action>", () => {
|
||||
// The operator's env is the one remaining hand-typed path; a manifest's names were checked at
|
||||
// discovery. `admin` would otherwise write a tuple that gates nothing, with no error anywhere.
|
||||
assert.throws(() => seedPermissions("admin", []), /ADMIN_PERMISSIONS.*<resource>:<action>.*admin/s);
|
||||
assert.throws(() => seedPermissions("users:read,Bad Name", []), /Bad Name/);
|
||||
});
|
||||
|
||||
test("seedAdmin on a fresh stack creates the identity and grants every permission (one tuple each)", async () => {
|
||||
const id = randomUUID();
|
||||
const calls: { method: string; url: string; body?: unknown }[] = [];
|
||||
|
||||
+10
-3
@@ -9,6 +9,7 @@
|
||||
import { existsSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { discoverPlugins } from "../plugin-host/discovery.ts";
|
||||
import { declaredPermissions, isValidPermissionName } from "../plugin-host/plugin.ts";
|
||||
import { generateJwks, type JwkSet } from "./gen-jwks.ts";
|
||||
import { createLogger, runWithLog, tracedFetch } from "../logger.ts";
|
||||
|
||||
@@ -34,9 +35,15 @@ export function permissionTuple(userId: string, permission: string) {
|
||||
// dropped-in plugin's permissions are seeded out of the box. Deduped, order-stable, blanks dropped.
|
||||
// The base is empty because permissions are `<resource>:<action>` and every one of them is owned by
|
||||
// the plugin that gates on it — a host-invented default would gate nothing.
|
||||
export function seedPermissions(adminPermissionsEnv: string | undefined, declaredPermissions: string[]): string[] {
|
||||
// ADMIN_PERMISSIONS is the one place an operator names a permission by hand, so it is held to the
|
||||
// same `<resource>:<action>` rule discovery applies to a manifest — fail loud rather than write a
|
||||
// tuple that gates nothing. A declared name has already passed that check at discovery.
|
||||
export function seedPermissions(adminPermissionsEnv: string | undefined, declaredNames: string[]): string[] {
|
||||
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
|
||||
return [...new Set([...clean((adminPermissionsEnv ?? "").split(",")), ...clean(declaredPermissions)])];
|
||||
const configured = clean((adminPermissionsEnv ?? "").split(","));
|
||||
const bad = configured.filter((name) => !isValidPermissionName(name));
|
||||
if (bad.length > 0) throw new Error(`bootstrap: ADMIN_PERMISSIONS must be <resource>:<action> names, e.g. "things:read"; got ${bad.join(", ")}`);
|
||||
return [...new Set([...configured, ...clean(declaredNames)])];
|
||||
}
|
||||
|
||||
// --- JWKS safety net -----------------------------------------------------------------
|
||||
@@ -147,7 +154,7 @@ async function main() {
|
||||
|
||||
// Seed every discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so the
|
||||
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
|
||||
const declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.name));
|
||||
const declared = declaredPermissions(await discoverPlugins()).map((decl) => decl.name);
|
||||
const permissions = seedPermissions(env["ADMIN_PERMISSIONS"], declared);
|
||||
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
|
||||
const password = env["ADMIN_PASSWORD"] ?? "admin";
|
||||
|
||||
@@ -1289,6 +1289,60 @@ test("admin permission grants: the picker offers the declared catalog, and a sav
|
||||
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "groups:read" && tp.subject_set?.object === "eng"));
|
||||
});
|
||||
|
||||
// Revoking your own grants can remove the last users:write on the deployment, and the instant-revoke
|
||||
// hook lands it on the next request — recovery would be a curl against Keto. Guarded like
|
||||
// self-deactivate and self-delete are. (`admin1` is the harness's own sub.)
|
||||
test("admin permission grants: you can't revoke your own permissions, but you can still grant", async (t) => {
|
||||
const identities: Identity[] = [{ id: "admin1", traits: { email: "you@example.com" } }];
|
||||
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:write", relation: "granted", subject_id: "user:admin1" }];
|
||||
const keto = fakeKeto(tuples);
|
||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||
const { post, token } = await adminHarness(t, { keto, kratosAdmin });
|
||||
|
||||
const refused = await post("/admin/users/admin1/permissions", `_csrf=${token}`); // every box cleared
|
||||
assert.equal(refused.status, 400);
|
||||
assert.match(await refused.text(), /lock yourself out/);
|
||||
assert.ok(tuples.some((tp) => tp.object === "users:write" && tp.subject_id === "user:admin1"), "nothing was revoked");
|
||||
|
||||
// Granting yourself more is not a lockout, so it goes through.
|
||||
const granted = await post("/admin/users/admin1/permissions", `_csrf=${token}&permission=users%3Awrite&permission=groups%3Aread`);
|
||||
assert.equal(granted.status, 303);
|
||||
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === "user:admin1"));
|
||||
});
|
||||
|
||||
// The read/write split is only honest if the UI models it: a users:read holder must not be shown
|
||||
// buttons that 403 on submit. The gate already refuses them (asserted above); this is the affordance.
|
||||
test("admin screens render no write affordance for a read-only holder", async (t) => {
|
||||
const ada = randomUUID();
|
||||
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||
const keto = fakeKeto([{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }]);
|
||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||
const { get } = await adminHarness(t, { keto, kratosAdmin });
|
||||
const readOnly = ["users:read", "groups:read"];
|
||||
|
||||
const list = await (await get("/admin/users", readOnly)).text();
|
||||
assert.doesNotMatch(list, /href="\/admin\/users\/new"/); // no "New user"
|
||||
assert.match(list, /ada@example\.com/); // but the list itself is there — that's the point of :read
|
||||
|
||||
// (The shell's own sign-out is a POST form, so assert on the affordances by name, not on <form>.)
|
||||
const detail = await (await get(`/admin/users/${ada}`, readOnly)).text();
|
||||
assert.doesNotMatch(detail, /Save changes/);
|
||||
assert.doesNotMatch(detail, /Generate recovery code/);
|
||||
assert.doesNotMatch(detail, /Delete user/);
|
||||
assert.doesNotMatch(detail, /Save permissions/);
|
||||
assert.match(detail, /type="checkbox"[^>]*disabled/); // the permissions are shown, just not editable
|
||||
|
||||
const group = await (await get("/admin/groups/eng", readOnly)).text();
|
||||
assert.doesNotMatch(group, /Add a member/);
|
||||
assert.doesNotMatch(group, /Delete group/);
|
||||
assert.doesNotMatch(group, /Save permissions/);
|
||||
|
||||
// A writer sees the affordances the reader didn't.
|
||||
const writable = await (await get(`/admin/users/${ada}`, ["users:read", "users:write"])).text();
|
||||
assert.match(writable, /Save changes/);
|
||||
assert.match(writable, /Save permissions/);
|
||||
});
|
||||
|
||||
// Built-in OAuth2 clients admin screen: gate + list/register/detail/delete over HTTP against an
|
||||
// in-memory Hydra. Registration shows the one-time client_secret on the post-create page (no PRG).
|
||||
test("admin OAuth2 clients screen: gate, list, register (one-time secret), detail, delete (CSRF-guarded)", async (t) => {
|
||||
|
||||
@@ -209,7 +209,7 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
||||
collect(plugins, (plugin, push) => {
|
||||
for (const decl of plugin.permissions ?? []) push(decl.name);
|
||||
}).forEach((owners, name) => {
|
||||
if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${name}" declared by ${uniq(owners).length} plugins; namespace as "<id>:<action>" unless shared on purpose`, plugins: uniq(owners) });
|
||||
if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${name}" declared by ${uniq(owners).length} plugins; pick a more specific "<resource>" unless shared on purpose`, plugins: uniq(owners) });
|
||||
});
|
||||
|
||||
return out;
|
||||
|
||||
@@ -24,7 +24,7 @@ const adminLike: Plugin = {
|
||||
{ href: "/admin/users", id: "users", label: "Users" },
|
||||
{ href: "/admin/groups", id: "groups", label: "Groups" },
|
||||
],
|
||||
icon: "i-shield", id: "admin", label: "Admin", permission: "admin",
|
||||
icon: "i-shield", id: "admin", label: "Admin", permission: "users:read",
|
||||
}],
|
||||
};
|
||||
|
||||
@@ -58,7 +58,7 @@ test("a permission holder sees the Dashboard link + plugin nav; current path ope
|
||||
});
|
||||
|
||||
test("a gated section (like the admin plugin) shows to a holder; a sub-path marks its base leaf current", () => {
|
||||
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], user: { email: "a@b.c", id: "u1", permissions: ["admin"] } });
|
||||
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], user: { email: "a@b.c", id: "u1", permissions: ["users:read"] } });
|
||||
const admin = chrome.nav.find((n) => n.label === "Admin")!;
|
||||
assert.ok(admin); // gated section visible to an admin
|
||||
assert.equal(admin.open, true); // ancestor of the current leaf opened
|
||||
|
||||
+6
-6
@@ -27,7 +27,7 @@ test("composeNav merges fragments, filters by permission, and emits clean render
|
||||
test("composeNav drops gated subtrees, empty headers, and (with no permissions) all gated nodes", () => {
|
||||
// A header the user can't reach takes its whole subtree, even visible children.
|
||||
const gatedHeader: NavNode[][] = [[
|
||||
{ id: "admin", label: "Admin", permission: "admin", children: [{ href: "/u", id: "u", label: "Users" }] },
|
||||
{ id: "admin", label: "Admin", permission: "users:read", children: [{ href: "/u", id: "u", label: "Users" }] },
|
||||
{ id: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] },
|
||||
]];
|
||||
assert.deepEqual(composeNav(gatedHeader, {}, []), [
|
||||
@@ -36,8 +36,8 @@ test("composeNav drops gated subtrees, empty headers, and (with no permissions)
|
||||
|
||||
// A pure header whose children are all filtered is dropped; a header with an href survives as a leaf.
|
||||
const emptyHeader: NavNode[][] = [[
|
||||
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", permission: "x" }] },
|
||||
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", permission: "y" }] },
|
||||
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", permission: "x:read" }] },
|
||||
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", permission: "y:read" }] },
|
||||
]];
|
||||
assert.deepEqual(composeNav(emptyHeader, {}, []), [{ href: "/hub", label: "Hub" }]);
|
||||
|
||||
@@ -66,7 +66,7 @@ test("composeNav applies the override: rename, group, order, hide (then filters)
|
||||
{ href: "/a", id: "a", label: "Alpha" },
|
||||
{ href: "/b", id: "b", label: "Beta" },
|
||||
{ href: "/c", id: "c", label: "Gamma" },
|
||||
{ href: "/secret", id: "secret", label: "Secret", permission: "root" },
|
||||
{ href: "/secret", id: "secret", label: "Secret", permission: "secrets:read" },
|
||||
]];
|
||||
|
||||
const tree = composeNav(base, {
|
||||
@@ -74,9 +74,9 @@ test("composeNav applies the override: rename, group, order, hide (then filters)
|
||||
groups: [{ icon: "i-box", id: "grp", label: "Group", open: true, children: ["b", "c"] }], // wrap b+c
|
||||
order: ["grp", "a"], // grp before the lone a
|
||||
hide: ["c"], // remove c from inside the group
|
||||
}, ["root"]);
|
||||
}, ["secrets:read"]);
|
||||
|
||||
// grp emitted (b only, c hidden), reordered before a; Secret kept now that permission "root" is present.
|
||||
// grp emitted (b only, c hidden), reordered before a; Secret kept now that permission "secrets:read" is present.
|
||||
assert.deepEqual(tree, [
|
||||
{ icon: "i-box", label: "Group", open: true, children: [{ href: "/b", label: "Beta" }] },
|
||||
{ href: "/a", label: "First" },
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
## Unfinnished work
|
||||
|
||||
- [ ] The permission picker has no concurrency baseline, so two operators editing the same user/group silently discard each other's change (standard lost-update on a set-based form — and the natural "two of us are onboarding the new hire" workflow produces exactly it). Sketch: post the rendered set as a hidden baseline; if it no longer matches Keto, re-render with "this changed while you had the page open" rather than applying. Fits the existing "the form is the whole truth" model instead of fighting it. Raised by the product review 2026-08-05.
|
||||
- [ ] A grant whose plugin was uninstalled is invisible and unremovable in the GUI. `grantDiff` deliberately never revokes an undeclared name (so an unrelated save can't drop it), but nothing *shows* it either — so it can't be audited or cleaned, and reinstalling that plugin silently reactivates access nobody remembers granting. Sketch: a read-only "held, but no installed plugin offers this" list with a remove action. Raised by the product review 2026-08-05.
|
||||
- [ ] A plugin may gate a route on a permission it never declares — declaring stays optional on purpose (mandatory declaration would warn on the legitimate cross-plugin sharing case). The cost is a dead end: the picker is built from declarations only, so that route is ungrantable from the GUI with no boot error, no warning, and a permanent 403 as the operator's only clue. Sketch: a discovery *warning* (not an error) naming the gated-but-undeclared permission. Raised by the product review 2026-08-05.
|
||||
- [ ] Saving permissions gives no confirmation, and a partial failure is silent. `applyGrants` loops writes then deletes with no transaction, so a Keto error midway leaves a half-applied set behind the generic error page; and a *successful* save is indistinguishable from "nothing changed" (PRG back to the same page, checkboxes as the only feedback). The `alert alert-pos` pattern the recovery-code banner uses is already available. Raised by the product review 2026-08-05.
|
||||
- [ ] Add the read-only operator to README → Overview's personas. `users:read` now makes a support/helpdesk account possible for the first time, and it is a distinct persona from the three listed (end user, non-technical user, plugin author) — the one whose screens must render without write affordances. Writing it down makes read-only rendering a stated requirement rather than something the next reviewer rediscovers. Raised by the product review 2026-08-05.
|
||||
- [ ] The seeded admin@plainpages.local are assigned twice to the permission "admin", should only be one, right? (the "admin" permission name can be switched after previous todos have been done)
|
||||
- [ ] In Playwright tests, try different resolutions and sizes, from BIG desktop down to tiny phone.
|
||||
- [ ] Decide whether `e2e-tests/` should be typechecked. It is outside `tsconfig.include`, so the gate never checks the most logic-bearing file in it (`console-guard.ts`) — Playwright strips its types without checking them. Including it needs the DOM lib and `@playwright/test` present wherever `npm run typecheck` runs, which today is the `web` image that installs neither. Raised by review 2026-08-05.
|
||||
|
||||
Reference in New Issue
Block a user