Answer stability round 1: 0 🔴, 6 🟡 (0 misread); pin which tuples a group delete keeps, test the self-refusal reasons and the failed save, one refuseForm comment

This commit is contained in:
2026-11-08 23:11:15 +01:00
parent 5088352fcf
commit a0ad0ddf2d
5 changed files with 58 additions and 51 deletions
+6 -6
View File
@@ -431,20 +431,20 @@ export const groupsDeleteConfirm = withGroupName(async (deps, name) => {
}) }, view: "confirm" };
}, "write");
// 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).
// POST /auth-admin/groups/:name/delete — one Keto transaction removes the group's member tuples
// and the grants it holds, so a Keto failure leaves the group as it was.
export const groupsDelete = withGroupName(async (deps, name) => {
const { ctx, keto, user } = deps;
await guardedForm(ctx); // CSRF-verify the POST
const lost = await lostOnDelete(deps, name);
if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost);
// 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
// re-created a group with the same name.
// A Keto set exists only through its tuples, so leaving the grants behind would resurrect every
// permission the moment someone re-created a group with the same name.
const subject = groupSubject(name);
const held = await heldPermissions(keto, subject);
const grants = held.map((permission) => grantTuple(permission, subject));
const members = await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS });
await keto.transactTuples({ delete: [...held.map((permission) => grantTuple(permission, subject)), ...members], insert: [] });
await keto.transactTuples({ delete: [...grants, ...members], insert: [] });
ctx.log.info("admin: group deleted", { actor: user.id, group: name, revoked: held.join(",") });
return { redirect: ADMIN_GROUPS_BASE };
});
+2 -2
View File
@@ -391,7 +391,6 @@ export const usersUpdate = withTarget(async (deps, identity, id) => {
try {
await kratosAdmin.updateIdentity(id, updateIdentityPayload(identity, input));
} catch (err) {
// Re-render with the picker, or the permissions section vanishes off the page on a failed save.
if (err instanceof KratosError) return refuseForm(deps, identity, id, ctx.t("admin.users.error.save"));
throw err;
}
@@ -443,7 +442,8 @@ export const usersRecovery = withTarget(async (deps, identity, id) => {
return formResult(ctx, { canWrite: canWriteUsers(ctx), identity, recovery, ...(await pickerOrNothing(deps, id)) });
});
// The 400 re-render of the user's form with `error`: the form every refusal of a user action shows.
// The 400 re-render of the user's form with `error` above it. `usersPermissions` refuses through its
// own `refuse`, which puts the error in the permissions section instead.
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 };
}