diff --git a/CHANGELOG.md b/CHANGELOG.md index 09d97f2..a630eb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 -Deleting a group now also deletes the tuples that made it a member of another group, in the same -transaction. It used to leave them, so a group re-created with the same name was back in every parent. +Deleting a group now also removes it from every group it was a member of, in the same Keto +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 diff --git a/examples/plugins/auth-admin/admin-groups.ts b/examples/plugins/auth-admin/admin-groups.ts index 3ef5d4d..4b869fc 100644 --- a/examples/plugins/auth-admin/admin-groups.ts +++ b/examples/plugins/auth-admin/admin-groups.ts @@ -2,7 +2,7 @@ // A group is a Keto subject set `Group:#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 // 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, // each returning a RouteResult. @@ -428,8 +428,10 @@ export const groupsDelete = withGroupName(async (deps, name) => { const lost = await lostOnDelete(deps, name); 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 - // tuples behind would resurrect every permission and nesting the moment someone re-created a - // group with the same name. + // tuples behind would resurrect every permission and parent membership the moment someone + // 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 held = await heldPermissions(keto, subject); const grants = held.map((permission) => grantTuple(permission, subject)); diff --git a/todo.md b/todo.md index 8ea3a4b..848f488 100644 --- a/todo.md +++ b/todo.md @@ -6,7 +6,7 @@ `Bar = 1` -`Next ID = 72` +`Next ID = 73` ## 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 | | 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 | +| 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 | | 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 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:#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:#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`. `src/auth` holds 19 modules over several concerns: the per-request session path (`jwt*`, `jwks`,