Items 62, 69, 64: group delete in one Keto transaction, dashboard view rename, permissions kept on self-refusals
CI / full-gate (push) Successful in 3m19s

This commit is contained in:
2026-11-08 23:04:18 +01:00
parent 9385f291a0
commit 428800fd62
10 changed files with 100 additions and 16 deletions
+15
View File
@@ -19,6 +19,21 @@ so all of them apply or none does. A `KetoClient` you build by hand, such as a t
method. The admin example saves a user's or group's permissions through it, so a Keto failure leaves method. The admin example saves a user's or group's permissions through it, so a Keto failure leaves
every grant as it was and answers with the error page. every grant as it was and answers with the error page.
### The admin example deletes a group's grants and members in one Keto transaction
A Keto failure while deleting a group now leaves the group as it was, with its grants and members,
and answers with the error page. It used to leave some grants gone and the members still listed.
### The admin example keeps a user's permissions section when it refuses a self-deactivate or self-delete
The refusal page used to drop the permissions section of the user form. It now shows the section, as
every other re-render of the form does.
### The core dashboard view is `dashboard`, not `index`
`views/index.ejs` is now `views/dashboard.ejs`, named for the `/dashboard` page it renders. A plugin
view that does `include("index")` must include `dashboard` instead.
### The admin example lists a user's or group's grants that no installed plugin declares, with a Remove ### The admin example lists a user's or group's grants that no installed plugin declares, with a Remove
A direct grant of a name no installed plugin declares, such as one left by an uninstalled plugin, A direct grant of a name no installed plugin declares, such as one left by an uninstalled plugin,
+1 -1
View File
@@ -1789,7 +1789,7 @@ src/ The app — strict tsc, no build step. *.test.ts sit beside
stylesheet-floor (the browser-floor check) stylesheet-floor (the browser-floor check)
plugin-api/ The `@plainpages/plugin-api` package — the author barrel, linked into /node_modules plugin-api/ The `@plainpages/plugin-api` package — the author barrel, linked into /node_modules
views/ Core EJS in the one app shell: home, index, auth, oauth-consent, error, 403/404/500/503, views/ Core EJS in the one app shell: home, dashboard, auth, oauth-consent, error, 403/404/500/503,
and partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, and partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card,
alert, menu/popover, theme switch, language picker, icon sprite). Domain screens live alert, menu/popover, theme switch, language picker, icon sprite). Domain screens live
in plugins. in plugins.
+29 -1
View File
@@ -103,7 +103,7 @@ Read most (opens and searches):
- `src/http/app.test.ts` 1 - `src/http/app.test.ts` 1
- `src/ui/chrome.test.ts` 1 - `src/ui/chrome.test.ts` 1
- `src/ui/chrome.ts` 1 - `src/ui/chrome.ts` 1
- `views/index.ejs` 1 - `views/dashboard.ejs` 1
- `views/partials/shell.ejs` 1 - `views/partials/shell.ejs` 1
## Reading cost, chunk e4d3bb0ac092, finished 2026-10-31T08:02:27Z ## Reading cost, chunk e4d3bb0ac092, finished 2026-10-31T08:02:27Z
@@ -485,3 +485,31 @@ Read most (opens and searches):
- `src/http/app.test.ts` 2 - `src/http/app.test.ts` 2
- `todo.md` 2 - `todo.md` 2
- `src/plugin-host/plugin.ts` 1 - `src/plugin-host/plugin.ts` 1
## Reading cost, chunk 8d5c5ad2ab7c, finished 2026-11-08T21:39:34Z
145906 new input tokens before the first commit.
Read most (opens and searches):
- `src/http/app-admin.test.ts` 8
- `README.md` 6
- `CHANGELOG.md` 5
- `compose.yml` 5
- `docs/decisions.md` 5
- `examples/plugins/auth-admin/admin-grants.test.ts` 4
- `examples/plugins/auth-admin/admin-users.ts` 4
- `src/auth/keto-client.ts` 4
- `todo.md` 4
- `examples/plugins/auth-admin/README.md` 3
## Reading cost, chunk ef984469151f, finished 2026-11-08T22:01:41Z
38668 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 7
- `AGENTS.md` 1
- `README.md` 1
- `docs/decisions.md` 1
+5 -4
View File
@@ -431,19 +431,20 @@ export const groupsDeleteConfirm = withGroupName(async (deps, name) => {
}) }, view: "confirm" }; }) }, view: "confirm" };
}, "write"); }, "write");
// POST /auth-admin/groups/:name/delete — remove every member tuple (the group ceases to exist). // POST /auth-admin/groups/:name/delete — remove every member tuple and held grant in one Keto
// transaction (the group ceases to exist, and a failure leaves it whole).
export const groupsDelete = withGroupName(async (deps, name) => { export const groupsDelete = withGroupName(async (deps, name) => {
const { ctx, keto, user } = deps; const { ctx, keto, user } = deps;
await guardedForm(ctx); // CSRF-verify the POST await guardedForm(ctx); // CSRF-verify the POST
const lost = await lostOnDelete(deps, name); const lost = await lostOnDelete(deps, name);
if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost); if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost);
// Drop what the group *holds* before what it *contains*: a Keto set exists only through its // Drop what the group *holds* along with what it *contains*: a Keto set exists only through its
// tuples, so leaving the grants behind would resurrect every permission the moment someone // tuples, so leaving the grants behind would resurrect every permission the moment someone
// re-created a group with the same name. // re-created a group with the same name.
const subject = groupSubject(name); const subject = groupSubject(name);
const held = await heldPermissions(keto, subject); const held = await heldPermissions(keto, subject);
for (const permission of held) await keto.deleteTuple(grantTuple(permission, subject)); const members = await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS });
await keto.deleteTuple({ namespace: GROUP_NS, object: name, relation: MEMBERS }); await keto.transactTuples({ delete: [...held.map((permission) => grantTuple(permission, subject)), ...members], insert: [] });
ctx.log.info("admin: group deleted", { actor: user.id, group: name, revoked: held.join(",") }); ctx.log.info("admin: group deleted", { actor: user.id, group: name, revoked: held.join(",") });
return { redirect: ADMIN_GROUPS_BASE }; return { redirect: ADMIN_GROUPS_BASE };
}); });
+14 -7
View File
@@ -392,7 +392,7 @@ export const usersUpdate = withTarget(async (deps, identity, id) => {
await kratosAdmin.updateIdentity(id, updateIdentityPayload(identity, input)); await kratosAdmin.updateIdentity(id, updateIdentityPayload(identity, input));
} catch (err) { } catch (err) {
// Re-render with the picker, or the permissions section vanishes off the page on a failed save. // Re-render with the picker, or the permissions section vanishes off the page on a failed save.
if (err instanceof KratosError) return { ...formResult(ctx, { canWrite: canWriteUsers(ctx), error: ctx.t("admin.users.error.save"), identity, ...(await pickerOrNothing(deps, id)) }), status: 400 }; if (err instanceof KratosError) return refuseForm(deps, identity, id, ctx.t("admin.users.error.save"));
throw err; throw err;
} }
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` }; return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
@@ -400,9 +400,10 @@ export const usersUpdate = withTarget(async (deps, identity, id) => {
// POST /auth-admin/users/:id/state — toggle active/inactive; a deactivation revokes the target's live // POST /auth-admin/users/:id/state — toggle active/inactive; a deactivation revokes the target's live
// tokens now (not after the JWT TTL). Self-protection: an admin can't deactivate their own account. // tokens now (not after the JWT TTL). Self-protection: an admin can't deactivate their own account.
export const usersState = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => { export const usersState = withTarget(async (deps, identity, id) => {
const { ctx, kratosAdmin, revoke, user } = deps;
await guardedForm(ctx); // CSRF-verify the POST (no fields read) await guardedForm(ctx); // CSRF-verify the POST (no fields read)
if (id === user.id) return { ...formResult(ctx, { error: ctx.t("admin.users.error.selfDeactivate"), identity }), status: 400 }; if (id === user.id) return refuseForm(deps, identity, id, ctx.t("admin.users.error.selfDeactivate"));
const nextState = identity.state === "inactive" ? "active" : "inactive"; const nextState = identity.state === "inactive" ? "active" : "inactive";
await kratosAdmin.updateIdentity(id, setStatePayload(identity, nextState)); await kratosAdmin.updateIdentity(id, setStatePayload(identity, nextState));
if (nextState === "inactive") revoke?.(id); if (nextState === "inactive") revoke?.(id);
@@ -412,7 +413,7 @@ export const usersState = withTarget(async ({ ctx, kratosAdmin, revoke, user },
// GET /auth-admin/users/:id/delete — the deliberate confirm step (zero-JS). Refuses self-delete. // GET /auth-admin/users/:id/delete — the deliberate confirm step (zero-JS). Refuses self-delete.
export const usersDeleteConfirm = withTarget((deps, identity, id) => { export const usersDeleteConfirm = withTarget((deps, identity, id) => {
if (id === deps.user.id) return Promise.resolve({ ...formResult(deps.ctx, { error: deps.ctx.t("admin.users.error.selfDelete"), identity }), status: 400 }); if (id === deps.user.id) return refuseForm(deps, identity, id, deps.ctx.t("admin.users.error.selfDelete"));
const back = `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}`; const back = `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}`;
const view = toUserView(identity); const view = toUserView(identity);
const tt = deps.ctx.t; const tt = deps.ctx.t;
@@ -424,9 +425,10 @@ export const usersDeleteConfirm = withTarget((deps, identity, id) => {
}, "write"); }, "write");
// POST /auth-admin/users/:id/delete — perform it; revoke the gone account's live tokens. Refuses self-delete. // POST /auth-admin/users/:id/delete — perform it; revoke the gone account's live tokens. Refuses self-delete.
export const usersDelete = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => { export const usersDelete = withTarget(async (deps, identity, id) => {
const { ctx, kratosAdmin, revoke, user } = deps;
await guardedForm(ctx); // CSRF-verify the POST await guardedForm(ctx); // CSRF-verify the POST
if (id === user.id) return { ...formResult(ctx, { error: ctx.t("admin.users.error.selfDelete"), identity }), status: 400 }; if (id === user.id) return refuseForm(deps, identity, id, ctx.t("admin.users.error.selfDelete"));
await kratosAdmin.deleteIdentity(id); await kratosAdmin.deleteIdentity(id);
revoke?.(id); revoke?.(id);
ctx.log.info("admin: user deleted", { actor: user.id, target: id }); ctx.log.info("admin: user deleted", { actor: user.id, target: id });
@@ -441,7 +443,12 @@ export const usersRecovery = withTarget(async (deps, identity, id) => {
return formResult(ctx, { canWrite: canWriteUsers(ctx), identity, recovery, ...(await pickerOrNothing(deps, id)) }); return formResult(ctx, { canWrite: canWriteUsers(ctx), identity, recovery, ...(await pickerOrNothing(deps, id)) });
}); });
// The picker as a spreadable fragment, so a re-render never silently drops the section. // The 400 re-render of the user's form with `error`: the form every refusal of a user action shows.
async function refuseForm(deps: UsersDeps, identity: Identity, id: string, error: string): Promise<RouteResult> {
return { ...formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), error, identity, ...(await pickerOrNothing(deps, id)) }), status: 400 };
}
// The picker as a spreadable fragment, so every form re-render keeps the section.
async function pickerOrNothing(deps: UsersDeps, id: string): Promise<{ permissions?: PermissionPicker }> { async function pickerOrNothing(deps: UsersDeps, id: string): Promise<{ permissions?: PermissionPicker }> {
const permissions = await userPermissionPicker(deps, id); const permissions = await userPermissionPicker(deps, id);
return permissions ? { permissions } : {}; return permissions ? { permissions } : {};
+1 -1
View File
@@ -26,7 +26,7 @@ test("every author-facing apiVersion sample matches the shipped contract", () =>
"examples/plugins/auth-admin/plugin.ts", "examples/plugins/auth-admin/plugin.ts",
"examples/plugins/scheduling/plugin.ts", "examples/plugins/scheduling/plugin.ts",
"release-tooling/dockerhub-overview.md.tmpl", "release-tooling/dockerhub-overview.md.tmpl",
"views/index.ejs", "views/dashboard.ejs",
]) { ]) {
const found = [...readFileSync(file, "utf8").matchAll(/apiVersion: "(\d+\.\d+\.\d+)"/g)].map((m) => m[1]); const found = [...readFileSync(file, "utf8").matchAll(/apiVersion: "(\d+\.\d+\.\d+)"/g)].map((m) => m[1]);
assert.ok(found.length > 0, `${file} should carry at least one apiVersion sample`); assert.ok(found.length > 0, `${file} should carry at least one apiVersion sample`);
+33
View File
@@ -368,6 +368,39 @@ test("admin permission grants: a Keto failure applies none of the changes and re
assert.equal(tuples.filter((tp) => tp.subject_set?.object === "eng").length, 0); assert.equal(tuples.filter((tp) => tp.subject_set?.object === "eng").length, 0);
}); });
test("admin users: a refused self-deactivate or self-delete keeps the permissions section", async (t) => {
const identities: Identity[] = [{ id: "admin1", traits: { email: "admin@x" } }];
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: fakeKeto(), kratosAdmin });
assert.notEqual(baselineOf(await (await get("/auth-admin/users/admin1")).text()), "", "the plain form carries the section");
const refusals = [
await post("/auth-admin/users/admin1/state", `_csrf=${token}`),
await get("/auth-admin/users/admin1/delete"),
await post("/auth-admin/users/admin1/delete", `_csrf=${token}`),
];
for (const res of refusals) {
assert.equal(res.status, 400);
assert.notEqual(baselineOf(await res.text()), "");
}
});
test("admin group delete: a Keto failure leaves the group's grants and members in place", async (t) => {
const ada = randomUUID();
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
const tuples: RelationTuple[] = [
{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` },
{ namespace: "Permission", object: "users:read", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } },
];
const keto = fakeKeto(tuples, { transactTuples: async () => { throw new Error("keto unreachable"); } });
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 res = await post("/auth-admin/groups/eng/delete", `_csrf=${token}`);
assert.equal(res.status, 500);
assert.equal(tuples.length, 2);
});
// A double-clicked Save posts twice from one page; the second post finds the first one's result. // 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. // 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) => { test("admin permission grants: a save that changes nothing is not refused for a stale page and leaves live tokens alone", async (t) => {
+1 -1
View File
@@ -406,7 +406,7 @@ test("/error renders a themed sign-in error page (Kratos' flow error sink), not
test("renders the 500 HTML page when a handler throws", async () => { test("renders the 500 HTML page when a handler throws", async () => {
const dir = mkdtempSync(join(tmpdir(), "pp-views-")); const dir = mkdtempSync(join(tmpdir(), "pp-views-"));
cpSync(viewsDir, dir, { recursive: true }); // the real views: 500.ejs includes the language picker cpSync(viewsDir, dir, { recursive: true }); // the real views: 500.ejs includes the language picker
writeFileSync(join(dir, "index.ejs"), "<% throw new Error('boom'); %>"); // …but the dashboard view throws writeFileSync(join(dir, "dashboard.ejs"), "<% throw new Error('boom'); %>"); // …but the dashboard view throws
const app = createApp({ jwks: staticJwks([ecJwk]), viewsDir: dir }); const app = createApp({ jwks: staticJwks([ecJwk]), viewsDir: dir });
try { try {
await new Promise<void>((resolve) => app.listen(0, resolve)); await new Promise<void>((resolve) => app.listen(0, resolve));
+1 -1
View File
@@ -165,7 +165,7 @@ export function createApp(options: AppOptions = {}): Server {
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, dashboardPlugin.id), pluginCtx.localeHref); await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, dashboardPlugin.id), pluginCtx.localeHref);
return null; return null;
} }
return { data: { chrome: ctx.chrome }, view: "index" }; return { data: { chrome: ctx.chrome }, view: "dashboard" };
}; };
// The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/ // The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/