clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

fix(access): authorize group-creation rollback, document creator superseat

Dami committed 58ba190
Resolves the authorization findings ACC-1 and ACC-2 from the full review.

ACC-1 (creator permanently retains membership-management authority) turns
out to be unfixable at the policy boundary on pinned jazz-tools alpha.53:
the intended bootstrap window ("creator authority only while no admin
membership exists") requires a negated existence condition, and the
alpha.53 policy engine SILENTLY DROPS Not around Exists/ExistsRel —
Not(ExistsRel(...)) evaluates as if un-negated. A LEFT-join IsNull
anti-join is not null-extended in policy evaluation either. Probe results
are recorded in docs/decisions/group-creator-authority-alpha53.md.

Consequently:
- Creator authority becomes an explicit, documented trust property (a
  permanent superseat), stated in policies.ts, permissions.md, the group
  example, and the access reference, so products do not present creator
  handover as complete.
- ACC-2: groups.allowDelete gains the $createdBy branch, which grants no
  net-new authority (a creator can already self-bootstrap admin and delete
  transitively) but authorizes createGroup's compensating delete — a failed
  first-admin insert no longer strands an undeletable orphan group.
- An engine canary test asserts the dropped-negation behavior; it fails
  the moment a jazz-tools upgrade fixes Not, prompting the bootstrap-window
  design recorded in the decision record.
- New harness tests codify the permanent-superseat contract (exclusive to
  the creator, survives demotion/removal) and creator-only orphan deletion.
commit
58ba190b352ddb2dc88d5f62516beba9b4125679
parent
810e6fb17af9b999096e84fb8ffd999cd956968b
author
Dami <dami.etoile@gmail.com>
7 files changed, +264 -5
Adocs/decisions/group-creator-authority-alpha53.md70++++++++++++++
Mdocs/examples/group.md5+-
Mdocs/permissions.md10++
Mdocs/reference/access.md11++-
Mpackage/access/access_security_test.ts150++++++++++++++++++++++++++++++
Mpackage/access/operations.ts3+
Mpackage/access/policies.ts20++++-
A docs/decisions/group-creator-authority-alpha53.md +70 -0
@@ -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`).
M docs/examples/group.md +4 -1
M docs/permissions.md +10 -0
M docs/reference/access.md +8 -3
M package/access/access_security_test.ts +150 -0
@@ -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() }),
M package/access/operations.ts +3 -0
M package/access/policies.ts +19 -1
@@ -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 },