Answer product-owner round 1: 0 🔴, 1 🟡 (0 misread); declined and filed as item 72, comment and changelog say a parent left empty vanishes

This commit is contained in:
2026-11-09 01:25:14 +01:00
parent d6b8a7982e
commit c9485631c8
3 changed files with 33 additions and 6 deletions
+4 -2
View File
@@ -32,8 +32,10 @@ and answers with the error page. It used to leave some grants gone and the membe
### The admin example removes a deleted group from the groups it was nested in ### The admin example removes a deleted group from the groups it was nested in
Deleting a group now also deletes the tuples that made it a member of another group, in the same Deleting a group now also removes it from every group it was a member of, in the same Keto
transaction. It used to leave them, so a group re-created with the same name was back in every parent. transaction as the rest. It used to stay listed there, so a group re-created with the same name was
back in every parent. A parent whose only member was the deleted group is left with no members, and
so no longer exists as a group.
### The admin example keeps a user's permissions section when it refuses a self-deactivate or self-delete ### The admin example keeps a user's permissions section when it refuses a self-deactivate or self-delete
+5 -3
View File
@@ -2,7 +2,7 @@
// A group is a Keto subject set `Group:<name>#members`; a member is a user or a nested group (see // A group is a Keto subject set `Group:<name>#members`; a member is a user or a nested group (see
// parseSubject). Writes go only to Keto (README "stateless"). Keto has no "create object" — a group // parseSubject). Writes go only to Keto (README "stateless"). Keto has no "create object" — a group
// exists exactly while it has ≥1 member, so create writes its first-member tuple and delete removes // exists exactly while it has ≥1 member, so create writes its first-member tuple and delete removes
// every member tuple. Pure builders turn tuples + the request URL into view models; below them are thin // every tuple that makes it a group or puts it in another. Pure builders turn tuples + the request URL into view models; below them are thin
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded, // per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
// each returning a RouteResult. // each returning a RouteResult.
@@ -428,8 +428,10 @@ export const groupsDelete = withGroupName(async (deps, name) => {
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);
// A Keto set exists only through its tuples, so leaving the grants or the parents' membership // A Keto set exists only through its tuples, so leaving the grants or the parents' membership
// tuples behind would resurrect every permission and nesting the moment someone re-created a // tuples behind would resurrect every permission and parent membership the moment someone
// group with the same name. // re-created a group with the same name. A parent that had only this group as a member is left
// with none and so ceases to exist, as when its last member is removed by hand; its own grants
// and parent memberships stay (todo.md).
const subject = groupSubject(name); const subject = groupSubject(name);
const held = await heldPermissions(keto, subject); const held = await heldPermissions(keto, subject);
const grants = held.map((permission) => grantTuple(permission, subject)); const grants = held.map((permission) => grantTuple(permission, subject));
+24 -1
View File
@@ -6,7 +6,7 @@
`Bar = 1` `Bar = 1`
`Next ID = 72` `Next ID = 73`
## Items ## Items
@@ -15,6 +15,7 @@
| 19 | 0.6.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 | | 19 | 0.6.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 |
| 16 | 0.6.0 | | question | **Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.** | 3 | 2 | 3 | 6 | 6 | 7 by 2 | 0.5 | | 16 | 0.6.0 | | question | **Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.** | 3 | 2 | 3 | 6 | 6 | 7 by 2 | 0.5 |
| 65 | 0.6.0 | | | **Export `KetoError` from `@plainpages/plugin-api`, so a plugin can tell a Keto failure apart with `instanceof`.** | 1 | 2 | 1 | 2 | 1 | 7 by 3, 1 by 1 | 6.7 | | 65 | 0.6.0 | | | **Export `KetoError` from `@plainpages/plugin-api`, so a plugin can tell a Keto failure apart with `instanceof`.** | 1 | 2 | 1 | 2 | 1 | 7 by 3, 1 by 1 | 6.7 |
| 72 | 0.6.0 | | | **Delete a group's grants and parent memberships when its last member leaves, by group delete or by removing the member, so re-creating the name restores none.** | 2 | 2 | 2 | 4 | 3 | 2 by 4 | 4.2 |
| 67 | 0.6.0 | | | **Group `src/auth`'s modules into one subfolder per concern, with `bootstrap.ts` staying at `src/auth/bootstrap.ts`.** | 3 | 1 | 2 | 4 | 6 | code health by 4 | 4.0 | | 67 | 0.6.0 | | | **Group `src/auth`'s modules into one subfolder per concern, with `bootstrap.ts` staying at `src/auth/bootstrap.ts`.** | 3 | 1 | 2 | 4 | 6 | code health by 4 | 4.0 |
| 14 | 0.6.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 | | 14 | 0.6.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 |
@@ -43,6 +44,28 @@ surface, so this adds to the plugin API: it ships in a minor, and `HOST_API_VERS
`0.6.0` when 0.6.0 is tagged (AGENTS.md → Rules). Adding is safe for installed plugins; removing it `0.6.0` when 0.6.0 is tagged (AGENTS.md → Rules). Adding is safe for installed plugins; removing it
later would take a major. later would take a major.
### 72. Delete a group's grants and parent memberships when its last member leaves, by group delete or by removing the member, so re-creating the name restores none.
In `examples/plugins/auth-admin/admin-groups.ts` a group exists exactly while it has a
`Group:<name>#members` tuple. Two paths empty a group but keep its other tuples, so re-creating the
name silently hands every new member the old permissions and parent memberships:
1. `groupsDelete` of a group that is a parent's only member: the parent ceases to exist but keeps
its `Permission:*#granted@Group:<parent>#members` grants and its memberships in grandparents.
2. `groupsRemoveMember` removing a group's last member, which today is a lone `deleteTuple`.
Fix both in one `transactTuples` with the removal: when the removal leaves a group with no members,
add its grants and the tuples naming it as a member of other groups to the delete. The cascade is
transitive: a grandparent emptied by its parent vanishing is the same bug one level up. The walk
stops at a group that keeps any member, so a nesting cycle (`groupsAddMember` refuses only a
self-nest) ends it. An emptied group grants nobody, so the cascade cannot change what
`lostToSelf` computes. Test it with nesting two levels deep and with a cycle.
When it ships, rewrite the comment at `groupsDelete` that points here, and the CHANGELOG entry "The
admin example removes a deleted group from the groups it was nested in", which then says a parent
left with no members no longer exists as a group. `lostToSelf`, in this file, is a place the seats
found hard; if the change touches it, make it easier to read.
### 67. Group `src/auth`'s modules into one subfolder per concern, with `bootstrap.ts` staying at `src/auth/bootstrap.ts`. ### 67. Group `src/auth`'s modules into one subfolder per concern, with `bootstrap.ts` staying at `src/auth/bootstrap.ts`.
`src/auth` holds 19 modules over several concerns: the per-request session path (`jwt*`, `jwks`, `src/auth` holds 19 modules over several concerns: the per-request session path (`jwt*`, `jwks`,