Refuse a stale permission save, confirm or report it, show View to read-only holders, keep an absolute return_to beside a locale
CI / full-gate (push) Failing after 2m53s

This commit is contained in:
2026-10-31 09:34:05 +01:00
parent 76995008eb
commit f4969d5c1f
17 changed files with 303 additions and 46 deletions
+36 -1
View File
@@ -3,7 +3,8 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import type { PermissionDecl } from "@plainpages/plugin-api";
import { buildPermissionPicker, grantDiff, grantTuple, groupSubject, userSubject } from "./admin-grants.ts";
import type { KetoClient } from "@plainpages/plugin-api";
import { applyGrants, baselineToken, buildPermissionPicker, grantDiff, grantTuple, groupSubject, userSubject } from "./admin-grants.ts";
const declared: PermissionDecl[] = [
{ description: "View users", name: "users:read" },
@@ -81,3 +82,37 @@ test("buildPermissionPicker says so when no plugin declares a permission, rather
assert.deepEqual(picker.choices, []);
assert.ok(picker.empty);
});
// The concurrency token of the permission form: equal exactly when the direct grants it offered are.
test("baselineToken depends on the offered grants held, not on their order or on undeclared ones", () => {
const token = baselineToken(declared, ["users:read", "users:write"]);
assert.match(token, /^[0-9a-f]+$/);
assert.equal(baselineToken(declared, ["users:write", "users:read"]), token);
assert.equal(baselineToken(declared, ["users:read", "users:write", "legacy:thing"]), token); // undeclared: never shown, never saved
assert.notEqual(baselineToken(declared, ["users:read"]), token);
assert.notEqual(baselineToken(declared, []), token);
assert.notEqual(baselineToken(declared, []), ""); // an empty set is a state too, not an absent token
assert.equal(buildPermissionPicker({ action: "/x", declared, direct: ["users:read", "users:write"] }).baseline, token);
});
test("applyGrants applies writes then deletes, and on a Keto failure stops and reports what was applied", async () => {
const log: string[] = [];
const keto = (failOn?: string): KetoClient => ({
check: async () => false,
deleteTuple: async (tp) => { if (tp.object === failOn) throw new Error("boom"); log.push(`delete ${tp.object}`); },
expand: async () => ({ type: "leaf" }),
listRelations: async () => ({ nextPageToken: null, tuples: [] }),
writeTuple: async (tp) => { if (tp.object === failOn) throw new Error("boom"); log.push(`write ${tp.object}`); },
});
const diff = { grant: ["a:read", "b:read"], revoke: ["c:read"] };
const ok = await applyGrants(keto(), userSubject("u1"), diff);
assert.deepEqual(ok, { applied: diff });
assert.deepEqual(log, ["write a:read", "write b:read", "delete c:read"]);
log.length = 0;
const failed = await applyGrants(keto("b:read"), userSubject("u1"), diff);
assert.deepEqual(failed.applied, { grant: ["a:read"], revoke: [] });
assert.ok(failed.error instanceof Error);
assert.deepEqual(log, ["write a:read"]); // nothing after the failure was attempted
});
+33 -4
View File
@@ -6,11 +6,14 @@
// what the installed plugins declare in code. Nothing here invents a name, which is why the old
// Permissions screen is gone: a grant is a property of a user or a group, edited where they are.
import { createHash } from "node:crypto";
import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "@plainpages/plugin-api";
const PERMISSION_NS = "Permission";
const GRANTED = "granted";
export const PERMISSIONS_FIELD = "permission"; // the checkbox name the two forms post
export const BASELINE_FIELD = "baseline"; // the hidden field carrying baselineToken
export const SAVED_NOTICE = "permissions-saved"; // the `info-msg` a successful save redirects with
export type GrantSubject = { subject_id: string } | { subject_set: SubjectSet };
@@ -53,6 +56,7 @@ export interface PermissionChoice {
export interface PermissionPicker {
action: string;
baseline: string; // baselineToken of the direct grants this form was rendered from
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
@@ -60,6 +64,7 @@ export interface PermissionPicker {
hint: string;
inheritedNote: string | undefined; // set when at least one choice is group-held, to explain the disabled row
legend: string;
notice?: string; // a confirmation, rendered above the list
// Set for a group: its members hold these transitively, so a change reaches them at their next
// re-mint rather than at once. The user picker revokes live tokens, so it says nothing.
pending: string | undefined;
@@ -90,6 +95,7 @@ export function buildPermissionPicker(opts: {
}));
return {
action: opts.action,
baseline: baselineToken(opts.declared, opts.direct),
choices,
empty: opts.declared.length === 0 ? t("admin.grants.none") : undefined,
field: PERMISSIONS_FIELD,
@@ -107,7 +113,7 @@ export function buildPermissionPicker(opts: {
// What a submitted set changes. Pure so the diff is testable without Keto: only declared names are
// considered, so a crafted POST cannot grant something no plugin gates on, and a held-but-undeclared
// name (left over from an uninstalled plugin) is never silently revoked by an unrelated save.
export function grantDiff(declared: readonly PermissionDecl[], held: string[], wanted: string[]): { grant: string[]; revoke: string[] } {
export function grantDiff(declared: readonly PermissionDecl[], held: string[], wanted: string[]): GrantChange {
const offered = new Set(declared.map((d) => d.name));
const heldSet = new Set(held);
const wantedSet = new Set(wanted.filter((name) => offered.has(name)));
@@ -117,7 +123,30 @@ export function grantDiff(declared: readonly PermissionDecl[], held: string[], w
};
}
export async function applyGrants(keto: KetoClient, subject: GrantSubject, diff: { grant: string[]; revoke: string[] }): Promise<void> {
for (const name of diff.grant) await keto.writeTuple(grantTuple(name, subject));
for (const name of diff.revoke) await keto.deleteTuple(grantTuple(name, subject));
// Proves which direct grants a form was rendered from, so a save made after someone else changed
// them is refused rather than silently undoing that change. Covers only the declared names the form
// offered, like grantDiff, and is a digest so an empty set is still a token and a missing field is not.
export function baselineToken(declared: readonly PermissionDecl[], held: string[]): string {
const offered = new Set(declared.map((d) => d.name));
return createHash("sha256").update(held.filter((name) => offered.has(name)).sort().join("\n")).digest("hex").slice(0, 16);
}
// True when the submitted token is missing or no longer matches Keto.
export const isStale = (form: { get(name: string): string | null }, declared: readonly PermissionDecl[], held: string[]): boolean =>
form.get(BASELINE_FIELD) !== baselineToken(declared, held);
export interface GrantChange { grant: string[]; revoke: string[] }
// Keto has no transaction, so the writes and deletes run one by one and stop at the first failure.
// What was applied by then is returned beside the error, so the caller can still revoke live tokens
// and tell the admin the save is only partly done.
export async function applyGrants(keto: KetoClient, subject: GrantSubject, diff: GrantChange): Promise<{ applied: GrantChange; error?: unknown }> {
const applied: GrantChange = { grant: [], revoke: [] };
try {
for (const name of diff.grant) { await keto.writeTuple(grantTuple(name, subject)); applied.grant.push(name); }
for (const name of diff.revoke) { await keto.deleteTuple(grantTuple(name, subject)); applied.revoke.push(name); }
} catch (error) {
return { applied, error };
}
return { applied };
}
+33 -19
View File
@@ -7,7 +7,7 @@
// each returning a RouteResult.
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 "@plainpages/plugin-api";
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD } from "./admin-grants.ts";
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, isStale, type PermissionPicker, PERMISSIONS_FIELD, SAVED_NOTICE } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts";
@@ -347,36 +347,50 @@ export const groupsCreate = withGroups(async (deps) => {
// GET /admin/groups/new — the create form.
export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}), "write");
// GET /admin/groups/:name — the detail + membership page.
export const groupsDetail = withGroupName(async ({ ctx, keto, kratosAdmin }, name) => {
// The detail + membership page. `notice` and `error` ride the permission picker, which is where a save reports.
async function groupDetailPage({ ctx, keto, kratosAdmin }: GroupsDeps, name: string, say: { error?: string; notice?: string } = {}): Promise<RouteResult> {
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,
direct,
effective, // a group nested in another group inherits its permissions too
readOnly: !can(ctx, permissionName("groups", "write")),
t: ctx.t,
transitive: true, // members inherit, so a change here lands at their next re-mint, not at once
});
const permissions: PermissionPicker = {
...buildPermissionPicker({
action: `${detailHref(name)}/permissions`,
declared: ctx.declaredPermissions,
direct,
effective, // a group nested in another group inherits its permissions too
readOnly: !can(ctx, permissionName("groups", "write")),
t: ctx.t,
transitive: true, // members inherit, so a change here lands at their next re-mint, not at once
}),
...(say.error ? { error: say.error } : {}),
...(say.notice ? { notice: say.notice } : {}),
};
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" };
});
}
// GET /admin/groups/:name — the detail + membership page; `info-msg` is read as on the user page.
export const groupsDetail = withGroupName((deps, name) =>
groupDetailPage(deps, name, deps.ctx.url.searchParams.get("info-msg") === SAVED_NOTICE ? { notice: deps.ctx.t("admin.grants.saved") } : {}));
// POST /admin/groups/:name/permissions — the submitted checkboxes are the desired set. Members hold
// a group's permissions transitively, so the change reaches them at their next login or re-mint —
// the documented instant-revoke tradeoff for anything held through a group.
export const groupsPermissions = withGroupName(async ({ ctx, keto, user }, name) => {
export const groupsPermissions = withGroupName(async (deps, name) => {
const { ctx, keto, user } = deps;
const form = (await guardedForm(ctx))!;
const subject = groupSubject(name);
const diff = grantDiff(ctx.declaredPermissions, await heldPermissions(keto, subject), form.getAll(PERMISSIONS_FIELD));
await applyGrants(keto, subject, diff);
if (diff.grant.length > 0 || diff.revoke.length > 0) {
ctx.log.info("admin: group permissions changed", { actor: user.id, granted: diff.grant.join(","), group: name, revoked: diff.revoke.join(",") });
const held = await heldPermissions(keto, subject);
if (isStale(form, ctx.declaredPermissions, held)) return { ...(await groupDetailPage(deps, name, { error: ctx.t("admin.grants.stale") })), status: 409 };
const { applied, error } = await applyGrants(keto, subject, grantDiff(ctx.declaredPermissions, held, form.getAll(PERMISSIONS_FIELD)));
if (applied.grant.length > 0 || applied.revoke.length > 0) {
ctx.log.info("admin: group permissions changed", { actor: user.id, granted: applied.grant.join(","), group: name, revoked: applied.revoke.join(",") });
}
return { redirect: detailHref(name) };
if (error) {
ctx.log.error("admin: group permissions only partly saved", { actor: user.id, error: String(error), group: name });
return { ...(await groupDetailPage(deps, name, { error: ctx.t("admin.grants.partial") })), status: 502 };
}
return { redirect: `${detailHref(name)}?info-msg=${SAVED_NOTICE}` };
});
// POST /admin/groups/:name/members — add a member (skip an invalid member or a self-nest).
@@ -60,6 +60,14 @@ test("buildUsersListModel filters by search + status, sorts, and paginates", ()
// Edit link points at the per-identity route.
assert.match(all.table.rows[0]!.actions![0]!.href!, new RegExp(`/admin/users/${people[0]!.id}$`));
assert.deepEqual([all.table.rows[0]!.actions![0]!.icon, all.table.rows[0]!.actions![0]!.label], ["i-edit", "Edit"]);
});
// The detail page of a `users:read` holder is read-only, so the row action must not promise an edit.
test("buildUsersListModel: a read-only holder's row action says View, with the same target", () => {
const people: Identity[] = [{ id: "u1", traits: { email: "a@x.io" } }];
const action = buildUsersListModel({ canWrite: false, identities: people, url: "http://x/admin/users" }).table.rows[0]!.actions![0]!;
assert.deepEqual([action.icon, action.label, action.href], ["i-eye", "View", "/admin/users/u1"]);
});
test("buildUserFormModel: create mode has an editable email + password, no edit actions", () => {
+25 -13
View File
@@ -4,7 +4,7 @@
// a shared `withUser` gate.
import { can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD, userSubject } from "./admin-grants.ts";
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, isStale, type PermissionPicker, PERMISSIONS_FIELD, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
@@ -138,12 +138,12 @@ export function buildUsersListModel(opts: {
canWrite: opts.canWrite !== false,
filterBar: listFilterBar(state, all.length, t),
pagination: listPagination(state, page, t),
table: listTable(rows, state, sort, t),
table: listTable(rows, state, sort, t, opts.canWrite !== false),
title: t("admin.users.title"),
};
}
function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate) {
function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate, canWrite: boolean) {
return {
actions: true,
caption: t("admin.users.title"),
@@ -153,7 +153,7 @@ function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "des
return { href: listHref(state, { page: 1, sort: next }), label: t(c.label), sort: dir, sortable: true };
}),
rows: rows.map((u) => ({
actions: [{ href: `${ADMIN_USERS_BASE}/${encodeURIComponent(u.id)}`, icon: "i-edit", label: t("common.edit") }],
actions: [{ href: `${ADMIN_USERS_BASE}/${encodeURIComponent(u.id)}`, icon: canWrite ? "i-edit" : "i-eye", label: t(canWrite ? "common.edit" : "common.view") }],
cells: [
{ user: { initials: u.initials, name: u.name } },
u.email,
@@ -329,7 +329,9 @@ 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);
// `info-msg` is honored only as the exact value a save redirects with, so a crafted link can't put words on the page.
const saved = deps.ctx.url.searchParams.get("info-msg") === SAVED_NOTICE;
const permissions = await userPermissionPicker(deps, id, undefined, saved ? deps.ctx.t("admin.grants.saved") : undefined);
return formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), identity, ...(permissions ? { permissions } : {}) });
});
@@ -338,7 +340,7 @@ const canWriteUsers = (ctx: RequestContext): boolean => can(ctx, permissionName(
// 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> {
async function userPermissionPicker(deps: UsersDeps, id: string, error?: string, notice?: string): Promise<PermissionPicker | undefined> {
if (!deps.keto) return undefined;
const subject = userSubject(id);
const [direct, effective] = await Promise.all([
@@ -355,6 +357,7 @@ async function userPermissionPicker(deps: UsersDeps, id: string, error?: string)
t: deps.ctx.t,
}),
...(error ? { error } : {}),
...(notice ? { notice } : {}),
};
}
@@ -366,21 +369,30 @@ export const usersPermissions = withTarget(async (deps, identity, id) => {
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));
const held = await heldPermissions(keto, subject);
const refuse = async (error: string, status: number): Promise<RouteResult> => {
const permissions = await userPermissionPicker(deps, id, error);
return { ...formResult(ctx, { canWrite: canWriteUsers(ctx), identity, ...(permissions ? { permissions } : {}) }), status };
};
if (isStale(form, ctx.declaredPermissions, held)) return refuse(ctx.t("admin.grants.stale"), 409);
const diff = grantDiff(ctx.declaredPermissions, held, 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 — leaving a `curl` against Keto as the only way back in.
if (id === user.id && diff.revoke.length > 0) {
ctx.log.warn("admin: refused a self-revoke of permissions", { actor: user.id, refused: diff.revoke.join(",") });
const permissions = await userPermissionPicker(deps, id, ctx.t("admin.grants.selfRevoke"));
return { ...formResult(ctx, { canWrite: canWriteUsers(ctx), identity, ...(permissions ? { permissions } : {}) }), status: 400 };
return refuse(ctx.t("admin.grants.selfRevoke"), 400);
}
await applyGrants(keto, subject, diff);
if (diff.grant.length > 0 || diff.revoke.length > 0) {
const { applied, error } = await applyGrants(keto, subject, diff);
if (applied.grant.length > 0 || applied.revoke.length > 0) {
revoke?.(id);
ctx.log.info("admin: user permissions changed", { actor: user.id, granted: diff.grant.join(","), revoked: diff.revoke.join(","), target: id });
ctx.log.info("admin: user permissions changed", { actor: user.id, granted: applied.grant.join(","), revoked: applied.revoke.join(","), target: id });
}
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
if (error) {
ctx.log.error("admin: user permissions only partly saved", { actor: user.id, error: String(error), target: id });
return refuse(ctx.t("admin.grants.partial"), 502);
}
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}?info-msg=${SAVED_NOTICE}` };
});
// POST /admin/users/:id — save edits; a Kratos 4xx re-renders the form (400).
+3
View File
@@ -53,9 +53,12 @@ const messages = {
"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.partial": "Saving stopped part-way because the permission service failed, so only some changes were applied. The list shows what is stored now. Save again to finish.",
"admin.grants.pending": "Members get this at their next sign-in (up to 10 minutes).",
"admin.grants.save": "Save permissions",
"admin.grants.saved": "Permissions saved.",
"admin.grants.selfRevoke": "You can't revoke your own permissions — ask another administrator, so you can't lock yourself out.",
"admin.grants.stale": "These permissions changed while you had the page open, so nothing was saved. The list now shows the current state. Review it and save again.",
"admin.groups.actions": "Group actions",
"admin.groups.addMember": "Add a member",
+3
View File
@@ -53,9 +53,12 @@ const messages: AdminMessages = {
"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.partial": "Sparandet avbröts halvvägs eftersom behörighetstjänsten fallerade, så bara en del av ändringarna genomfördes. Listan visar vad som är sparat nu. Spara igen för att slutföra.",
"admin.grants.pending": "Medlemmar får detta vid nästa inloggning (upp till 10 minuter).",
"admin.grants.save": "Spara behörigheter",
"admin.grants.saved": "Behörigheterna är sparade.",
"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.grants.stale": "Behörigheterna ändrades medan du hade sidan öppen, så ingenting sparades. Listan visar nu aktuellt läge. Granska den och spara igen.",
"admin.groups.actions": "Gruppåtgärder",
"admin.groups.addMember": "Lägg till en medlem",
@@ -7,13 +7,19 @@
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, pending, readOnly, submit }).
The hidden `baseline` is a token for the direct grants this page showed: a save made after they changed
is refused (see baselineToken in admin-grants.ts).
Locals: csrfToken, permissions ({ action, baseline, choices, empty, error, field, hint, inheritedNote, legend, notice, pending, 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.notice) { -%>
<%- include("partials/alert", { text: permissions.notice, tone: "pos" }) %>
<% } -%>
<% if (permissions.empty) { -%>
<p class="cell-muted"><%= permissions.empty %></p>
<% } else { -%>
@@ -28,6 +34,7 @@
<% } else { -%>
<form method="post" action="<%= localeHref(permissions.action) %>">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input type="hidden" name="baseline" value="<%= permissions.baseline %>">
<fieldset class="check-group">
<legend class="sr-only"><%= permissions.legend %></legend>
<% permissions.choices.forEach((c) => { -%>