@filelayer/core 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +338 -0
  2. package/LICENSE +202 -0
  3. package/MIGRATIONS.md +328 -0
  4. package/NOTICE +37 -0
  5. package/README.md +343 -0
  6. package/SEMANTICS.md +729 -0
  7. package/dist/authz.d.ts +524 -0
  8. package/dist/authz.d.ts.map +1 -0
  9. package/dist/authz.js +889 -0
  10. package/dist/authz.js.map +1 -0
  11. package/dist/db.d.ts +145 -0
  12. package/dist/db.d.ts.map +1 -0
  13. package/dist/db.js +217 -0
  14. package/dist/db.js.map +1 -0
  15. package/dist/delivery.d.ts +293 -0
  16. package/dist/delivery.d.ts.map +1 -0
  17. package/dist/delivery.js +519 -0
  18. package/dist/delivery.js.map +1 -0
  19. package/dist/errors.d.ts +16 -0
  20. package/dist/errors.d.ts.map +1 -0
  21. package/dist/errors.js +21 -0
  22. package/dist/errors.js.map +1 -0
  23. package/dist/filelayer.d.ts +542 -0
  24. package/dist/filelayer.d.ts.map +1 -0
  25. package/dist/filelayer.js +1360 -0
  26. package/dist/filelayer.js.map +1 -0
  27. package/dist/index.d.ts +8 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +8 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/simple.d.ts +297 -0
  32. package/dist/simple.d.ts.map +1 -0
  33. package/dist/simple.js +492 -0
  34. package/dist/simple.js.map +1 -0
  35. package/dist/storage.d.ts +269 -0
  36. package/dist/storage.d.ts.map +1 -0
  37. package/dist/storage.js +700 -0
  38. package/dist/storage.js.map +1 -0
  39. package/dist/store.d.ts +432 -0
  40. package/dist/store.d.ts.map +1 -0
  41. package/dist/store.js +862 -0
  42. package/dist/store.js.map +1 -0
  43. package/package.json +77 -0
  44. package/schema.sql +1190 -0
  45. package/src/authz.ts +1398 -0
  46. package/src/db.ts +271 -0
  47. package/src/delivery.ts +737 -0
  48. package/src/errors.ts +24 -0
  49. package/src/filelayer.ts +1836 -0
  50. package/src/index.ts +7 -0
  51. package/src/simple.ts +666 -0
  52. package/src/storage.ts +917 -0
  53. package/src/store.ts +1072 -0
  54. package/test/delivery.test.ts +0 -0
  55. package/test/group-subjects.test.ts +1072 -0
  56. package/test/helpers.ts +65 -0
  57. package/test/listing.test.ts +689 -0
  58. package/test/local-s3.d.mts +33 -0
  59. package/test/local-s3.mjs +400 -0
  60. package/test/persistence.test.ts +953 -0
  61. package/test/regression.test.ts +619 -0
  62. package/test/s3-live.test.ts +322 -0
  63. package/test/security.test.ts +1652 -0
  64. package/test/semantics.test.ts +888 -0
  65. package/test/storage.test.ts +437 -0
  66. package/test/tiers.test.ts +432 -0
  67. package/test/vault-example.test.ts +302 -0
  68. package/tsconfig.build.json +29 -0
  69. package/tsconfig.json +19 -0
