clone

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

Merge pull request #85 from FelineStateMachine/fix/access-authorization

FelineStateMachine committed c140e22
fix(access): authorize group-creation rollback, document creator superseat
commit
c140e223a9e838225855feb5fa38922b471fabed
parent
810e6fb17af9b999096e84fb8ffd999cd956968b
parent
58ba190b352ddb2dc88d5f62516beba9b4125679
author
FelineStateMachine <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 },