fix(access): authorize group-creation rollback, document creator superseat
- commit
- c140e223a9e838225855feb5fa38922b471fabed
- parent
- 810e6fb17af9b999096e84fb8ffd999cd956968b
- parent
- 58ba190b352ddb2dc88d5f62516beba9b4125679
- author
- FelineStateMachine <dami.etoile@gmail.com>
| A | docs/ | 70 | ++++++++++++++ |
| M | docs/ | 5 | +- |
| M | docs/ | 10 | ++ |
| M | docs/ | 11 | ++- |
| M | package/ | 150 | ++++++++++++++++++++++++++++++ |
| M | package/ | 3 | + |
| M | package/ | 20 | ++++- |
@@ -0,0 +1,70 @@
1+# Group-creator authority under Jazz alpha.53 policy negation limits2+3+Date: 2026-07-17\4+Reviewed dependency: `jazz-tools@2.0.0-alpha.53`\5+Decision: document permanent creator authority; grant direct creator delete; revisit on upgrade6+7+## Problem8+9+The `groupAccess` template gives a group's creator `$createdBy` update authority on the group row so10+they can insert the first admin membership (membership management derives from group-update via11+`allowedTo.update("groupId")`). That authority never lapses: a creator who is later demoted or12+removed by other admins can restore their own admin membership at any time. Separately, a failed13+first-admin insert used to strand an undeletable orphan group, because rollback deletion required14+the admin membership that had just failed to insert.15+16+The intended fix was a **bootstrap window**: creator authority over the group row holds only while17+the group has no admin membership. That requires a negated existence condition — "no admin18+membership row exists for this group".19+20+## Exact alpha.53 engine behavior21+22+The policy IR (`schema.js` `PolicyExpr`) declares `{ type: "Not", expr }`, the permissions compiler23+passes raw IR through, and `schema-permissions.js` serializes `Not` for the wasm core. The core,24+however, **silently drops `Not` around existence conditions**. Probed against `createPolicyTestApp`25+with a two-table group/members app, rule under test on `allowDelete`:26+27+| Probe | Expected | alpha.53 result |28+| ------------------------------------------------- | ---------- | ------------------------------- |29+| `Not(True)` / `Not(False)` | deny/allow | correct |30+| `Not(Cmp column = literal)` | deny | correct |31+| `Exists(...)` / `ExistsRel(...)` (pos + neg ctrl) | — | correct |32+| `Not(Exists(...))`, no matching rows | allow | **denied** |33+| `Not(ExistsRel(...))`, matching row present | deny | **allowed** |34+| `And(True, Not(ExistsRel(...)))`, row present | deny | **allowed** |35+| LEFT-join + `IsNull` anti-join via `ExistsRel` | allow | denied (join not null-extended) |36+37+`Not` over `Exists`/`ExistsRel` evaluates as if the `Not` were absent — a silent wrong-direction38+result, not an error. The `RelExpr` LEFT-join anti-join alternative does not produce null-extended39+rows in policy evaluation, so absence-of-rows is not expressible at all in this engine.40+41+## Decision42+43+- **Creator authority over the group row stays permanent and becomes a documented trust property**44+ of the template: the creator can always update the group and therefore always restore their own45+ admin membership. Products that need creator-proof handover cannot get it from this template on46+ this engine.47+- **The creator additionally receives direct `allowDelete` on their own group rows.** This grants no48+ new authority — a creator can already self-bootstrap an admin membership and delete transitively —49+ and it lets `createGroup` roll back cleanly when the first-admin insert fails, removing the50+ orphan-group failure mode.51+- **An engine canary test** (`access_security_test.ts`, "engine canary") asserts the broken52+ behavior: a `Not(ExistsRel(...))` delete guard that a correct engine would deny. When a jazz-tools53+ upgrade fixes negation, the canary fails, signalling that the bootstrap-window design in this54+ record should replace permanent creator authority:55+ `allowUpdate/allowDelete = anyOf([isAdmin, allOf([$createdBy, Not(Exists(admin membership))])])`,56+ which also preserves creator recovery of admin-less groups.57+58+## Trust and fallback59+60+- The template's authority model is: fixed roles for members, plus a permanent creator superseat.61+ The sharing UI and docs state this; it must not be presented as revocable admin membership.62+- Upstream: the dropped-negation behavior is a jazz-tools core issue worth reporting; until then, no63+ lofi policy may rely on `Not` around `Exists`/`ExistsRel`.64+65+## Evidence sources66+67+- Probe suite output recorded in this record's table (2026-07-17, Deno harness68+ `createPolicyTestApp`, pinned npm cache for `jazz-tools@2.0.0-alpha.53`).69+- `dist/permissions/index.js` (raw IR pass-through), `dist/schema-permissions.js` (`Not`70+ serialization), `dist/ir.d.ts` (`PolicyExprV2.Not`, `RelExpr`).@@ -41,4 +41,7 @@ await groups.removeMember(group.id, recipientIdentity);
4141 4242 Readers can read. Contributors can create and edit their own rows. Writers can edit any group row.4343 Admins can do everything writers can and manage membership. `leaveGroup` removes the current44-principal's membership. Managed sync is required for every group operation.44+principal's membership. Managed sync is required for every group operation. The group's creator45+additionally keeps a permanent superseat: demoting or removing them does not end their ability to46+manage the group — see [Permission templates](../permissions.md#fixed-group-roles) before building47+handover flows.@@ -109,6 +109,16 @@ The helper stores the role label plus fixed capability bits because the pinned J
109109 role-string comparisons inside relationship policies. Only the four role names are accepted by the110110 typed operations; custom roles belong in a raw Jazz policy.111111 112+**The group creator holds a permanent superseat.** Whoever created the group row can always update113+it and — because membership management derives from group-update authority — can always restore114+their own admin membership, even after other admins demote or remove them. This is a documented115+property of the template, not revocable admin membership: the pinned Jazz alpha cannot express116+"creator authority only until the first admin exists" (its policy engine drops negated existence117+conditions), so do not present creator handover as complete in product UI. The creator can also118+delete their own group rows, which is what lets group creation roll back cleanly if bootstrapping119+the first admin membership fails. See120+[the decision record](decisions/group-creator-authority-alpha53.md).121+112122 Direct shares and group membership use a non-secret `lofi1:<app-id>:<jazz-user-id>` sharing113123 identity. It is safe to copy for this purpose but is not a directory, invitation, or sign-in token.114124 The package deliberately does not discover users or deliver invitations.@@ -36,9 +36,14 @@ Collaboration operations reject local-only use with `AccessError.code === "sync-
3636 In pinned Jazz alpha.53, the permission authority does not expose a group inserted earlier in the3737 same transaction to the first membership's `allowedTo.update("groupId")` check. `createGroup`3838 therefore serializes the creator-owned group write and first-admin membership write, and attempts a39-compensating group delete if the membership is rejected. This is secure and leaves no usable40-unadministered group in the tested path, but it is not database-transaction atomic. Do not describe41-it as atomic until Jazz can validate that relationship against staged transaction rows.39+compensating group delete if the membership is rejected. The compensating delete is authorized by40+the creator's direct delete authority on their own group rows, so a failed bootstrap does not strand41+an orphan group. This is secure, but it is not database-transaction atomic. Do not describe it as42+atomic until Jazz can validate that relationship against staged transaction rows.43+44+The group creator additionally holds a permanent superseat — see45+[Permission templates](../permissions.md#fixed-group-roles) and46+[the decision record](../decisions/group-creator-authority-alpha53.md).4247 4348 The templates compile ordinary Jazz policies and accept a final raw-policy callback. They do not4449 replace `s.table`, `s.defineApp`, or raw `s.definePermissions`; use those directly when the fixed@@ -281,6 +281,156 @@ Deno.test("official Jazz harness enforces fixed group roles and membership lifec
281281 }282282 });283283 284+Deno.test("group-creator authority is permanent, exclusive, and survives demotion", async () => {285+ const testApp = await createPolicyTestApp(app, permissions, expectLike);286+ try {287+ const creator = testApp.as(session("creator"));288+ const successor = testApp.as(session("successor"));289+ const stranger = testApp.as(session("stranger"));290+291+ const group = await creator.insert(app.workspaces, { name: "handover" }).wait({292+ tier: "global",293+ });294+ // Creator authority never extends to anyone else.295+ stranger.expectDenied((db) => db.update(app.workspaces, group.id, { name: "hijacked" }));296+ stranger.expectDenied((db) =>297+ db.insert(app.workspaceMembers, {298+ groupId: group.id,299+ user_id: "stranger",300+ ...groupRoleCapabilities("admin"),301+ })302+ );303+ stranger.expectDenied((db) => db.delete(app.workspaces, group.id));304+305+ const creatorMembership = await creator.insert(app.workspaceMembers, {306+ groupId: group.id,307+ user_id: "creator",308+ ...groupRoleCapabilities("admin"),309+ }).wait({ tier: "global" });310+ await creator.insert(app.workspaceMembers, {311+ groupId: group.id,312+ user_id: "successor",313+ ...groupRoleCapabilities("admin"),314+ }).wait({ tier: "global" });315+316+ // The documented trust property of the template on the pinned Jazz317+ // alpha.53 engine: demoting or removing the creator does NOT end their318+ // authority — they retain group update and, through it, membership319+ // management, so they can always restore their own admin membership.320+ // The engine cannot express "creator authority only while no admin321+ // exists" (see the negation canary below); when that becomes possible,322+ // this contract is expected to change to a bootstrap-only window.323+ successor.expectAllowed((db) =>324+ db.update(app.workspaceMembers, creatorMembership.id, groupRoleCapabilities("reader"))325+ );326+ creator.expectAllowed((db) =>327+ db.update(app.workspaceMembers, creatorMembership.id, groupRoleCapabilities("admin"))328+ );329+ successor.expectAllowed((db) => db.delete(app.workspaceMembers, creatorMembership.id));330+ creator.expectAllowed((db) =>331+ db.insert(app.workspaceMembers, {332+ groupId: group.id,333+ user_id: "creator",334+ ...groupRoleCapabilities("admin"),335+ })336+ );337+ } finally {338+ await testApp.shutdown();339+ }340+});341+342+Deno.test("engine canary: pinned alpha.53 silently drops Not around existence conditions", async () => {343+ // Guard rule that SHOULD deny deletion while an admin membership exists:344+ // Not(ExistsRel(members WHERE groupId = <this group> AND can_manage)).345+ // The pinned engine ignores the negation, so the delete is ALLOWED below.346+ // WHEN THIS TEST FAILS after a jazz-tools upgrade: negation started347+ // working — replace permanent group-creator authority with the348+ // bootstrap-only window design in349+ // docs/decisions/group-creator-authority-alpha53.md and update the350+ // permanent-authority contract test above.351+ const canaryPermissions = s.definePermissions(app, (rawContext) => {352+ const { policy } = rawContext as unknown as {353+ policy: Record<354+ string,355+ {356+ allowRead: { always(): unknown };357+ allowInsert: { always(): unknown };358+ allowDelete: { where(input: unknown): unknown };359+ }360+ >;361+ };362+ policy.workspaces.allowRead.always();363+ policy.workspaces.allowInsert.always();364+ policy.workspaceMembers.allowRead.always();365+ policy.workspaceMembers.allowInsert.always();366+ policy.workspaces.allowDelete.where({367+ type: "Not",368+ expr: {369+ type: "ExistsRel",370+ rel: {371+ Filter: {372+ input: { TableScan: { table: "workspaceMembers" } },373+ predicate: {374+ And: [375+ {376+ Cmp: {377+ left: { column: "groupId" },378+ op: "Eq",379+ right: { OuterColumn: { column: "id" } },380+ },381+ },382+ {383+ Cmp: {384+ left: { column: "can_manage" },385+ op: "Eq",386+ right: { Literal: true },387+ },388+ },389+ ],390+ },391+ },392+ },393+ },394+ });395+ });396+ const testApp = await createPolicyTestApp(app, canaryPermissions, expectLike);397+ try {398+ const creator = testApp.as(session("creator"));399+ const group = await creator.insert(app.workspaces, { name: "canary" }).wait({400+ tier: "global",401+ });402+ await creator.insert(app.workspaceMembers, {403+ groupId: group.id,404+ user_id: "creator",405+ ...groupRoleCapabilities("admin"),406+ }).wait({ tier: "global" });407+ // A working Not would DENY this delete (an admin membership exists).408+ creator.expectAllowed((db) => db.delete(app.workspaces, group.id));409+ } finally {410+ await testApp.shutdown();411+ }412+});413+414+Deno.test("a group without its first admin remains deletable by its creator only", async () => {415+ const testApp = await createPolicyTestApp(app, permissions, expectLike);416+ try {417+ const creator = testApp.as(session("creator"));418+ const stranger = testApp.as(session("stranger"));419+ // A group whose first-admin insert failed: the row exists, no membership.420+ const orphan = await creator.insert(app.workspaces, { name: "orphan" }).wait({421+ tier: "global",422+ });423+ stranger.expectDenied((db) => db.delete(app.workspaces, orphan.id));424+ await creator.delete(app.workspaces, orphan.id).wait({ tier: "global" });425+ assert(426+ (await creator.all(app.workspaces.where({ id: orphan.id }))).length === 0,427+ "the creator's rollback delete did not remove the orphaned group",428+ );429+ } finally {430+ await testApp.shutdown();431+ }432+});433+284434 Deno.test("access templates reject malformed relationship tables with actionable errors", () => {285435 const malformed = s.defineApp({286436 docs: s.table({ title: s.string() }),@@ -174,6 +174,9 @@ export function createGroupOperations<
174174 } as MemberInit));175175 return { group, membership };176176 } catch (cause) {177+ // The creator's direct delete authority on their own group row178+ // authorizes this rollback, so a failed first-admin insert no longer179+ // strands an undeletable orphan group.177180 await settle(db.delete(config.groups, group.id)).catch(() => undefined);178181 throw cause;179182 }@@ -195,6 +195,22 @@ export function defineAccessPolicies<TApp extends object>(
195195 const hasCapability = (groupId: unknown, capability: string) =>196196 members.exists.where({ groupId, user_id: session.user_id, [capability]: true });197197 const isAdmin = (groupId: unknown) => hasCapability(groupId, "can_manage");198+ // Group-creator authority is PERMANENT by design: the creator can always199+ // update the group row, and — because membership management derives from200+ // group-update via `allowedTo.update` — can always restore their own201+ // admin membership after demotion or removal. This is a documented trust202+ // property of the template, not an oversight: scoping creator authority203+ // to a bootstrap window ("only while no admin membership exists")204+ // requires a negated existence condition, and the pinned Jazz alpha.53205+ // policy engine silently drops Not around Exists/ExistsRel — the window206+ // cannot be enforced at the policy boundary. The engine canary in207+ // access_security_test.ts fails when an upgrade fixes negation; implement208+ // the window then. See docs/decisions/group-creator-authority-alpha53.md.209+ //210+ // Direct creator delete below grants no additional authority (a creator211+ // can always self-bootstrap an admin membership and delete transitively);212+ // it exists so group creation can roll back when the first admin insert213+ // fails, instead of stranding an undeletable orphan row.198214 groups.allowRead.where((group: Record<string, unknown>) =>199215 anyOf([isMember(group.id), { $createdBy: session.user_id }])200216 );@@ -202,7 +218,9 @@ export function defineAccessPolicies<TApp extends object>(
202218 groups.allowUpdate.where((group: Record<string, unknown>) =>203219 anyOf([{ $createdBy: session.user_id }, isAdmin(group.id)])204220 );205- groups.allowDelete.where((group: Record<string, unknown>) => isAdmin(group.id));221+ groups.allowDelete.where((group: Record<string, unknown>) =>222+ anyOf([{ $createdBy: session.user_id }, isAdmin(group.id)])223+ );206224 members.allowRead.where(207225 anyOf([208226 { user_id: session.user_id },