clone

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

feat/141-demo-review-fixes
lofi / package / access / access_security_test.ts
access_security_test.ts 19.4K
1import { schema as s } from "jazz-tools";2import { createPolicyTestApp } from "jazz-tools/testing";3import {4 defineAccessPolicies,5 groupAccess,6 groupMembershipTable,7 groupRoleCapabilities,8 privateAccess,9 sharedAccess,10 sharedGrantTable,11} from "./mod.ts";12import { s as lofiSchema } from "../schema/mod.ts";13import { clearEncryptedColumnRegistry } from "../schema/encrypted.ts";14import { assert } from "../runtime/test-assert.ts";1516const schema = {17 privateDocs: s.table({ title: s.string() }),18 sharedDocs: s.table({ title: s.string() }),19 sharedDocGrants: sharedGrantTable("sharedDocs"),20 workspaces: s.table({ name: s.string() }),21 workspaceMembers: groupMembershipTable("workspaces"),22 groupDocs: s.table({ workspaceId: s.ref("workspaces"), title: s.string() }),23};24const app = s.defineApp(schema);25const permissions = defineAccessPolicies(app, [26 privateAccess({ resource: app.privateDocs }),27 sharedAccess({ resource: app.sharedDocs, grants: app.sharedDocGrants }),28 groupAccess({29 groups: app.workspaces,30 members: app.workspaceMembers,31 resources: app.groupDocs,32 groupId: "workspaceId",33 }),34]);3536type ThrowExpectation = {37 toThrow(expected?: unknown): void;38 not: { toThrow(expected?: unknown): void };39};4041function expectLike(value: unknown): ThrowExpectation {42 const run = (): unknown => {43 if (typeof value !== "function") throw new Error("expectLike requires a callback");44 try {45 value();46 return undefined;47 } catch (error) {48 return error;49 }50 };51 const matches = (error: unknown, expected: unknown): boolean => {52 if (expected === undefined) return error !== undefined;53 const message = error instanceof Error ? error.message : String(error);54 return typeof expected === "string"55 ? message.includes(expected)56 : error instanceof (expected as typeof Error);57 };58 return {59 toThrow(expected?: unknown) {60 const error = run();61 if (!matches(error, expected)) {62 throw new Error(`expected callback to throw ${String(expected)}`);63 }64 },65 not: {66 toThrow(expected?: unknown) {67 const error = run();68 if (error !== undefined && matches(error, expected)) {69 throw new Error(`expected callback not to throw ${String(expected)}`, { cause: error });70 }71 },72 },73 };74}7576const session = (user_id: string) => ({ user_id, claims: {}, authMode: "local-first" as const });7778Deno.test("official Jazz harness enforces private and direct-share templates", async () => {79 const testApp = await createPolicyTestApp(app, permissions, expectLike);80 try {81 const owner = testApp.as(session("owner"));82 const reader = testApp.as(session("reader"));83 const editor = testApp.as(session("editor"));84 const stranger = testApp.as(session("stranger"));8586 const privateDoc = await owner.insert(app.privateDocs, { title: "private" }).wait({87 tier: "global",88 });89 assert(90 (await owner.all(app.privateDocs.where({ id: privateDoc.id }))).length === 1,91 "owner lost private row",92 );93 assert(94 (await stranger.all(app.privateDocs.where({ id: privateDoc.id }))).length === 0,95 "unrelated user read private row",96 );97 stranger.expectDenied((db) => db.update(app.privateDocs, privateDoc.id, { title: "denied" }));9899 const sharedDoc = await owner.insert(app.sharedDocs, { title: "shared" }).wait({100 tier: "global",101 });102 const readGrant = await owner.insert(app.sharedDocGrants, {103 resourceId: sharedDoc.id,104 user_id: "reader",105 can_edit: false,106 }).wait({ tier: "global" });107 await owner.insert(app.sharedDocGrants, {108 resourceId: sharedDoc.id,109 user_id: "editor",110 can_edit: true,111 }).wait({ tier: "global" });112 let ownerRows: Array<s.RowOf<typeof app.sharedDocs>> = [];113 let readerRows: Array<s.RowOf<typeof app.sharedDocs>> = [];114 let editorRows: Array<s.RowOf<typeof app.sharedDocs>> = [];115 const liveSharedDoc = app.sharedDocs.where({ id: sharedDoc.id });116 const stopOwner = owner.subscribeAll(liveSharedDoc, (delta) => ownerRows = delta.all);117 const stopReader = reader.subscribeAll(liveSharedDoc, (delta) => readerRows = delta.all);118 const stopEditor = editor.subscribeAll(liveSharedDoc, (delta) => editorRows = delta.all);119120 assert(121 (await reader.all(app.sharedDocs.where({ id: sharedDoc.id }))).length === 1,122 "reader could not read shared row",123 );124 assert(125 (await editor.all(app.sharedDocs.where({ id: sharedDoc.id }))).length === 1,126 "editor could not read shared row",127 );128 assert(129 (await stranger.all(app.sharedDocs.where({ id: sharedDoc.id }))).length === 0,130 "stranger read shared row",131 );132 reader.expectDenied((db) => db.update(app.sharedDocs, sharedDoc.id, { title: "denied" }));133 await editor.update(app.sharedDocs, sharedDoc.id, { title: "allowed" }).wait({134 tier: "global",135 });136 assert(137 ownerRows[0]?.title === "allowed" && editorRows[0]?.title === "allowed",138 "authorized live queries did not observe the shared update",139 );140 stranger.expectDenied((db) => db.delete(app.sharedDocs, sharedDoc.id));141142 await owner.delete(app.sharedDocGrants, readGrant.id).wait({ tier: "global" });143 assert(readerRows.length === 0, "revocation did not remove the row from the active query");144 assert(145 (await reader.all(app.sharedDocs.where({ id: sharedDoc.id }))).length === 0,146 "revoked reader retained access",147 );148 assert(149 ownerRows.length === 1 && editorRows.length === 1,150 "revocation affected other identities",151 );152 stopOwner();153 stopReader();154 stopEditor();155 } finally {156 await testApp.shutdown();157 }158});159160Deno.test("official Jazz harness enforces fixed group roles and membership lifecycle", async () => {161 const testApp = await createPolicyTestApp(app, permissions, expectLike);162 try {163 const admin = testApp.as(session("admin"));164 const reader = testApp.as(session("reader"));165 const contributor = testApp.as(session("contributor"));166 const writer = testApp.as(session("writer"));167 const stranger = testApp.as(session("stranger"));168169 const group = await admin.insert(app.workspaces, {170 name: "workspace",171 }).wait({172 tier: "global",173 });174 assert(175 (await admin.all(app.workspaces.where({ id: group.id }))).length === 1,176 "group creator could not read the created group",177 );178 const initialAdmin = await admin.insert(app.workspaceMembers, {179 groupId: group.id,180 user_id: "admin",181 ...groupRoleCapabilities("admin"),182 }).wait({ tier: "global" });183 assert(initialAdmin.role === "admin", "creator did not bootstrap as first admin");184 const groupId = group.id;185 const memberships = await Promise.all([186 admin.insert(app.workspaceMembers, {187 groupId,188 user_id: "reader",189 ...groupRoleCapabilities("reader"),190 }).wait({191 tier: "global",192 }),193 admin.insert(app.workspaceMembers, {194 groupId,195 user_id: "contributor",196 ...groupRoleCapabilities("contributor"),197 })198 .wait(199 { tier: "global" },200 ),201 admin.insert(app.workspaceMembers, {202 groupId,203 user_id: "writer",204 ...groupRoleCapabilities("writer"),205 }).wait({206 tier: "global",207 }),208 ]);209 assert(210 (await admin.all(app.workspaceMembers.where({ groupId, user_id: "admin", role: "admin" })))211 .length === 1,212 "admin membership was not visible to the admin",213 );214 const adminDoc = await admin.insert(app.groupDocs, { workspaceId: groupId, title: "admin row" })215 .wait({216 tier: "global",217 });218 let adminRows: Array<s.RowOf<typeof app.groupDocs>> = [];219 let readerRows: Array<s.RowOf<typeof app.groupDocs>> = [];220 let writerRows: Array<s.RowOf<typeof app.groupDocs>> = [];221 const liveGroupDocs = app.groupDocs.where({ workspaceId: groupId });222 const stopAdmin = admin.subscribeAll(liveGroupDocs, (delta) => adminRows = delta.all);223 const stopReader = reader.subscribeAll(liveGroupDocs, (delta) => readerRows = delta.all);224 const stopWriter = writer.subscribeAll(liveGroupDocs, (delta) => writerRows = delta.all);225 const liveDoc = await contributor.insert(app.groupDocs, {226 workspaceId: groupId,227 title: "live insert",228 }).wait({ tier: "global" });229 assert(230 [adminRows, readerRows, writerRows].every((rows) =>231 rows.some((row) => row.id === liveDoc.id)232 ),233 "authorized live queries did not observe the group insert",234 );235 await writer.update(app.groupDocs, liveDoc.id, { title: "live update" }).wait({236 tier: "global",237 });238 assert(239 [adminRows, readerRows, writerRows].every((rows) =>240 rows.find((row) => row.id === liveDoc.id)?.title === "live update"241 ),242 "authorized live queries did not observe the group update",243 );244 await admin.delete(app.groupDocs, liveDoc.id).wait({ tier: "global" });245 assert(246 [adminRows, readerRows, writerRows].every((rows) =>247 rows.every((row) => row.id !== liveDoc.id)248 ),249 "authorized live queries did not observe the group delete",250 );251252 reader.expectDenied((db) =>253 db.insert(app.groupDocs, { workspaceId: groupId, title: "reader cannot create" })254 );255 contributor.expectAllowed((db) =>256 db.insert(app.groupDocs, { workspaceId: groupId, title: "contributor own" })257 );258 contributor.expectDenied((db) => db.update(app.groupDocs, adminDoc.id, { title: "not own" }));259 writer.expectAllowed((db) => db.update(app.groupDocs, adminDoc.id, { title: "writer" }));260 admin.expectAllowed((db) => db.update(app.groupDocs, adminDoc.id, { title: "admin" }));261 stranger.expectDenied((db) =>262 db.insert(app.groupDocs, { workspaceId: groupId, title: "non-member" })263 );264 assert(265 (await stranger.all(app.groupDocs.where({ workspaceId: groupId }))).length === 0,266 "non-member read group rows",267 );268269 admin.expectAllowed((db) =>270 db.update(app.workspaceMembers, memberships[0].id, groupRoleCapabilities("writer"))271 );272 reader.expectDenied((db) =>273 db.update(app.workspaceMembers, memberships[0].id, groupRoleCapabilities("admin"))274 );275 admin.expectAllowed((db) => db.delete(app.workspaceMembers, memberships[1].id));276 writer.expectAllowed((db) => db.delete(app.workspaceMembers, memberships[2].id));277 stranger.expectDenied((db) => db.delete(app.workspaceMembers, memberships[2].id));278 stopAdmin();279 stopReader();280 stopWriter();281 } finally {282 await testApp.shutdown();283 }284});285286Deno.test("group-creator authority is permanent, exclusive, and survives demotion", async () => {287 const testApp = await createPolicyTestApp(app, permissions, expectLike);288 try {289 const creator = testApp.as(session("creator"));290 const successor = testApp.as(session("successor"));291 const stranger = testApp.as(session("stranger"));292293 const group = await creator.insert(app.workspaces, { name: "handover" }).wait({294 tier: "global",295 });296 // Creator authority never extends to anyone else.297 stranger.expectDenied((db) => db.update(app.workspaces, group.id, { name: "hijacked" }));298 stranger.expectDenied((db) =>299 db.insert(app.workspaceMembers, {300 groupId: group.id,301 user_id: "stranger",302 ...groupRoleCapabilities("admin"),303 })304 );305 stranger.expectDenied((db) => db.delete(app.workspaces, group.id));306307 const creatorMembership = await creator.insert(app.workspaceMembers, {308 groupId: group.id,309 user_id: "creator",310 ...groupRoleCapabilities("admin"),311 }).wait({ tier: "global" });312 await creator.insert(app.workspaceMembers, {313 groupId: group.id,314 user_id: "successor",315 ...groupRoleCapabilities("admin"),316 }).wait({ tier: "global" });317318 // The documented trust property of the template on the pinned Jazz319 // alpha.53 engine: demoting or removing the creator does NOT end their320 // authority — they retain group update and, through it, membership321 // management, so they can always restore their own admin membership.322 // The engine cannot express "creator authority only while no admin323 // exists" (see the negation canary below); when that becomes possible,324 // this contract is expected to change to a bootstrap-only window.325 successor.expectAllowed((db) =>326 db.update(app.workspaceMembers, creatorMembership.id, groupRoleCapabilities("reader"))327 );328 creator.expectAllowed((db) =>329 db.update(app.workspaceMembers, creatorMembership.id, groupRoleCapabilities("admin"))330 );331 successor.expectAllowed((db) => db.delete(app.workspaceMembers, creatorMembership.id));332 creator.expectAllowed((db) =>333 db.insert(app.workspaceMembers, {334 groupId: group.id,335 user_id: "creator",336 ...groupRoleCapabilities("admin"),337 })338 );339 } finally {340 await testApp.shutdown();341 }342});343344Deno.test("engine canary: pinned alpha.53 silently drops Not around existence conditions", async () => {345 // Guard rule that SHOULD deny deletion while an admin membership exists:346 // Not(ExistsRel(members WHERE groupId = <this group> AND can_manage)).347 // The pinned engine ignores the negation, so the delete is ALLOWED below.348 // WHEN THIS TEST FAILS after a jazz-tools upgrade: negation started349 // working — replace permanent group-creator authority with the350 // bootstrap-only window design in351 // docs/decisions/group-creator-authority-alpha53.md and update the352 // permanent-authority contract test above.353 const canaryPermissions = s.definePermissions(app, (rawContext) => {354 const { policy } = rawContext as unknown as {355 policy: Record<356 string,357 {358 allowRead: { always(): unknown };359 allowInsert: { always(): unknown };360 allowDelete: { where(input: unknown): unknown };361 }362 >;363 };364 policy.workspaces.allowRead.always();365 policy.workspaces.allowInsert.always();366 policy.workspaceMembers.allowRead.always();367 policy.workspaceMembers.allowInsert.always();368 policy.workspaces.allowDelete.where({369 type: "Not",370 expr: {371 type: "ExistsRel",372 rel: {373 Filter: {374 input: { TableScan: { table: "workspaceMembers" } },375 predicate: {376 And: [377 {378 Cmp: {379 left: { column: "groupId" },380 op: "Eq",381 right: { OuterColumn: { column: "id" } },382 },383 },384 {385 Cmp: {386 left: { column: "can_manage" },387 op: "Eq",388 right: { Literal: true },389 },390 },391 ],392 },393 },394 },395 },396 });397 });398 const testApp = await createPolicyTestApp(app, canaryPermissions, expectLike);399 try {400 const creator = testApp.as(session("creator"));401 const group = await creator.insert(app.workspaces, { name: "canary" }).wait({402 tier: "global",403 });404 await creator.insert(app.workspaceMembers, {405 groupId: group.id,406 user_id: "creator",407 ...groupRoleCapabilities("admin"),408 }).wait({ tier: "global" });409 // A working Not would DENY this delete (an admin membership exists).410 creator.expectAllowed((db) => db.delete(app.workspaces, group.id));411 } finally {412 await testApp.shutdown();413 }414});415416Deno.test("a group without its first admin remains deletable by its creator only", async () => {417 const testApp = await createPolicyTestApp(app, permissions, expectLike);418 try {419 const creator = testApp.as(session("creator"));420 const stranger = testApp.as(session("stranger"));421 // A group whose first-admin insert failed: the row exists, no membership.422 const orphan = await creator.insert(app.workspaces, { name: "orphan" }).wait({423 tier: "global",424 });425 stranger.expectDenied((db) => db.delete(app.workspaces, orphan.id));426 await creator.delete(app.workspaces, orphan.id).wait({ tier: "global" });427 assert(428 (await creator.all(app.workspaces.where({ id: orphan.id }))).length === 0,429 "the creator's rollback delete did not remove the orphaned group",430 );431 } finally {432 await testApp.shutdown();433 }434});435436Deno.test("access templates reject malformed relationship tables with actionable errors", () => {437 const malformed = s.defineApp({438 docs: s.table({ title: s.string() }),439 grants: s.table({ resourceId: s.string(), user_id: s.string(), can_edit: s.boolean() }),440 });441 let message = "";442 try {443 defineAccessPolicies(malformed, [444 sharedAccess({ resource: malformed.docs, grants: malformed.grants }),445 ]);446 } catch (error) {447 message = error instanceof Error ? error.message : String(error);448 }449 assert(450 message.includes("grants") && message.includes("resourceId"),451 "configuration error was not actionable",452 );453});454455Deno.test("policies referencing encrypted columns fail configuration", () => {456 clearEncryptedColumnRegistry();457 try {458 // Template-consumed column: user_id sealed in a grant table. requireColumn459 // must refuse before the type check ever compares the built schema, where460 // an encrypted column is indistinguishable TEXT.461 const sealedGrantApp = lofiSchema.defineApp({462 sealedDocs: lofiSchema.table({ title: lofiSchema.string() }),463 sealedDocGrants: lofiSchema.table({464 resourceId: lofiSchema.ref("sealedDocs"),465 user_id: lofiSchema.encryptedText("sealedDocGrants.user_id"),466 can_edit: lofiSchema.boolean(),467 }),468 });469 let templateMessage = "";470 try {471 defineAccessPolicies(sealedGrantApp, [472 sharedAccess({473 resource: sealedGrantApp.sealedDocs,474 grants: sealedGrantApp.sealedDocGrants,475 }),476 ]);477 } catch (error) {478 templateMessage = error instanceof Error ? error.message : String(error);479 }480 assert(481 templateMessage.includes("encrypted") && templateMessage.includes("user_id"),482 `template with encrypted user_id compiled: ${templateMessage || "no error"}`,483 );484485 // Raw-extension rules: an object condition keyed by an encrypted column486 // must refuse — such a policy would compile but silently never match.487 const rawApp = lofiSchema.defineApp({488 vaultDocs: lofiSchema.table({489 title: lofiSchema.string(),490 secret: lofiSchema.encryptedText("vaultDocs.secret"),491 }),492 });493 let objectMessage = "";494 try {495 defineAccessPolicies(rawApp, [privateAccess({ resource: rawApp.vaultDocs })], (context) => {496 context.policy.vaultDocs.allowRead.where({ secret: "visible" });497 });498 } catch (error) {499 objectMessage = error instanceof Error ? error.message : String(error);500 }501 assert(502 objectMessage.includes("encrypted") && objectMessage.includes("secret"),503 `object condition on encrypted column compiled: ${objectMessage || "no error"}`,504 );505506 // A function condition whose returned literal is keyed by an encrypted507 // column is checked the same way.508 let functionMessage = "";509 try {510 defineAccessPolicies(rawApp, [privateAccess({ resource: rawApp.vaultDocs })], (context) => {511 context.policy.vaultDocs.allowUpdate.where(() => ({ secret: "visible" }));512 });513 } catch (error) {514 functionMessage = error instanceof Error ? error.message : String(error);515 }516 assert(517 functionMessage.includes("encrypted") && functionMessage.includes("secret"),518 `function condition on encrypted column compiled: ${functionMessage || "no error"}`,519 );520 } finally {521 clearEncryptedColumnRegistry();522 }523});