@@ -0,0 +1,619 @@
1
+ /**
2
+ * REGRESSION SUITE FOR THE SECURITY REVIEW FINDINGS
3
+ *
4
+ * One suite per fixed finding. Every test in this file was run against the
5
+ * PRE-FIX tree and observed to fail there. A regression test that has never
6
+ * been seen to fail is a regression test you have no reason to believe.
7
+ *
8
+ * The world is built with raw SQL rather than through `Filelayer.addMember`, so
9
+ * that this file could be dropped into the pre-fix tree unchanged and still
10
+ * exercise the property rather than an API signature change. The one exception
11
+ * is the membership suite, whose whole subject IS the signature: `addMember`
12
+ * used to take no principal, and no test can express "this should have been
13
+ * authorized" against an API that has nowhere to put the caller.
14
+ */
15
+
16
+ import { describe, it } from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+ import { createTestDb, type Queryable } from '../src/db.ts';
19
+ import { Filelayer } from '../src/filelayer.ts';
20
+ import { MemoryStorage } from '../src/storage.ts';
21
+ import type { Capability, OrgRole, Principal } from '../src/authz.ts';
22
+ import { bytes, text, rejects } from './helpers.ts';
23
+
24
+ const P = (actorId: string | null, extra: Partial<Principal> = {}): Principal => ({
25
+ actorId,
26
+ ...extra,
27
+ });
28
+
29
+ interface World {
30
+ db: Queryable;
31
+ storage: MemoryStorage;
32
+ fl: Filelayer;
33
+ org: string;
34
+ owner: string;
35
+ file: { id: string; storageKey: string };
36
+ actor(label: string, role?: OrgRole | null): Promise<string>;
37
+ }
38
+
39
+ /**
40
+ * Membership is written directly so this file works against both trees.
41
+ * Everything under test is reached through the public API.
42
+ */
43
+ async function world(fileOpts: { visibility?: 'private' | 'org' } = {}): Promise<World> {
44
+ const { db } = await createTestDb();
45
+ const storage = new MemoryStorage();
46
+ const fl = new Filelayer(db, storage, { baseUrl: 'https://files.example.test' });
47
+
48
+ const org = (
49
+ await db.query<{ id: string }>(
50
+ `INSERT INTO org (external_id, name) VALUES ('acme','Acme') RETURNING id`,
51
+ )
52
+ ).rows[0]!.id;
53
+
54
+ let n = 0;
55
+ const actor = async (label: string, role: OrgRole | null = null): Promise<string> => {
56
+ const id = (
57
+ await db.query<{ id: string }>(
58
+ `INSERT INTO actor (external_id) VALUES ($1) RETURNING id`,
59
+ [`${label}-${n++}`],
60
+ )
61
+ ).rows[0]!.id;
62
+ if (role) {
63
+ await db.query(
64
+ `INSERT INTO membership (org_id, actor_id, role) VALUES ($1,$2,$3)
65
+ ON CONFLICT (org_id, actor_id) DO UPDATE SET role = EXCLUDED.role`,
66
+ [org, id, role],
67
+ );
68
+ }
69
+ return id;
70
+ };
71
+
72
+ const owner = await actor('owner', 'owner');
73
+ const file = await fl.upload({ actorId: owner }, org, {
74
+ name: 'confidential.pdf',
75
+ contentType: 'application/pdf',
76
+ body: bytes('SECRET'),
77
+ ...(fileOpts.visibility ? { visibility: fileOpts.visibility } : {}),
78
+ });
79
+
80
+ return { db, storage, fl, org, owner, file, actor };
81
+ }
82
+
83
+ // =============================================================================
84
+ // Delegated grants may not outlive their parent (P4, transitive)
85
+ // =============================================================================
86
+
87
+ describe('recursive grant liveness', () => {
88
+ it('a chain of five delegated grants dies the instant the ROOT is revoked', async () => {
89
+ // The depth is the point. A one-level test would pass against an
90
+ // implementation that special-cased "my parent", which is exactly the kind
91
+ // of fix that looks right and is not.
92
+ const w = await world();
93
+ const links: string[] = [];
94
+ const grants: string[] = [];
95
+
96
+ // The root: an actor grant carrying read + share.
97
+ let holder = await w.actor('d0');
98
+ let issuer: Principal = P(w.owner);
99
+ for (let depth = 0; depth < 5; depth++) {
100
+ const g = await w.fl.share(issuer, w.file.id, {
101
+ subject: { type: 'actor', actorId: holder },
102
+ capabilities: ['read', 'share'],
103
+ });
104
+ grants.push(g.grantId);
105
+ if (depth > 0) {
106
+ assert.equal(
107
+ g.parentGrantId,
108
+ grants[depth - 1],
109
+ `grant at depth ${depth} must record its parent`,
110
+ );
111
+ } else {
112
+ assert.equal(g.parentGrantId, null, 'the root has no parent');
113
+ }
114
+
115
+ // Each holder also mints a share LINK, so we can prove the URLs die too.
116
+ const link = await w.fl.share(P(holder), w.file.id, {
117
+ subject: { type: 'link' },
118
+ capabilities: ['read'],
119
+ });
120
+ links.push(link.secret!);
121
+
122
+ issuer = P(holder);
123
+ holder = await w.actor(`d${depth + 1}`);
124
+ }
125
+
126
+ assert.equal(grants.length, 5);
127
+ assert.equal(links.length, 5);
128
+
129
+ // Everything works before the revocation.
130
+ for (const secret of links) {
131
+ assert.equal(text((await w.fl.redeem(secret)).body), 'SECRET');
132
+ }
133
+ const liveBefore = await w.db.query(`SELECT count(*)::int c FROM live_grant`);
134
+ assert.equal(Number((liveBefore.rows[0] as { c: number }).c), 10);
135
+
136
+ // Revoke ONLY the root.
137
+ await w.fl.revoke(P(w.owner), grants[0]!);
138
+
139
+ // Every descendant, at every depth, is dead -- with no cascading write.
140
+ const { rows: stillRevoked } = await w.db.query<{ c: number }>(
141
+ `SELECT count(*)::int c FROM file_grant WHERE revoked_at IS NOT NULL`,
142
+ );
143
+ assert.equal(Number(stillRevoked[0]!.c), 1, 'exactly one row was written to');
144
+
145
+ const { rows: live } = await w.db.query<{ c: number }>(`SELECT count(*)::int c FROM live_grant`);
146
+ assert.equal(Number(live[0]!.c), 0, 'and yet nothing in the tree is live');
147
+
148
+ for (const secret of links) {
149
+ await rejects(() => w.fl.redeem(secret), 404);
150
+ }
151
+ });
152
+
153
+ it('revoking a MIDDLE link kills its subtree and spares its ancestors', async () => {
154
+ const w = await world();
155
+ const a = await w.actor('mid-a');
156
+ const b = await w.actor('mid-b');
157
+ const c = await w.actor('mid-c');
158
+
159
+ const gA = await w.fl.share(P(w.owner), w.file.id, {
160
+ subject: { type: 'actor', actorId: a },
161
+ capabilities: ['read', 'share'],
162
+ });
163
+ const gB = await w.fl.share(P(a), w.file.id, {
164
+ subject: { type: 'actor', actorId: b },
165
+ capabilities: ['read', 'share'],
166
+ });
167
+ const gC = await w.fl.share(P(b), w.file.id, {
168
+ subject: { type: 'actor', actorId: c },
169
+ capabilities: ['read'],
170
+ });
171
+
172
+ await w.fl.revoke(P(w.owner), gB.grantId);
173
+
174
+ assert.equal(text((await w.fl.read(P(a), w.file.id)).body), 'SECRET', 'the ancestor survives');
175
+ await rejects(() => w.fl.read(P(b), w.file.id), 404);
176
+ await rejects(() => w.fl.read(P(c), w.file.id), 404);
177
+
178
+ const summaries = await w.fl.listGrants(P(w.owner), w.file.id);
179
+ const byId = new Map(summaries.map((g) => [g.id, g]));
180
+ assert.equal(byId.get(gA.grantId)!.live, true);
181
+ assert.equal(byId.get(gB.grantId)!.live, false);
182
+ assert.equal(byId.get(gC.grantId)!.live, false);
183
+ assert.equal(byId.get(gC.grantId)!.revokedAt, null, 'the leaf was never revoked itself');
184
+ });
185
+
186
+ it('the two independent liveness formulations agree on every row', async () => {
187
+ // `grant_is_live` walks UP from one grant; `live_grant_recursive` walks
188
+ // DOWN from the live roots. They are written separately on purpose: one
189
+ // implementation agreeing with itself proves nothing.
190
+ const w = await world();
191
+ const a = await w.actor('agree-a');
192
+ const b = await w.actor('agree-b');
193
+ const gA = await w.fl.share(P(w.owner), w.file.id, {
194
+ subject: { type: 'actor', actorId: a },
195
+ capabilities: ['read', 'share'],
196
+ maxDownloads: 2,
197
+ });
198
+ const gB = await w.fl.share(P(a), w.file.id, {
199
+ subject: { type: 'actor', actorId: b },
200
+ capabilities: ['read', 'share'],
201
+ });
202
+ await w.fl.share(P(b), w.file.id, { subject: { type: 'link' }, capabilities: ['read'] });
203
+ await w.fl.share(P(w.owner), w.file.id, { subject: { type: 'anonymous' } });
204
+
205
+ const check = async (label: string) => {
206
+ const { rows } = await w.db.query<{ c: number }>(
207
+ `SELECT count(*)::int AS c FROM (
208
+ (SELECT id FROM live_grant EXCEPT SELECT id FROM live_grant_recursive)
209
+ UNION ALL
210
+ (SELECT id FROM live_grant_recursive EXCEPT SELECT id FROM live_grant)
211
+ ) d`,
212
+ );
213
+ assert.equal(Number(rows[0]!.c), 0, `formulations disagree ${label}`);
214
+ };
215
+
216
+ await check('with a healthy tree');
217
+ await w.fl.revoke(P(w.owner), gB.grantId);
218
+ await check('after revoking a middle node');
219
+ await w.db.query(`UPDATE file_grant SET expires_at = now() - interval '1s' WHERE id = $1`, [
220
+ gA.grantId,
221
+ ]);
222
+ await check('after expiring the root');
223
+ });
224
+
225
+ it('lineage is immutable, so a liveness cycle cannot be created', async () => {
226
+ const w = await world();
227
+ const a = await w.actor('cycle-a');
228
+ const g1 = await w.fl.share(P(w.owner), w.file.id, {
229
+ subject: { type: 'actor', actorId: a },
230
+ capabilities: ['read', 'share'],
231
+ });
232
+ const g2 = await w.fl.share(P(a), w.file.id, {
233
+ subject: { type: 'actor', actorId: a },
234
+ capabilities: ['read'],
235
+ });
236
+
237
+ // Re-parenting the root under its own child would make liveness circular.
238
+ await assert.rejects(
239
+ () =>
240
+ w.db.query(`UPDATE file_grant SET parent_grant_id = $1 WHERE id = $2`, [
241
+ g2.grantId,
242
+ g1.grantId,
243
+ ]),
244
+ /grant_lineage_immutable/,
245
+ );
246
+ // A grant cannot be its own parent either.
247
+ await assert.rejects(
248
+ () =>
249
+ w.db.query(`UPDATE file_grant SET parent_grant_id = id WHERE id = $1`, [g1.grantId]),
250
+ /grant_no_self_parent|grant_lineage_immutable/,
251
+ );
252
+ });
253
+
254
+ it('a delegated grant cannot point at a different file (structural)', async () => {
255
+ const w = await world();
256
+ const other = await w.fl.upload({ actorId: w.owner }, w.org, {
257
+ name: 'other.pdf',
258
+ contentType: 'application/pdf',
259
+ body: bytes('OTHER'),
260
+ });
261
+ const a = await w.actor('xfile');
262
+ const parent = await w.fl.share(P(w.owner), w.file.id, {
263
+ subject: { type: 'actor', actorId: a },
264
+ capabilities: ['read', 'share'],
265
+ });
266
+ await assert.rejects(
267
+ () =>
268
+ w.db.query(
269
+ `INSERT INTO file_grant (file_id, org_id, parent_grant_id, subject_type, subject_id, capabilities)
270
+ VALUES ($1,$2,$3,'actor',$4,ARRAY['read']::grant_capability[])`,
271
+ [other.id, w.org, parent.grantId, a],
272
+ ),
273
+ /violates foreign key constraint/i,
274
+ );
275
+ });
276
+ });
277
+
278
+ // =============================================================================
279
+ // Capability amplification
280
+ // =============================================================================
281
+
282
+ /**
283
+ * FOUND WHILE IMPLEMENTING RFC-001, and it predates it.
284
+ *
285
+ * `getActorGrants` had no ORDER BY. `resolveStanding` attributes an allow to
286
+ * the FIRST returned grant that carries the requested capability, and on the
287
+ * share path that grant becomes the child's `parent_grant_id` -- the ceiling
288
+ * the attenuation trigger measures against. So when a principal held more than
289
+ * one grant on a file, WHICH ONE became the parent was heap order: undefined,
290
+ * and observably dependent on row width, page packing and VACUUM. Adding two
291
+ * nullable columns to `file_grant` was enough to flip it, and a delegation that
292
+ * had always succeeded began failing with `grant_capability_amplification`.
293
+ *
294
+ * Fail-closed, so never a disclosure -- but a `share()` whose outcome depends
295
+ * on physical storage is not a semantics anyone can document.
296
+ */
297
+ describe('delegation picks its parent deterministically, not in heap order', () => {
298
+ it('a principal holding several grants delegates from the OLDEST, every time', async () => {
299
+ const w = await world();
300
+ const holder = await w.actor('multi-holder', 'viewer');
301
+
302
+ // Oldest first, and it is the BROAD one. The narrow `{share}` grant that
303
+ // arrives later must not become the ceiling.
304
+ const broad = await w.fl.share(P(w.owner), w.file.id, {
305
+ subject: { type: 'actor', actorId: holder },
306
+ capabilities: ['read', 'share'],
307
+ });
308
+ await w.fl.share(P(w.owner), w.file.id, {
309
+ subject: { type: 'actor', actorId: holder },
310
+ capabilities: ['share'],
311
+ });
312
+
313
+ // The store must hand them back in creation order regardless of layout.
314
+ const seen = await w.fl.store.getActorGrants(w.file.id, holder);
315
+ assert.deepEqual(
316
+ seen.map((g) => g.capabilities.slice().sort().join('+')),
317
+ ['read+share', 'share'],
318
+ 'getActorGrants returned an undefined order',
319
+ );
320
+
321
+ // ...so delegating the full held set succeeds, and is parented to `broad`.
322
+ const target = await w.actor('multi-target');
323
+ const child = await w.fl.share(P(holder), w.file.id, {
324
+ subject: { type: 'actor', actorId: target },
325
+ capabilities: ['read', 'share'],
326
+ });
327
+ assert.equal(child.parentGrantId, broad.grantId);
328
+
329
+ // Repeat: the answer must not drift as more rows land on the page.
330
+ for (let i = 0; i < 3; i++) {
331
+ const again = await w.fl.share(P(holder), w.file.id, {
332
+ subject: { type: 'actor', actorId: target },
333
+ capabilities: ['read'],
334
+ });
335
+ assert.equal(again.parentGrantId, broad.grantId, `iteration ${i}`);
336
+ }
337
+ });
338
+ });
339
+
340
+ describe('attenuation lives in the engine and the schema, not in the API', () => {
341
+ it('every proper superset of the held capabilities is refused', async () => {
342
+ const w = await world();
343
+ const holder = await w.actor('att-holder', 'viewer');
344
+ await w.fl.share(P(w.owner), w.file.id, {
345
+ subject: { type: 'actor', actorId: holder },
346
+ capabilities: ['read', 'share'],
347
+ });
348
+
349
+ const forbidden: Capability[][] = [
350
+ ['write'],
351
+ ['delete'],
352
+ ['read', 'write'],
353
+ ['read', 'delete'],
354
+ ['share', 'delete'],
355
+ ['read', 'write', 'delete', 'share'],
356
+ ];
357
+ for (const caps of forbidden) {
358
+ await rejects(
359
+ () =>
360
+ w.fl.share(P(holder), w.file.id, {
361
+ subject: { type: 'actor', actorId: holder },
362
+ capabilities: caps,
363
+ }),
364
+ 403,
365
+ 'forbidden',
366
+ );
367
+ }
368
+
369
+ // Positive control: subsets of what they hold go through.
370
+ for (const caps of [['read'], ['share'], ['read', 'share']] as Capability[][]) {
371
+ const g = await w.fl.share(P(holder), w.file.id, {
372
+ subject: { type: 'actor', actorId: holder },
373
+ capabilities: caps,
374
+ });
375
+ assert.ok(g.grantId);
376
+ }
377
+
378
+ // And the escalation they were reaching for still does not exist.
379
+ await rejects(() => w.fl.delete(P(holder), w.file.id), 404);
380
+ assert.equal(text((await w.fl.read(P(w.owner), w.file.id)).body), 'SECRET');
381
+ });
382
+
383
+ it('the refusal is audited, naming what was asked for and what was held', async () => {
384
+ const w = await world();
385
+ const holder = await w.actor('att-audit', 'viewer');
386
+ await w.fl.share(P(w.owner), w.file.id, {
387
+ subject: { type: 'actor', actorId: holder },
388
+ capabilities: ['share'],
389
+ });
390
+ await rejects(
391
+ () =>
392
+ w.fl.share(P(holder), w.file.id, {
393
+ subject: { type: 'actor', actorId: holder },
394
+ capabilities: ['delete'],
395
+ }),
396
+ 403,
397
+ );
398
+ const log = await w.fl.store.listAudit(w.org, { decision: 'deny' });
399
+ const e = log.find((x) => x.reason === 'attenuation_violation');
400
+ assert.ok(e, 'the attempt must be on the record');
401
+ assert.deepEqual(e.context['requested'], ['delete']);
402
+ assert.deepEqual(e.context['held'], ['share']);
403
+ });
404
+ });
405
+
406
+ // =============================================================================
407
+ // File-level default deny
408
+ // =============================================================================
409
+
410
+ describe('files are private by default', () => {
411
+ it('the DATABASE default is the restrictive one, not just the API default', async () => {
412
+ const w = await world();
413
+ const { rows } = await w.db.query<{ visibility: string }>(
414
+ `INSERT INTO file (org_id, owner_id, name, content_type, storage_key, state)
415
+ VALUES ($1,$2,'raw.pdf','application/pdf','raw-key','ready')
416
+ RETURNING visibility`,
417
+ [w.org, w.owner],
418
+ );
419
+ assert.equal(rows[0]!.visibility, 'private');
420
+ });
421
+
422
+ it('org visibility is per file, so one shared document does not open the rest', async () => {
423
+ const w = await world();
424
+ const staff = await w.actor('f4-staff', 'member');
425
+ const shared = await w.fl.upload({ actorId: w.owner }, w.org, {
426
+ name: 'handbook.pdf',
427
+ contentType: 'application/pdf',
428
+ body: bytes('HANDBOOK'),
429
+ visibility: 'org',
430
+ });
431
+ assert.equal(text((await w.fl.read(P(staff), shared.id)).body), 'HANDBOOK');
432
+ await rejects(() => w.fl.read(P(staff), w.file.id), 404);
433
+ });
434
+ });
435
+
436
+ // =============================================================================
437
+ // The download reservation must fail closed
438
+ // =============================================================================
439
+
440
+ describe('consume_download fails closed', () => {
441
+ it('a refusal is an explicit (false, 0) row, not the absence of a row', async () => {
442
+ const w = await world();
443
+ const link = await w.fl.share(P(w.owner), w.file.id, {
444
+ subject: { type: 'link' },
445
+ maxDownloads: 1,
446
+ });
447
+ await w.fl.redeem(link.secret!);
448
+
449
+ const { rows } = await w.db.query<{ granted: boolean; remaining: number | null }>(
450
+ `SELECT granted, remaining FROM consume_download($1)`,
451
+ [link.grantId],
452
+ );
453
+ assert.equal(rows.length, 1, 'exactly one row, so `rows[0]?.granted ?? true` cannot fail open');
454
+ assert.equal(rows[0]!.granted, false);
455
+
456
+ // The same for a grant id that does not exist at all.
457
+ const ghost = await w.db.query<{ granted: boolean }>(
458
+ `SELECT granted FROM consume_download($1)`,
459
+ ['00000000-0000-0000-0000-0000000000ff'],
460
+ );
461
+ assert.equal(ghost.rows.length, 1);
462
+ assert.equal(ghost.rows[0]!.granted, false);
463
+
464
+ // And the store layer refuses anything that is not literally true.
465
+ assert.deepEqual(await w.fl.store.consumeDownload(link.grantId), {
466
+ granted: false,
467
+ remaining: 0,
468
+ });
469
+ assert.deepEqual(await w.fl.store.consumeDownload('not-a-uuid'), {
470
+ granted: false,
471
+ remaining: 0,
472
+ });
473
+ });
474
+ });
475
+
476
+ // =============================================================================
477
+ // Membership management
478
+ // =============================================================================
479
+ // This is the one suite that cannot be expressed against the pre-fix API at
480
+ // all: `addMember(orgId, actorId, role)` has nowhere to put the caller.
481
+
482
+ describe('membership changes are authorization decisions', () => {
483
+ it('the org-capability table is total and enumerated', async () => {
484
+ // Hand-written from the documented model, like the file matrix. 4 roles x
485
+ // 3 org capabilities = 12 cells.
486
+ const EXPECTED: Record<string, boolean> = {
487
+ 'viewer|create_file': false,
488
+ 'viewer|manage_members': false,
489
+ 'viewer|read_audit': false,
490
+ 'member|create_file': true,
491
+ 'member|manage_members': false,
492
+ 'member|read_audit': false,
493
+ 'admin|create_file': true,
494
+ 'admin|manage_members': true,
495
+ 'admin|read_audit': true,
496
+ 'owner|create_file': true,
497
+ 'owner|manage_members': true,
498
+ 'owner|read_audit': true,
499
+ };
500
+ const { orgCapabilities } = await import('../src/authz.ts');
501
+ let checked = 0;
502
+ for (const role of ['viewer', 'member', 'admin', 'owner'] as OrgRole[]) {
503
+ for (const cap of ['create_file', 'manage_members', 'read_audit'] as const) {
504
+ const key = `${role}|${cap}`;
505
+ assert.notEqual(EXPECTED[key], undefined, `missing cell ${key}`);
506
+ assert.equal(orgCapabilities(role).has(cap), EXPECTED[key], `mismatch at ${key}`);
507
+ checked++;
508
+ }
509
+ }
510
+ assert.equal(checked, 12);
511
+ assert.equal(Object.keys(EXPECTED).length, 12);
512
+ });
513
+
514
+ it('every membership change emits exactly one audit event', async () => {
515
+ const w = await world();
516
+ const target = await w.actor('m-target');
517
+ const before = (
518
+ await w.db.query<{ c: number }>(
519
+ `SELECT count(*)::int c FROM audit_event WHERE action LIKE 'member%'`,
520
+ )
521
+ ).rows[0]!.c;
522
+
523
+ await w.fl.addMember(P(w.owner), w.org, target, 'member');
524
+ await w.fl.addMember(P(w.owner), w.org, target, 'admin');
525
+ await w.fl.removeMember(P(w.owner), w.org, target);
526
+ await rejects(() => w.fl.addMember(P(target), w.org, target, 'owner'), 404);
527
+
528
+ const { rows } = await w.db.query<{ action: string; decision: string }>(
529
+ `SELECT action, decision FROM audit_event WHERE action LIKE 'member%' ORDER BY id`,
530
+ );
531
+ assert.equal(rows.length - Number(before), 4, 'four attempts, four events');
532
+ const tail = rows.slice(rows.length - 4);
533
+ assert.deepEqual(
534
+ tail.map((r) => `${r.action}:${r.decision}`),
535
+ ['member.add:allow', 'member.role_change:allow', 'member.remove:allow', 'member.add:deny'],
536
+ );
537
+ });
538
+
539
+ it('the self-promotion attack is closed and leaves a trace', async () => {
540
+ const w = await world();
541
+ const outsider = await w.actor('outsider');
542
+ await rejects(() => w.fl.addMember(P(outsider), w.org, outsider, 'owner'), 404);
543
+ await rejects(() => w.fl.read(P(outsider), w.file.id), 404);
544
+ const { rows } = await w.db.query<{ c: number }>(
545
+ `SELECT count(*)::int c FROM audit_event
546
+ WHERE action = 'member.add' AND decision = 'deny' AND actor_id = $1`,
547
+ [outsider],
548
+ );
549
+ assert.equal(Number(rows[0]!.c), 1);
550
+ });
551
+
552
+ it('membership grants nothing retroactively: the audit log is still admin-only', async () => {
553
+ const w = await world();
554
+ const member = await w.actor('m-reader', 'member');
555
+ await rejects(() => w.fl.auditLog(P(member), w.org, {}), 404);
556
+ await rejects(() => w.fl.verifyAuditChain(P(member), w.org), 404);
557
+ assert.ok((await w.fl.auditLog(P(w.owner), w.org, {})).length > 0);
558
+ });
559
+ });
560
+
561
+ // =============================================================================
562
+ // Revoke authority follows the delegation tree
563
+ // =============================================================================
564
+ // Found while building the recursive-liveness fix, not carried over from the
565
+ // review.
566
+
567
+ describe('holding `share` does not confer revoke over other people’s grants', () => {
568
+ it('a delegated share-holder cannot revoke a grant outside their own subtree', async () => {
569
+ const w = await world();
570
+ const contractor = await w.actor('rev-contractor');
571
+ const parent = await w.fl.share(P(w.owner), w.file.id, {
572
+ subject: { type: 'actor', actorId: contractor },
573
+ capabilities: ['read', 'share'],
574
+ });
575
+ // An unrelated link, issued by the owner, that the contractor has nothing
576
+ // to do with.
577
+ const partner = await w.fl.share(P(w.owner), w.file.id, { subject: { type: 'link' } });
578
+
579
+ await rejects(() => w.fl.revoke(P(contractor), partner.grantId), 404);
580
+ assert.equal(text((await w.fl.redeem(partner.secret!)).body), 'SECRET', 'still works');
581
+
582
+ // What they CAN revoke is their own subtree: the link they issued...
583
+ const own = await w.fl.share(P(contractor), w.file.id, { subject: { type: 'link' } });
584
+ await w.fl.revoke(P(contractor), own.grantId);
585
+ await rejects(() => w.fl.redeem(own.secret!), 404);
586
+ // ...and the authority they were given in the first place.
587
+ await w.fl.revoke(P(contractor), parent.grantId);
588
+ await rejects(() => w.fl.read(P(contractor), w.file.id), 404);
589
+
590
+ // The refusal is on the record.
591
+ const log = await w.fl.store.listAudit(w.org, { decision: 'deny' });
592
+ assert.ok(log.some((e) => e.reason === 'foreign_grant' && e.grantId === partner.grantId));
593
+ });
594
+
595
+ it('role-derived authority still carries revoke over every grant on the file', async () => {
596
+ const w = await world();
597
+ const admin = await w.actor('rev-admin', 'admin');
598
+ const link = await w.fl.share(P(w.owner), w.file.id, { subject: { type: 'link' } });
599
+ await w.fl.revoke(P(admin), link.grantId);
600
+ await rejects(() => w.fl.redeem(link.secret!), 404);
601
+ });
602
+ });
603
+
604
+ // =============================================================================
605
+ // No dead code in the decision path
606
+ // =============================================================================
607
+
608
+ describe('the file-state gate has no vestigial branch', () => {
609
+ it('authz.ts contains no capabilityRequiresLiveFile', async () => {
610
+ // The old gate read `if (deleted || capabilityRequiresLiveFile(cap)) { if
611
+ // (deleted) {...} }`, where the function unconditionally returned true. It
612
+ // read like an intended-but-unimplemented distinction in the most
613
+ // security-critical function in the codebase, which is the worst possible
614
+ // place for one.
615
+ const { readFile } = await import('node:fs/promises');
616
+ const src = await readFile(new URL('../src/authz.ts', import.meta.url), 'utf8');
617
+ assert.equal(src.includes('capabilityRequiresLiveFile'), false);
618
+ });
619
+ });