Refuse a stale permission save, confirm or report it, show View to read-only holders, keep an absolute return_to beside a locale #192

Merged
lilleman merged 8 commits from item-4 into main 2026-10-31 09:52:39 +01:00
17 changed files with 423 additions and 84 deletions
+27
View File
@@ -6,6 +6,33 @@ at boot. Entries start at 0.3.0.
## Unreleased
### A language chosen on a sign-in link no longer drops an absolute return target
`/login?return_to=<absolute URL>&locale=sv-SE` passed the visitor to the dashboard after sign-in and
dropped the OAuth2 login challenge in `return_to`. The target now reaches Kratos, and the chosen
language is lost across that round-trip instead.
### The admin example refuses a permission save made from an out-of-date page
Two administrators editing one user's or group's permissions no longer overwrite each other. The
form carries a token for the grants it showed. A save that would change something, made after those
grants changed, is refused with "These permissions changed while you had the page open", and the page
shows the current grants. A POST with no token is refused the same way, so a script posting to
`/admin/users/:id/permissions` or `/admin/groups/:name/permissions` must first read the token from the
page's `baseline` field.
### The admin example confirms a permission save and reports a partial one
A save now lands on the page with "Permissions saved." If the permission service fails midway, the
page says the save stopped part-way instead of showing the generic error page. On a user, the
changes that landed still reach the user's live sessions at once when the revocation denylist is on.
### A read-only holder sees View, not Edit, on the admin example's users list
A holder of `users:read` without `users:write` gets a "View" row action with an eye icon. The admin
example's catalogs gain `admin.grants.partial`, `admin.grants.saved`, `admin.grants.stale` and
`admin.users.view`, so a translated catalog mounted under `locales/plugins/admin/` must add them.
### Every page carries a smaller icon sprite
`i-chart`, `i-copy` and `i-download` are gone from the inlined icon sprite, so a plugin that
+17
View File
@@ -122,3 +122,20 @@ Read most (opens and searches):
- `public/css/styles.css` 1
- `renovate.json` 1
- `src/e2e-console-guard.test.ts` 1
## Reading cost, chunk 2f36d29bd746, finished 2026-10-31T08:19:09Z
85029 new input tokens before the first commit.
Read most (opens and searches):
- `e2e-tests/full-flow.spec.ts` 4
- `README.md` 3
- `src/auth/routes.test.ts` 3
- `todo.md` 3
- `src/auth/kratos-public.ts` 2
- `src/http/app.test.ts` 2
- `ci.sh` 1
- `compose.yml` 1
- `e2e-tests/auth-refresh.spec.ts` 1
- `src/auth/flow-view.ts` 1
+15 -2
View File
@@ -1,5 +1,5 @@
import type { Browser, Page } from "@playwright/test";
import { expect, test, watchedPage } from "./console-guard.ts";
import { allowConsole, expect, test, watchedPage } from "./console-guard.ts";
import { randomUUID } from "node:crypto";
// Full browser E2E: the real Playwright UI against the live stack via the same-origin
@@ -136,7 +136,7 @@ test.describe.serial("authenticated admin journey", () => {
await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
});
test("groups CRUD: create a group (writes go to Keto), see it listed, then grant it a permission", async () => {
test("groups CRUD: create a group (writes go to Keto), see it listed, then grant it a permission", async ({ browser }) => {
// A Keto set exists only while it has ≥1 member, so create needs a first member (the form
// enforces it); pick the first option (a user) from the required picker.
const group = `e2e-grp-${suffix}`;
@@ -150,6 +150,10 @@ test.describe.serial("authenticated admin journey", () => {
// Permissions are declared in plugin code, so the group's detail page offers them as a fixed
// checkbox list rather than a create form — there is no Permissions screen to visit.
await page.goto(`/admin/groups/${group}`);
allowConsole(/status of 409 .*\/admin\/groups\/[^/]+\/permissions/); // the stale save below, refused on purpose
// A second browser signed in as the same admin, opened before the save below, so its form carries the old grants.
const staleTab = await watchedPage(await browser.newContext({ storageState: await page.context().storageState() }));
await staleTab.goto(`/admin/groups/${group}`);
const scheduling = page.locator('input[name="permission"][value="scheduling:read"]');
await expect(scheduling).toHaveCount(1); // declared by the reference plugin, so it's on offer
await expect(scheduling).not.toBeChecked();
@@ -157,7 +161,16 @@ test.describe.serial("authenticated admin journey", () => {
await page.locator('form:has(input[name="permission"]) button[type="submit"]').click();
await expect(page).toHaveURL(new RegExp(`/admin/groups/${group}`));
await expect(page.getByText("Permissions saved.")).toBeVisible();
await expect(page.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked();
// The other tab no longer matches Keto, so its save is refused and shows the grant made above.
await staleTab.locator('input[name="permission"][value="scheduling:write"]').check();
await staleTab.locator('form:has(input[name="permission"]) button[type="submit"]').click();
await expect(staleTab.getByText("changed while you had the page open")).toBeVisible();
await expect(staleTab.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked();
await expect(staleTab.locator('input[name="permission"][value="scheduling:write"]')).not.toBeChecked();
await staleTab.context().close();
});
test("OAuth2 clients CRUD: register a client (writes go to Hydra), see the one-time secret once, then delete it via the confirm step", async () => {
+47 -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, isStale, userSubject } from "./admin-grants.ts";
const declared: PermissionDecl[] = [
{ description: "View users", name: "users:read" },
@@ -81,3 +82,48 @@ test("buildPermissionPicker says so when no plugin declares a permission, rather
assert.deepEqual(picker.choices, []);
assert.ok(picker.empty);
});
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("isStale refuses a missing or old token, but only for a save that would change something", () => {
const held = ["users:read"];
const change = grantDiff(declared, held, ["users:read", "users:write"]);
const nothing = grantDiff(declared, held, ["users:read"]);
const current = new URLSearchParams({ baseline: baselineToken(declared, held) });
const old = new URLSearchParams({ baseline: baselineToken(declared, []) });
assert.equal(isStale(current, declared, held, change), false);
assert.equal(isStale(old, declared, held, change), true);
assert.equal(isStale(new URLSearchParams(), declared, held, change), true); // no token at all
assert.equal(isStale(old, declared, held, nothing), false); // a repeated save has nothing to refuse
});
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
});
+38 -4
View File
@@ -6,11 +6,17 @@
// 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
// Only that exact value counts, so a crafted link can't put words on the page.
export const isSavedNotice = (url: URL): boolean => url.searchParams.get("info-msg") === SAVED_NOTICE;
export type GrantSubject = { subject_id: string } | { subject_set: SubjectSet };
@@ -53,6 +59,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 +67,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 +98,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 +116,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 +126,32 @@ 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));
// A token for the direct grants a form was rendered from. Like grantDiff it covers only the declared
// names the form offered. It is a digest so an empty set is still a token; it is a guard against a
// stale page, not a secret, since anyone who can read the page can compute it.
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 form's token is missing or no longer matches Keto *and* the save would change
// something. A save that changes nothing, such as a double-clicked Save whose first POST landed, is
// harmless, so it is not refused.
export const isStale = (form: { get(name: string): string | null }, declared: readonly PermissionDecl[], held: string[], diff: GrantChange): boolean =>
(diff.grant.length > 0 || diff.revoke.length > 0) && form.get(BASELINE_FIELD) !== baselineToken(declared, held);
export interface GrantChange { grant: string[]; revoke: string[] }
// KetoClient writes one tuple per call, 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 };
}
+28 -13
View File
@@ -7,8 +7,8 @@
// 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 { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, isSavedNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, SAVED_NOTICE } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, partialSave, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts";
const GROUP_NS = "Group";
@@ -347,13 +347,14 @@ 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({
const permissions: PermissionPicker = {
...buildPermissionPicker({
action: `${detailHref(name)}/permissions`,
declared: ctx.declaredPermissions,
direct,
@@ -361,22 +362,36 @@ export const groupsDetail = withGroupName(async ({ ctx, keto, kratosAdmin }, nam
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.
export const groupsDetail = withGroupName((deps, name) =>
groupDetailPage(deps, name, isSavedNotice(deps.ctx.url) ? { 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);
const diff = grantDiff(ctx.declaredPermissions, held, form.getAll(PERMISSIONS_FIELD));
if (isStale(form, ctx.declaredPermissions, held, diff)) return { ...(await groupDetailPage(deps, name, { error: ctx.t("admin.grants.stale") })), status: 409 };
const { applied, error } = await applyGrants(keto, subject, diff);
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 partialSave(ctx, async () => ({ ...(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).
+10
View File
@@ -83,6 +83,16 @@ export function unavailable(ctx: RequestContext, what: string): RouteResult {
return { data: { chrome: ctx.chrome, message: ctx.t("admin.unavailable.message", { what }), title: ctx.t("admin.unavailable.title") }, status: 503, view: "notice" };
}
// The page a partial permission save reports on reads Keto again. If that read fails too, the report
// stands alone on the notice page, so the admin still hears that part of the save landed.
export async function partialSave(ctx: RequestContext, render: () => Promise<RouteResult>): Promise<RouteResult> {
try {
return await render();
} catch {
return { data: { chrome: ctx.chrome, message: ctx.t("admin.grants.partial"), title: ctx.t("admin.unavailable.title") }, status: 502, view: "notice" };
}
}
// Model for the shared destructive-confirm page (views/confirm.ejs). The view reads the shell fields
// (brand/csrf/theme/user/nav) from ctx.chrome; this carries only the page body + title/breadcrumbs.
export function buildConfirmModel(opts: {
@@ -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", () => {
+24 -14
View File
@@ -4,8 +4,8 @@
// 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 { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, isSavedNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, partialSave, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
const DEFAULT_PAGE_SIZE = 25;
@@ -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" : "admin.users.view") }],
cells: [
{ user: { initials: u.initials, name: u.name } },
u.email,
@@ -329,7 +329,7 @@ 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);
const permissions = await userPermissionPicker(deps, id, undefined, isSavedNotice(deps.ctx.url) ? deps.ctx.t("admin.grants.saved") : undefined);
return formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), identity, ...(permissions ? { permissions } : {}) });
});
@@ -338,7 +338,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 +355,7 @@ async function userPermissionPicker(deps: UsersDeps, id: string, error?: string)
t: deps.ctx.t,
}),
...(error ? { error } : {}),
...(notice ? { notice } : {}),
};
}
@@ -366,21 +367,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 };
};
const diff = grantDiff(ctx.declaredPermissions, held, form.getAll(PERMISSIONS_FIELD));
if (isStale(form, ctx.declaredPermissions, held, diff)) return refuse(ctx.t("admin.grants.stale"), 409);
// 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 partialSave(ctx, () => 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).
+4
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. Check the permissions and make any remaining changes again.",
"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",
@@ -149,6 +152,7 @@ const messages = {
"admin.users.status.inactive": "Inactive",
"admin.users.status.label": "Status",
"admin.users.title": "Users",
"admin.users.view": "View",
};
export type AdminMessages = typeof messages;
+4
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 gav fel, så bara en del av ändringarna genomfördes. Kontrollera behörigheterna och gör de återstående ändringarna igen.",
"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",
@@ -149,6 +152,7 @@ const messages: AdminMessages = {
"admin.users.status.inactive": "Inaktiv",
"admin.users.status.label": "Status",
"admin.users.title": "Användare",
"admin.users.view": "Visa",
};
export default messages;
@@ -7,13 +7,16 @@
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 }).
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 { -%>
@@ -26,8 +29,10 @@
<% }) -%>
</fieldset>
<% } else { -%>
<form method="post" action="<%= localeHref(permissions.action) %>">
<%# The fragment brings the card, and the notice in it, into view on a long page. %>
<form method="post" action="<%= localeHref(permissions.action + '#permissions-h') %>">
<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) => { -%>
+2 -1
View File
@@ -46,7 +46,8 @@ function flowReturnTo(ctx: RequestContext, secureCookies: boolean): string | und
const raw = ctx.url.searchParams.get("return_to");
const local = localPath(raw);
const chosen = chosenLocale(ctx);
if (!local && !chosen) return raw ?? undefined;
if (raw && !local) return raw; // beside a chosen language too: the target wins, the language is lost
if (!local && !chosen) return undefined;
const complete = new URL(`${selfOrigin(ctx, secureCookies)}/auth/complete`);
if (local) complete.searchParams.set("return_to", ctx.localeHref(local));
if (chosen) complete.searchParams.set("locale", chosen);
+165 -7
View File
@@ -851,10 +851,18 @@ test("login return_to: a chosen language alone still routes through /auth/comple
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
lastReturnTo = "unset";
await fetch(url + "/login", { redirect: "manual" });
assert.equal(lastReturnTo, undefined, "no target and no language: Kratos keeps its default");
await fetch(url + "/login?locale=sv-SE", { redirect: "manual" });
assert.match(lastReturnTo ?? "", /^http:\/\/[^/]+\/auth\/complete\?locale=sv-SE$/);
await fetch(url + "/login?locale=sv-SE&return_to=" + encodeURIComponent("/admin/users"), { redirect: "manual" });
assert.match(lastReturnTo ?? "", /^http:\/\/[^/]+\/auth\/complete\?return_to=%2Fadmin%2Fusers%3Flocale%3Dsv-SE&locale=sv-SE$/);
// An absolute target still reaches Kratos beside a chosen language; the language is lost instead.
const abs = "http://localhost/oauth2/login?login_challenge=abc";
await fetch(url + "/login?locale=sv-SE&return_to=" + encodeURIComponent(abs), { redirect: "manual" });
assert.equal(lastReturnTo, abs);
});
// "Ory down ⇒ no logins" is documented; the auth path should say so honestly (503), not the
@@ -953,6 +961,8 @@ async function adminHarness(t: TestContext, opts: AppOptions = {}) {
// What the plugin itself declares — the harness holds every screen's read and write, so a screen
// test exercises the screen rather than the gate. assertAdminGate covers the refusals.
const ADMIN_ALL = (adminManifest.permissions ?? []).map((p) => p.name);
// The permission form's concurrency token, read off the page the way a browser would post it back.
const baselineOf = (html: string): string => /name="baseline" value="([0-9a-f]+)"/.exec(html)?.[1] ?? "";
// Every admin route is gated: anonymous → /login, a signed-in non-admin → 403.
async function assertAdminGate(url: string, get: (path: string, permissions?: string[]) => Promise<Response>, path: string) {
const anon = await fetch(url + path, { redirect: "manual" });
@@ -1313,7 +1323,8 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
// Give it a permission first, so the delete below has an orphan to avoid leaving behind.
await post("/admin/groups/eng/permissions", `_csrf=${token}&permission=users%3Aread`);
const permissionsBaseline = baselineOf(await (await get("/admin/groups/eng")).text());
await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${permissionsBaseline}&permission=users%3Aread`);
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "users:read" && tp.subject_set?.object === "eng"));
// Delete the group: a confirm step (GET) then the POST removes every member tuple, back to the list.
@@ -1349,9 +1360,11 @@ test("admin permission grants: the picker offers the declared catalog, and a sav
}
assert.match(edit, /value="users:read"[^>]*checked/); // held → ticked
assert.doesNotMatch(edit, /value="groups:write"[^>]*checked/); // not held → unticked
assert.doesNotMatch(edit, /Permissions saved/); // no confirmation before a save
// Save a new set: users:write is added, users:read is dropped — the POST is the whole truth.
const saved = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Awrite&permission=groups%3Aread`);
const baseline = baselineOf(edit);
const saved = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite&permission=groups%3Aread`);
assert.equal(saved.status, 303);
assert.deepEqual(
tuples.filter((tp) => tp.subject_id === `user:${ada}`).map((tp) => tp.object).sort(),
@@ -1359,16 +1372,160 @@ test("admin permission grants: the picker offers the declared catalog, and a sav
);
assert.equal(denylist.isRevoked(ada, 0), true); // a change to your own grants revokes live tokens
// The redirect lands on a page that confirms the save, and shows the new state.
const landing = saved.headers.get("location") ?? "";
assert.match(landing, new RegExp(`^/admin/users/${ada}\\?info-msg=permissions-saved$`));
const confirmed = await (await get(landing)).text();
assert.match(confirmed, /Permissions saved/);
assert.match(confirmed, /value="users:write"[^>]*checked/);
// A crafted POST can't grant something no plugin declares.
await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Awrite&permission=superuser%3Aall`);
await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baselineOf(confirmed)}&permission=users%3Awrite&permission=superuser%3Aall`);
assert.ok(!tuples.some((tp) => tp.object === "superuser:all"));
// The same picker on a group writes the group's subject_set, which Keto resolves transitively.
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
await post("/admin/groups/eng/permissions", `_csrf=${token}&permission=groups%3Aread`);
const groupBaseline = baselineOf(await (await get("/admin/groups/eng")).text());
const groupSaved = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupBaseline}&permission=groups%3Aread`);
assert.equal(groupSaved.headers.get("location"), "/admin/groups/eng?info-msg=permissions-saved");
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "groups:read" && tp.subject_set?.object === "eng"));
});
// Two admins open the same page; the second save would silently undo the first.
test("admin permission grants: a save from a stale page is refused, and the page re-renders the current state", async (t) => {
const ada = randomUUID();
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` }];
const keto = fakeKeto(tuples);
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
const stale = baselineOf(await (await get(`/admin/users/${ada}`)).text());
// Another admin grants users:write while this page is open.
tuples.push({ namespace: "Permission", object: "users:write", relation: "granted", subject_id: `user:${ada}` });
const before = JSON.stringify(tuples);
const refused = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${stale}&permission=users%3Aread&permission=groups%3Aread`);
assert.equal(refused.status, 409);
const page = await refused.text();
assert.match(page, /changed while you had the page open/);
assert.equal(JSON.stringify(tuples), before, "nothing was applied");
assert.match(page, /value="users:write"[^>]*checked/); // the re-render shows the other admin's grant
assert.notEqual(baselineOf(page), stale);
// Saving from the re-rendered page goes through.
const retried = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baselineOf(page)}&permission=users%3Aread&permission=users%3Awrite&permission=groups%3Aread`);
assert.equal(retried.status, 303);
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === `user:${ada}`));
// A POST with no token at all (a crafted request, or a form rendered before this check existed) is stale too.
assert.equal((await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Aread`)).status, 409);
// Groups are checked the same way.
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
const groupStale = baselineOf(await (await get("/admin/groups/eng")).text());
tuples.push({ namespace: "Permission", object: "groups:read", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
const groupRefused = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupStale}&permission=users%3Aread`);
assert.equal(groupRefused.status, 409);
assert.match(await groupRefused.text(), /changed while you had the page open/);
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_set?.object === "eng"), "the group keeps its grant");
});
// A Keto error midway leaves some changes applied; the save says so and still revokes the user's live tokens.
test("admin permission grants: a Keto failure midway reports a partial save and keeps what was applied", async (t) => {
const ada = randomUUID();
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
const tuples: RelationTuple[] = [];
let writes = 0;
const keto = fakeKeto(tuples, {
writeTuple: async (tp) => {
if (++writes === 2) throw new Error("keto unreachable");
tuples.push(tp);
},
});
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const denylist = createDenylist();
const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
const baseline = baselineOf(await (await get(`/admin/users/${ada}`)).text());
const res = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=groups%3Aread&permission=users%3Aread&permission=users%3Awrite`);
assert.equal(res.status, 502);
const page = await res.text();
assert.match(page, /stopped part-way/);
assert.match(page, /value="groups:read"[^>]*checked/); // the first write landed, and the page shows what is stored
assert.doesNotMatch(page, /value="users:write"[^>]*checked/);
assert.deepEqual(tuples.map((tp) => tp.object), ["groups:read"]);
assert.equal(denylist.isRevoked(ada, 0), true); // the half that was applied still revokes live tokens
});
// A double-clicked Save posts twice from one page; the second post finds the first one's result.
// A save that changes nothing has nothing to refuse and nothing to revoke.
test("admin permission grants: a save that changes nothing is not refused for a stale page and leaves live tokens alone", async (t) => {
const ada = randomUUID();
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` }];
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const denylist = createDenylist();
const { get, post, token } = await adminHarness(t, { denylist, keto: fakeKeto(tuples), kratosAdmin });
const stale = baselineOf(await (await get(`/admin/users/${ada}`)).text());
tuples.push({ namespace: "Permission", object: "users:write", relation: "granted", subject_id: `user:${ada}` });
const before = JSON.stringify(tuples);
const repeated = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${stale}&permission=users%3Aread&permission=users%3Awrite`);
assert.equal(repeated.status, 303);
assert.equal(JSON.stringify(tuples), before);
assert.equal(denylist.isRevoked(ada, 0), false);
});
// Keto answered the writes but not the re-read: the report must still reach the admin.
test("admin permission grants: a partial save is still reported when Keto cannot be read again", async (t) => {
const ada = randomUUID();
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
const tuples: RelationTuple[] = [];
let writes = 0;
let down = false;
let failureTakesKetoDown = true;
const keto = fakeKeto(tuples, {
listRelations: async (q = {}) => {
if (down) throw new Error("keto unreachable");
return { nextPageToken: null, tuples: tuples.filter((tp) => matchesTuple(tp, q)) };
},
writeTuple: async (tp) => {
if (++writes === 2) { down = failureTakesKetoDown; throw new Error("keto unreachable"); }
tuples.push(tp);
},
});
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
const baseline = baselineOf(await (await get(`/admin/users/${ada}`)).text());
const res = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=groups%3Aread&permission=users%3Aread`);
assert.equal(res.status, 502);
assert.match(await res.text(), /stopped part-way/);
assert.deepEqual(tuples.map((tp) => tp.object), ["groups:read"]);
// The same on a group, whose page needs Keto for its members as well.
down = false;
writes = 0;
tuples.length = 0;
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
const groupBaseline = baselineOf(await (await get("/admin/groups/eng")).text());
const groupRes = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupBaseline}&permission=groups%3Aread&permission=users%3Aread`);
assert.equal(groupRes.status, 502);
assert.match(await groupRes.text(), /stopped part-way/);
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_set?.object === "eng"), "the first write landed");
// With Keto still answering, the group page itself reports it and shows what is stored.
failureTakesKetoDown = false;
down = false;
writes = 0;
tuples.length = 1;
const again = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${baselineOf(await (await get("/admin/groups/eng")).text())}&permission=groups%3Aread&permission=users%3Aread`);
assert.equal(again.status, 502);
const page = await again.text();
assert.match(page, /stopped part-way/);
assert.match(page, /value="groups:read"[^>]*checked/);
});
// 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.)
@@ -1377,15 +1534,16 @@ test("admin permission grants: you can't revoke your own permissions, but you ca
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 { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
const baseline = baselineOf(await (await get("/admin/users/admin1")).text());
const refused = await post("/admin/users/admin1/permissions", `_csrf=${token}`); // every box cleared
const refused = await post("/admin/users/admin1/permissions", `_csrf=${token}&baseline=${baseline}`); // 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`);
const granted = await post("/admin/users/admin1/permissions", `_csrf=${token}&baseline=${baseline}&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"));
});
+1
View File
@@ -13,6 +13,7 @@ export const ICON_NAMES: Record<string, string> = {
"i-chev": "chevron-right",
"i-cols": "columns-3",
"i-edit": "pencil",
"i-eye": "eye",
"i-globe": "globe",
"i-grid": "layout-grid",
"i-kebab": "ellipsis-vertical",
+18 -33
View File
@@ -6,18 +6,15 @@
`Bar = 1`
`Next ID = 40`
`Next ID = 42`
## Items
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 4 | 0.4.21 | | | **Refuse a permission save whose baseline no longer matches Keto, so concurrent edits are not silently lost.** | 2 | 2 | 2 | 4 | 4 | 2 by 4 | 2.8 |
| 7 | 0.4.21 | | | **Confirm a successful permission save, and report a partial failure instead of hiding it.** | 2 | 2 | 2 | 4 | 4 | 2 by 4 | 2.8 |
| 39 | 0.4.21 | | | **Pass an absolute `return_to` on to Kratos even when the URL also carries a chosen `locale`.** | 1 | 2 | 2 | 3 | 2 | 2 by 2 | 2.8 |
| 22 | 0.4.21 | | | **Offer a `users:read` holder a View row action on the users list, not Edit.** | 2 | 2 | 1 | 3 | 3 | 2 by 2 | 2.1 |
| 34 | 0.4.21 | | | **Serve every stylesheet and script under a content-versioned URL with a year-long immutable `Cache-Control`, so a page view downloads none of them again.** | 4 | 2 | 3 | 7 | 6 | 8 by 6, 2 by 2 | 1.8 |
| 3 | 0.4.21 | | | **Guard the group paths against self-lockout, or accept the gap explicitly.** | 3 | 2 | 2 | 5 | 6 | 2 by 6 | 1.4 |
| 40 | 0.4.21 | | | **Show a `users:read` holder "View user" as the user page's heading and breadcrumb, not "Edit user".** | 1 | 2 | 1 | 2 | 2 | 2 by 1 | 1.4 |
| 1 | 0.4.21 | | owner | **Publish every release to Docker Hub, and give the Hub page its short description.** | 3 | 3 | 2 | 6 | 5 | 1 by 3 | 1.3 |
| 32 | 0.4.21 | | | **Fail the gate on a stylesheet feature the browser floor lacks, unless it is guarded.** | 4 | 2 | 2 | 6 | 7 | code health by 2 | 1.1 |
| 13 | 0.4.21 | | | **Accept a verification code pasted with surrounding whitespace.** | 4 | 2 | 3 | 7 | 7 | 2 by 8 | 1.0 |
@@ -28,38 +25,11 @@
| 28 | 0.5.0 | | | **Let a plugin add entries to the profile menu through the plugin API.** | 2 | 2 | 2 | 4 | 4 | 7 by 4, 1 by 2 | 4.2 |
| 2 | 0.5.0 | | | **Rename the admin example plugin to a name that says what it administers.** | 3 | 1 | 2 | 4 | 6 | 9 by 3, code health by 2 | 2.6 |
| 5 | 0.5.0 | | | **List grants that no installed plugin declares, with a remove action.** | 2 | 2 | 2 | 4 | 4 | 2 by 3 | 2.1 |
| 41 | 0.5.0 | | | **Apply a permission save as one Keto call, so it cannot stop part-way.** | 2 | 2 | 2 | 4 | 3 | 2 by 2 | 2.1 |
| 14 | 0.5.0 | | | **Drop a double-clicked duplicate submit, with no client-side JavaScript.** | 4 | 2 | 3 | 7 | 7 | 2 by 7, 1 by 4 | 1.4 |
## Details
### 4. Refuse a permission save whose baseline no longer matches Keto, so concurrent edits are not silently lost.
Sketch: post the rendered set as a hidden baseline; when it no longer matches Keto, re-render with
"this changed while you had the page open" rather than applying.
### 7. Confirm a successful permission save, and report a partial failure instead of hiding it.
`applyGrants` loops writes then deletes with no transaction, so a Keto error midway leaves a
half-applied set behind the generic error page. The `alert alert-pos` pattern of the recovery-code
banner is available.
### 39. Pass an absolute `return_to` on to Kratos even when the URL also carries a chosen `locale`.
`flowReturnTo` (`src/auth/routes.ts`) returns the absolute target as-is only when no locale is chosen.
With `/login?return_to=<absolute OAuth2 login URL>&locale=sv-SE` and no `?flow=`, it builds
`<origin>/auth/complete?locale=sv-SE` instead and drops the absolute target, so after sign-in the
visitor lands on the dashboard and the OAuth2 login challenge never continues. Normal paths don't hit
it, because Hydra's redirect carries no `locale` and the language picker acts on a page that already
holds a `?flow=`; a hand-built URL does. Fix by keeping the absolute target when `localPath` rejects
it, losing the language across that round-trip instead, and add the case to the `login return_to`
test in `src/http/app.test.ts`.
### 22. Offer a `users:read` holder a View row action on the users list, not Edit.
The pencil opens the read-only detail page, so it is harmless, but the label contradicts what the
reader can do. Needs `canWrite` threaded into `listTable`, a `common.view` core catalog key and an
`i-eye` icon.
### 34. Serve every stylesheet and script under a content-versioned URL with a year-long immutable `Cache-Control`, so a page view downloads none of them again.
`serveStatic` (`src/http/static.ts`) sends only `content-length` and `content-type`, so every page
@@ -97,6 +67,13 @@ on a group you belong to, leaving that group, or deleting it can still strip you
warning. AGENTS.md records this as a known gap. The robust fix is a "last effective holder" check,
which needs a reverse Keto query.
### 40. Show a `users:read` holder "View user" as the user page's heading and breadcrumb, not "Edit user".
The users list now says View for a read-only holder, but the page it opens still titles itself
`admin.users.edit` ("Edit user") with an "Edit" breadcrumb. `buildUserFormModel` in
`examples/plugins/admin/admin-users.ts` already receives `canWrite`; the two admin catalog keys it
needs are new.
### 1. Publish every release to Docker Hub, and give the Hub page its short description.
Hub's newest tag is 0.4.0, while git has v0.4.1 onwards, and the overview still says 0.4.0. No
@@ -162,6 +139,14 @@ E2E stacks and README move with it.
`grantDiff` never revokes an undeclared name, and nothing shows one either, so it can't be audited or
cleaned, and reinstalling its plugin silently reactivates access nobody remembers granting.
### 41. Apply a permission save as one Keto call, so it cannot stop part-way.
`applyGrants` writes and deletes one tuple per request and reports a partial save when one fails.
**Check first:** Keto documents `PATCH /admin/relation-tuples`, which should apply a list of inserts
and deletes in one transaction; confirm that against the Keto version `ory/` pins. If so, add the
call to `KetoClient`, which is contract surface (`@plainpages/plugin-api`) and so needs 0.5.0, and the
partial-failure path in the admin example can go.
### 14. Drop a double-clicked duplicate submit, with no client-side JavaScript.
A second identical POST is expected from a non-technical user, and today creates two
+1
View File
@@ -9,6 +9,7 @@
<symbol id="i-chev" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6" /></symbol>
<symbol id="i-cols" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M9 3v18" /><path d="M15 3v18" /></symbol>
<symbol id="i-edit" viewBox="0 0 24 24"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /><path d="m15 5 4 4" /></symbol>
<symbol id="i-eye" viewBox="0 0 24 24"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" /><circle cx="12" cy="12" r="3" /></symbol>
<symbol id="i-globe" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" /><path d="M2 12h20" /></symbol>
<symbol id="i-grid" viewBox="0 0 24 24"><rect width="7" height="7" x="3" y="3" rx="1" /><rect width="7" height="7" x="14" y="3" rx="1" /><rect width="7" height="7" x="14" y="14" rx="1" /><rect width="7" height="7" x="3" y="14" rx="1" /></symbol>
<symbol id="i-kebab" viewBox="0 0 24 24"><circle cx="12" cy="12" r="1" /><circle cx="12" cy="5" r="1" /><circle cx="12" cy="19" r="1" /></symbol>