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
CI / full-gate (push) Successful in 3m19s
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 } : {};
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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
@@ -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/
|
||||||
|
|||||||
Reference in New Issue
Block a user