@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.
- package/CHANGELOG.md +338 -0
- package/LICENSE +202 -0
- package/MIGRATIONS.md +328 -0
- package/NOTICE +37 -0
- package/README.md +343 -0
- package/SEMANTICS.md +729 -0
- package/dist/authz.d.ts +524 -0
- package/dist/authz.d.ts.map +1 -0
- package/dist/authz.js +889 -0
- package/dist/authz.js.map +1 -0
- package/dist/db.d.ts +145 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +217 -0
- package/dist/db.js.map +1 -0
- package/dist/delivery.d.ts +293 -0
- package/dist/delivery.d.ts.map +1 -0
- package/dist/delivery.js +519 -0
- package/dist/delivery.js.map +1 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +21 -0
- package/dist/errors.js.map +1 -0
- package/dist/filelayer.d.ts +542 -0
- package/dist/filelayer.d.ts.map +1 -0
- package/dist/filelayer.js +1360 -0
- package/dist/filelayer.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/simple.d.ts +297 -0
- package/dist/simple.d.ts.map +1 -0
- package/dist/simple.js +492 -0
- package/dist/simple.js.map +1 -0
- package/dist/storage.d.ts +269 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +700 -0
- package/dist/storage.js.map +1 -0
- package/dist/store.d.ts +432 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +862 -0
- package/dist/store.js.map +1 -0
- package/package.json +77 -0
- package/schema.sql +1190 -0
- package/src/authz.ts +1398 -0
- package/src/db.ts +271 -0
- package/src/delivery.ts +737 -0
- package/src/errors.ts +24 -0
- package/src/filelayer.ts +1836 -0
- package/src/index.ts +7 -0
- package/src/simple.ts +666 -0
- package/src/storage.ts +917 -0
- package/src/store.ts +1072 -0
- package/test/delivery.test.ts +0 -0
- package/test/group-subjects.test.ts +1072 -0
- package/test/helpers.ts +65 -0
- package/test/listing.test.ts +689 -0
- package/test/local-s3.d.mts +33 -0
- package/test/local-s3.mjs +400 -0
- package/test/persistence.test.ts +953 -0
- package/test/regression.test.ts +619 -0
- package/test/s3-live.test.ts +322 -0
- package/test/security.test.ts +1652 -0
- package/test/semantics.test.ts +888 -0
- package/test/storage.test.ts +437 -0
- package/test/tiers.test.ts +432 -0
- package/test/vault-example.test.ts +302 -0
- package/tsconfig.build.json +29 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,1652 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SECURITY PROPERTY SUITE
|
|
3
|
+
*
|
|
4
|
+
* Every test in this file is named for a property Filelayer sells. If a test
|
|
5
|
+
* here goes red, the corresponding claim comes off the website.
|
|
6
|
+
*
|
|
7
|
+
* Rules this file holds itself to:
|
|
8
|
+
* - No test asserts on an internal implementation detail where the observable
|
|
9
|
+
* security behaviour is what matters.
|
|
10
|
+
* - Expected outcomes are written out by hand, never derived from the code
|
|
11
|
+
* under test (a table generated from `roleCapabilities` would prove only
|
|
12
|
+
* that the function equals itself).
|
|
13
|
+
* - Where a property cannot be fully proven in this environment, the test
|
|
14
|
+
* says so in its name and the limitation is stated in the comment.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it } from 'node:test';
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import { authorize, type Capability, type OrgRole, type Principal } from '../src/authz.ts';
|
|
20
|
+
import { auditHash } from '../src/store.ts';
|
|
21
|
+
import { newWorld, bytes, text, rejects, dbRejects, countAudit } from './helpers.ts';
|
|
22
|
+
|
|
23
|
+
// -----------------------------------------------------------------------------
|
|
24
|
+
// Scenario builder: two orgs that must never see each other.
|
|
25
|
+
// -----------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
async function twoOrgs() {
|
|
28
|
+
const w = await newWorld();
|
|
29
|
+
|
|
30
|
+
const alice = (await w.fl.createActor('alice')).id; // owner of org A
|
|
31
|
+
const anna = (await w.fl.createActor('anna')).id; // member of org A
|
|
32
|
+
const bob = (await w.fl.createActor('bob')).id; // owner of org B
|
|
33
|
+
const mallory = (await w.fl.createActor('mallory')).id; // member of no org
|
|
34
|
+
|
|
35
|
+
// An org is created together with its first owner: there is no moment at
|
|
36
|
+
// which an org exists with nobody accountable for it, and therefore no
|
|
37
|
+
// "empty org" bootstrap path for an attacker to walk through.
|
|
38
|
+
const orgA = (await w.fl.createOrg('acme', 'Acme', { ownerActorId: alice })).id;
|
|
39
|
+
const orgB = (await w.fl.createOrg('initech', 'Initech', { ownerActorId: bob })).id;
|
|
40
|
+
|
|
41
|
+
await w.fl.addMember({ actorId: alice }, orgA, anna, 'member');
|
|
42
|
+
|
|
43
|
+
const fileA = await w.fl.upload({ actorId: alice }, orgA, {
|
|
44
|
+
name: 'acme-plan.pdf',
|
|
45
|
+
contentType: 'application/pdf',
|
|
46
|
+
body: bytes('ACME CONFIDENTIAL'),
|
|
47
|
+
});
|
|
48
|
+
const fileB = await w.fl.upload({ actorId: bob }, orgB, {
|
|
49
|
+
name: 'initech-payroll.csv',
|
|
50
|
+
contentType: 'text/csv',
|
|
51
|
+
body: bytes('INITECH PAYROLL'),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return { ...w, orgA, orgB, alice, anna, bob, mallory, fileA, fileB };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const P = (actorId: string | null, extra: Partial<Principal> = {}): Principal => ({
|
|
58
|
+
actorId,
|
|
59
|
+
...extra,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// =============================================================================
|
|
63
|
+
// 1. CROSS-TENANT ISOLATION
|
|
64
|
+
// =============================================================================
|
|
65
|
+
|
|
66
|
+
describe('PROPERTY 1: cross-tenant isolation', () => {
|
|
67
|
+
it('an actor in org A cannot read, delete, share, or list grants on a file in org B', async () => {
|
|
68
|
+
const s = await twoOrgs();
|
|
69
|
+
|
|
70
|
+
await rejects(() => s.fl.read(P(s.alice), s.fileB.id), 404, 'not_found');
|
|
71
|
+
await rejects(() => s.fl.delete(P(s.alice), s.fileB.id), 404, 'not_found');
|
|
72
|
+
await rejects(
|
|
73
|
+
() => s.fl.share(P(s.alice), s.fileB.id, { subject: { type: 'link' } }),
|
|
74
|
+
404,
|
|
75
|
+
'not_found',
|
|
76
|
+
);
|
|
77
|
+
await rejects(() => s.fl.listGrants(P(s.alice), s.fileB.id), 404, 'not_found');
|
|
78
|
+
|
|
79
|
+
// ...and the reverse direction, so the test cannot pass by accident of
|
|
80
|
+
// org A simply having no files.
|
|
81
|
+
await rejects(() => s.fl.read(P(s.bob), s.fileA.id), 404, 'not_found');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('an actor cannot upload into an org they are not a member of', async () => {
|
|
85
|
+
const s = await twoOrgs();
|
|
86
|
+
await rejects(
|
|
87
|
+
() =>
|
|
88
|
+
s.fl.upload({ actorId: s.alice }, s.orgB, {
|
|
89
|
+
name: 'trojan.pdf',
|
|
90
|
+
contentType: 'application/pdf',
|
|
91
|
+
body: bytes('x'),
|
|
92
|
+
}),
|
|
93
|
+
404,
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('the DATABASE rejects a grant in org A that points at a file in org B (P3, structural)', async () => {
|
|
98
|
+
const s = await twoOrgs();
|
|
99
|
+
// This is the attack a WHERE clause would have to catch. Here it is not
|
|
100
|
+
// caught, it is unrepresentable: the composite FK (file_id, org_id) ->
|
|
101
|
+
// file(id, org_id) has no matching row.
|
|
102
|
+
const msg = await dbRejects(
|
|
103
|
+
s.db,
|
|
104
|
+
`INSERT INTO file_grant (file_id, org_id, subject_type, subject_id, capabilities)
|
|
105
|
+
VALUES ($1, $2, 'actor', $3, ARRAY['read']::grant_capability[])`,
|
|
106
|
+
[s.fileB.id, s.orgA, s.alice],
|
|
107
|
+
/foreign key|file_grant_file_id_org_id_fkey/i,
|
|
108
|
+
);
|
|
109
|
+
assert.match(msg, /violates foreign key constraint/i);
|
|
110
|
+
|
|
111
|
+
// Sanity: the same insert with the CORRECT org succeeds, so the rejection
|
|
112
|
+
// above is caused by the tenant mismatch and not by a malformed statement.
|
|
113
|
+
await s.db.query(
|
|
114
|
+
`INSERT INTO file_grant (file_id, org_id, subject_type, subject_id, capabilities)
|
|
115
|
+
VALUES ($1, $2, 'actor', $3, ARRAY['read']::grant_capability[])`,
|
|
116
|
+
[s.fileB.id, s.orgB, s.alice],
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('a link secret issued in org A does not authorize a file in org B', async () => {
|
|
121
|
+
const s = await twoOrgs();
|
|
122
|
+
const share = await s.fl.share(P(s.alice), s.fileA.id, { subject: { type: 'link' } });
|
|
123
|
+
|
|
124
|
+
// The secret works for its own file...
|
|
125
|
+
const ok = await s.fl.redeem(share.secret!);
|
|
126
|
+
assert.equal(text(ok.body), 'ACME CONFIDENTIAL');
|
|
127
|
+
|
|
128
|
+
// ...and is inert against org B's file, presented directly.
|
|
129
|
+
await rejects(
|
|
130
|
+
() => s.fl.read(P(null, { linkSecret: share.secret! }), s.fileB.id),
|
|
131
|
+
404,
|
|
132
|
+
'not_found',
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('a non-member of any org gets nothing', async () => {
|
|
137
|
+
const s = await twoOrgs();
|
|
138
|
+
await rejects(() => s.fl.read(P(s.mallory), s.fileA.id), 404);
|
|
139
|
+
await rejects(() => s.fl.read(P(s.mallory), s.fileB.id), 404);
|
|
140
|
+
await rejects(() => s.fl.read(P(null), s.fileA.id), 404);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// =============================================================================
|
|
145
|
+
// 2. REVOCATION BEATS A LIVE URL (P4)
|
|
146
|
+
// =============================================================================
|
|
147
|
+
|
|
148
|
+
describe('PROPERTY 2: revocation beats a live URL (P4)', () => {
|
|
149
|
+
it('the SAME share link stops working the instant the grant is revoked', async () => {
|
|
150
|
+
const s = await twoOrgs();
|
|
151
|
+
const share = await s.fl.share(P(s.alice), s.fileA.id, { subject: { type: 'link' } });
|
|
152
|
+
|
|
153
|
+
const before = await s.fl.redeem(share.secret!);
|
|
154
|
+
assert.equal(text(before.body), 'ACME CONFIDENTIAL');
|
|
155
|
+
|
|
156
|
+
await s.fl.revoke(P(s.alice), share.grantId);
|
|
157
|
+
|
|
158
|
+
// Same string, same file, no deletion, no key rotation, no waiting for a
|
|
159
|
+
// TTL. This is the case Convex cannot express and Cloudinary documents as
|
|
160
|
+
// a limitation.
|
|
161
|
+
await rejects(() => s.fl.redeem(share.secret!), 404, 'not_found');
|
|
162
|
+
|
|
163
|
+
// The file itself is untouched: revocation is not deletion.
|
|
164
|
+
const still = await s.fl.read(P(s.alice), s.fileA.id);
|
|
165
|
+
assert.equal(text(still.body), 'ACME CONFIDENTIAL');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('revoking an actor grant immediately removes that actor’s access', async () => {
|
|
169
|
+
const s = await twoOrgs();
|
|
170
|
+
// Cross-org sharing by explicit invitation: org B's owner grants Alice
|
|
171
|
+
// (an org A actor) read on an org B file.
|
|
172
|
+
const share = await s.fl.share(P(s.bob), s.fileB.id, {
|
|
173
|
+
subject: { type: 'actor', actorId: s.alice },
|
|
174
|
+
});
|
|
175
|
+
const got = await s.fl.read(P(s.alice), s.fileB.id);
|
|
176
|
+
assert.equal(text(got.body), 'INITECH PAYROLL');
|
|
177
|
+
|
|
178
|
+
await s.fl.revoke(P(s.bob), share.grantId);
|
|
179
|
+
await rejects(() => s.fl.read(P(s.alice), s.fileB.id), 404);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('a revoked grant is invisible to the live_grant view, not merely filtered by the caller', async () => {
|
|
183
|
+
const s = await twoOrgs();
|
|
184
|
+
const share = await s.fl.share(P(s.alice), s.fileA.id, { subject: { type: 'link' } });
|
|
185
|
+
await s.fl.revoke(P(s.alice), share.grantId);
|
|
186
|
+
|
|
187
|
+
const live = await s.db.query(`SELECT id FROM live_grant WHERE id = $1`, [share.grantId]);
|
|
188
|
+
assert.equal(live.rows.length, 0);
|
|
189
|
+
const raw = await s.db.query(`SELECT id FROM file_grant WHERE id = $1`, [share.grantId]);
|
|
190
|
+
assert.equal(raw.rows.length, 1, 'the row must still exist, for the audit trail');
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// =============================================================================
|
|
195
|
+
// 3. CONFUSED DEPUTY
|
|
196
|
+
// =============================================================================
|
|
197
|
+
|
|
198
|
+
describe('PROPERTY 3: a valid link secret for file A does not authorize file B', () => {
|
|
199
|
+
it('same org, two files, one secret: the secret is bound to its file', async () => {
|
|
200
|
+
const s = await twoOrgs();
|
|
201
|
+
const other = await s.fl.upload({ actorId: s.alice }, s.orgA, {
|
|
202
|
+
name: 'salaries.xlsx',
|
|
203
|
+
contentType: 'application/vnd.ms-excel',
|
|
204
|
+
body: bytes('ACME SALARIES'),
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const share = await s.fl.share(P(s.alice), s.fileA.id, { subject: { type: 'link' } });
|
|
208
|
+
|
|
209
|
+
// Works for the file it was minted for.
|
|
210
|
+
assert.equal(text((await s.fl.redeem(share.secret!)).body), 'ACME CONFIDENTIAL');
|
|
211
|
+
|
|
212
|
+
// Inert against a sibling file in the same tenant. Same org, same owner,
|
|
213
|
+
// same grant — only the file id differs.
|
|
214
|
+
await rejects(() => s.fl.read(P(null, { linkSecret: share.secret! }), other.id), 404);
|
|
215
|
+
|
|
216
|
+
const d = await authorize(s.fl.store, P(null, { linkSecret: share.secret! }), other.id, 'read');
|
|
217
|
+
assert.equal(d.allow, false);
|
|
218
|
+
assert.equal(d.allow === false && d.reason, 'bad_link_secret');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('a garbage secret authorizes nothing', async () => {
|
|
222
|
+
const s = await twoOrgs();
|
|
223
|
+
await rejects(() => s.fl.redeem('not-a-real-secret'), 404);
|
|
224
|
+
await rejects(
|
|
225
|
+
() => s.fl.read(P(null, { linkSecret: 'not-a-real-secret' }), s.fileA.id),
|
|
226
|
+
404,
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// =============================================================================
|
|
232
|
+
// 4. ATOMIC DOWNLOAD CAP (P6)
|
|
233
|
+
// =============================================================================
|
|
234
|
+
//
|
|
235
|
+
// HONEST LIMITATION, STATED UP FRONT:
|
|
236
|
+
// PGlite is a single Postgres backend in WASM with one connection. Statements
|
|
237
|
+
// from concurrent JS promises are interleaved by the driver queue but executed
|
|
238
|
+
// one at a time, so this suite exercises the INTERLEAVING form of the race
|
|
239
|
+
// (many readers observe the counter before any writer advances it) and not the
|
|
240
|
+
// LOCK-CONTENTION form (two backends updating the same row simultaneously,
|
|
241
|
+
// where correctness depends on Postgres re-evaluating the UPDATE's WHERE clause
|
|
242
|
+
// against the locked, updated row under READ COMMITTED).
|
|
243
|
+
//
|
|
244
|
+
// What that weakens: this suite proves `consume_download` is not vulnerable to
|
|
245
|
+
// check-then-act. It does not, by itself, prove the row-lock path. That path is
|
|
246
|
+
// standard Postgres behaviour for a single conditional UPDATE and is the reason
|
|
247
|
+
// the reservation is written as one statement, but the claim rests on Postgres
|
|
248
|
+
// semantics, not on this test.
|
|
249
|
+
//
|
|
250
|
+
// To show the harness is capable of FAILING (i.e. that it is not a test that
|
|
251
|
+
// passes for everyone), the last test in this block runs the naive
|
|
252
|
+
// read-then-write implementation through the identical harness and asserts that
|
|
253
|
+
// it over-issues. If the harness could not detect a real TOCTOU bug, the passing
|
|
254
|
+
// tests above it would be worthless.
|
|
255
|
+
|
|
256
|
+
describe('PROPERTY 4: download caps are atomic (P6)', () => {
|
|
257
|
+
it('max_downloads=1 with 20 concurrent redemptions yields exactly 1 success', async () => {
|
|
258
|
+
const s = await twoOrgs();
|
|
259
|
+
const share = await s.fl.share(P(s.alice), s.fileA.id, {
|
|
260
|
+
subject: { type: 'link' },
|
|
261
|
+
maxDownloads: 1,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const results = await Promise.allSettled(
|
|
265
|
+
Array.from({ length: 20 }, () => s.fl.redeem(share.secret!)),
|
|
266
|
+
);
|
|
267
|
+
const ok = results.filter((r) => r.status === 'fulfilled');
|
|
268
|
+
const failed = results.filter((r) => r.status === 'rejected');
|
|
269
|
+
|
|
270
|
+
assert.equal(ok.length, 1, `expected exactly 1 success, got ${ok.length}`);
|
|
271
|
+
assert.equal(failed.length, 19);
|
|
272
|
+
|
|
273
|
+
const { rows } = await s.db.query<{ download_count: number }>(
|
|
274
|
+
`SELECT download_count FROM file_grant WHERE id = $1`,
|
|
275
|
+
[share.grantId],
|
|
276
|
+
);
|
|
277
|
+
assert.equal(Number(rows[0]!.download_count), 1, 'the counter must not exceed the cap');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('max_downloads=5 with 50 concurrent redemptions yields exactly 5 successes', async () => {
|
|
281
|
+
const s = await twoOrgs();
|
|
282
|
+
const share = await s.fl.share(P(s.alice), s.fileA.id, {
|
|
283
|
+
subject: { type: 'link' },
|
|
284
|
+
maxDownloads: 5,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const results = await Promise.allSettled(
|
|
288
|
+
Array.from({ length: 50 }, () => s.fl.redeem(share.secret!)),
|
|
289
|
+
);
|
|
290
|
+
const ok = results.filter((r) => r.status === 'fulfilled');
|
|
291
|
+
assert.equal(ok.length, 5, `expected exactly 5 successes, got ${ok.length}`);
|
|
292
|
+
|
|
293
|
+
const { rows } = await s.db.query<{ download_count: number }>(
|
|
294
|
+
`SELECT download_count FROM file_grant WHERE id = $1`,
|
|
295
|
+
[share.grantId],
|
|
296
|
+
);
|
|
297
|
+
assert.equal(Number(rows[0]!.download_count), 5);
|
|
298
|
+
|
|
299
|
+
// Exhausted grants leave the live view, so the 6th attempt is a plain 404
|
|
300
|
+
// rather than an error the caller has to interpret.
|
|
301
|
+
await rejects(() => s.fl.redeem(share.secret!), 404);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('CONTROL: the same harness DOES catch a naive read-then-write counter (proves the test can fail)', async () => {
|
|
305
|
+
const s = await twoOrgs();
|
|
306
|
+
const share = await s.fl.share(P(s.alice), s.fileA.id, {
|
|
307
|
+
subject: { type: 'link' },
|
|
308
|
+
maxDownloads: 1,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// Remove the schema's backstop CHECK so we observe the application bug
|
|
312
|
+
// itself rather than the database catching it. (That the CHECK exists at
|
|
313
|
+
// all is a second line of defence and is asserted separately below.)
|
|
314
|
+
await s.db.query(`ALTER TABLE file_grant DROP CONSTRAINT grant_downloads_within_cap`);
|
|
315
|
+
|
|
316
|
+
const naiveConsume = async (grantId: string): Promise<boolean> => {
|
|
317
|
+
const { rows } = await s.db.query<{ download_count: number; max_downloads: number | null }>(
|
|
318
|
+
`SELECT download_count, max_downloads FROM file_grant WHERE id = $1`,
|
|
319
|
+
[grantId],
|
|
320
|
+
);
|
|
321
|
+
const r = rows[0]!;
|
|
322
|
+
if (r.max_downloads !== null && Number(r.download_count) >= Number(r.max_downloads)) {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
await s.db.query(
|
|
326
|
+
`UPDATE file_grant SET download_count = download_count + 1 WHERE id = $1`,
|
|
327
|
+
[grantId],
|
|
328
|
+
);
|
|
329
|
+
return true;
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const results = await Promise.all(
|
|
333
|
+
Array.from({ length: 20 }, () => naiveConsume(share.grantId)),
|
|
334
|
+
);
|
|
335
|
+
const granted = results.filter(Boolean).length;
|
|
336
|
+
assert.ok(
|
|
337
|
+
granted > 1,
|
|
338
|
+
`the naive implementation should over-issue under this harness; it granted ${granted}. ` +
|
|
339
|
+
`If this is 1, the harness is not interleaving and the atomicity tests above prove nothing.`,
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('the schema refuses to store a count above the cap even if application code tries', async () => {
|
|
344
|
+
const s = await twoOrgs();
|
|
345
|
+
const share = await s.fl.share(P(s.alice), s.fileA.id, {
|
|
346
|
+
subject: { type: 'link' },
|
|
347
|
+
maxDownloads: 1,
|
|
348
|
+
});
|
|
349
|
+
await dbRejects(
|
|
350
|
+
s.db,
|
|
351
|
+
`UPDATE file_grant SET download_count = 99 WHERE id = $1`,
|
|
352
|
+
[share.grantId],
|
|
353
|
+
/grant_downloads_within_cap/,
|
|
354
|
+
);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// =============================================================================
|
|
359
|
+
// 5. EXPIRY IS SERVER-SIDE
|
|
360
|
+
// =============================================================================
|
|
361
|
+
|
|
362
|
+
describe('PROPERTY 5: expiry is enforced server-side, not encoded in a token', () => {
|
|
363
|
+
it('an expired grant fails even with a perfectly valid, unmodified secret', async () => {
|
|
364
|
+
const s = await twoOrgs();
|
|
365
|
+
const share = await s.fl.share(P(s.alice), s.fileA.id, {
|
|
366
|
+
subject: { type: 'link' },
|
|
367
|
+
expiresIn: 3600,
|
|
368
|
+
});
|
|
369
|
+
assert.equal(text((await s.fl.redeem(share.secret!)).body), 'ACME CONFIDENTIAL');
|
|
370
|
+
|
|
371
|
+
// Move the expiry into the past. The secret is untouched and still valid
|
|
372
|
+
// cryptographically — which is precisely the point: with a signed URL the
|
|
373
|
+
// expiry lives in the token and the server has no say. Here it does.
|
|
374
|
+
await s.db.query(`UPDATE file_grant SET expires_at = now() - interval '1 second' WHERE id = $1`, [
|
|
375
|
+
share.grantId,
|
|
376
|
+
]);
|
|
377
|
+
|
|
378
|
+
await rejects(() => s.fl.redeem(share.secret!), 404);
|
|
379
|
+
const live = await s.db.query(`SELECT 1 FROM live_grant WHERE id = $1`, [share.grantId]);
|
|
380
|
+
assert.equal(live.rows.length, 0);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('an expired FILE is unreachable even through a live, unexpired grant', async () => {
|
|
384
|
+
const s = await twoOrgs();
|
|
385
|
+
const share = await s.fl.share(P(s.alice), s.fileA.id, { subject: { type: 'link' } });
|
|
386
|
+
await s.fl.redeem(share.secret!); // works while the file is live
|
|
387
|
+
|
|
388
|
+
await s.db.query(`UPDATE file SET expires_at = now() - interval '1 second' WHERE id = $1`, [
|
|
389
|
+
s.fileA.id,
|
|
390
|
+
]);
|
|
391
|
+
|
|
392
|
+
// The grant is still live by its own terms...
|
|
393
|
+
const live = await s.db.query(`SELECT 1 FROM live_grant WHERE id = $1`, [share.grantId]);
|
|
394
|
+
assert.equal(live.rows.length, 1);
|
|
395
|
+
// ...and it still buys nothing, because the file gate runs first.
|
|
396
|
+
await rejects(() => s.fl.redeem(share.secret!), 410, 'gone');
|
|
397
|
+
// Members of the org are equally blocked.
|
|
398
|
+
await rejects(() => s.fl.read(P(s.alice), s.fileA.id), 410, 'gone');
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('an expired grant does not consume a download slot', async () => {
|
|
402
|
+
const s = await twoOrgs();
|
|
403
|
+
const share = await s.fl.share(P(s.alice), s.fileA.id, {
|
|
404
|
+
subject: { type: 'link' },
|
|
405
|
+
maxDownloads: 3,
|
|
406
|
+
});
|
|
407
|
+
await s.db.query(`UPDATE file_grant SET expires_at = now() - interval '1 second' WHERE id = $1`, [
|
|
408
|
+
share.grantId,
|
|
409
|
+
]);
|
|
410
|
+
await rejects(() => s.fl.redeem(share.secret!), 404);
|
|
411
|
+
const { rows } = await s.db.query<{ download_count: number }>(
|
|
412
|
+
`SELECT download_count FROM file_grant WHERE id = $1`,
|
|
413
|
+
[share.grantId],
|
|
414
|
+
);
|
|
415
|
+
assert.equal(Number(rows[0]!.download_count), 0);
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// =============================================================================
|
|
420
|
+
// 6. DENY BY DEFAULT (P1)
|
|
421
|
+
// =============================================================================
|
|
422
|
+
|
|
423
|
+
describe('PROPERTY 6: deny by default (P1)', () => {
|
|
424
|
+
it('a brand new file is reachable by nobody outside the org: no members, no links, no anonymous', async () => {
|
|
425
|
+
const s = await twoOrgs();
|
|
426
|
+
const outsiders: Array<[string, Principal]> = [
|
|
427
|
+
['unauthenticated', P(null)],
|
|
428
|
+
['actor with no org', P(s.mallory)],
|
|
429
|
+
['owner of a different org', P(s.bob)],
|
|
430
|
+
['forged link secret', P(null, { linkSecret: 'a'.repeat(43) })],
|
|
431
|
+
];
|
|
432
|
+
for (const [label, principal] of outsiders) {
|
|
433
|
+
await rejects(() => s.fl.read(principal, s.fileA.id), 404);
|
|
434
|
+
const d = await authorize(s.fl.store, principal, s.fileA.id, 'read');
|
|
435
|
+
assert.equal(d.allow, false, `${label} must be denied`);
|
|
436
|
+
}
|
|
437
|
+
// There is no row anywhere that would have granted this. Absence is denial.
|
|
438
|
+
const { rows } = await s.db.query(`SELECT count(*)::int c FROM file_grant WHERE file_id = $1`, [
|
|
439
|
+
s.fileA.id,
|
|
440
|
+
]);
|
|
441
|
+
assert.equal(Number((rows[0] as { c: number }).c), 0);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('there is no "public" flag: public delivery requires an explicit, revocable grant row', async () => {
|
|
445
|
+
const s = await twoOrgs();
|
|
446
|
+
// Prove the schema has no boolean that could be flipped by accident.
|
|
447
|
+
const { rows } = await s.db.query<{ column_name: string }>(
|
|
448
|
+
`SELECT column_name FROM information_schema.columns
|
|
449
|
+
WHERE table_schema='public' AND data_type='boolean'`,
|
|
450
|
+
);
|
|
451
|
+
assert.deepEqual(rows, [], 'no boolean columns exist anywhere in the schema');
|
|
452
|
+
|
|
453
|
+
await rejects(() => s.fl.read(P(null), s.fileA.id), 404);
|
|
454
|
+
await s.fl.share(P(s.alice), s.fileA.id, { subject: { type: 'anonymous' } });
|
|
455
|
+
const got = await s.fl.read(P(null), s.fileA.id);
|
|
456
|
+
assert.equal(text(got.body), 'ACME CONFIDENTIAL');
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('P1 holds at the FILE boundary too: an org member cannot read a private file', async () => {
|
|
460
|
+
// P1 used to hold only at the TENANT boundary. Every role, including
|
|
461
|
+
// `viewer`, could read every file in the org, so a document uploaded by the
|
|
462
|
+
// CFO was readable by the whole workspace the moment it existed, with no
|
|
463
|
+
// grant and no act of sharing. That was the defect.
|
|
464
|
+
//
|
|
465
|
+
// Files are now `private` by default: the owner and the org's admins, and
|
|
466
|
+
// nobody else, until somebody shares.
|
|
467
|
+
const s = await twoOrgs();
|
|
468
|
+
const nosy = (await s.fl.createActor('nosy')).id;
|
|
469
|
+
const colleague = (await s.fl.createActor('colleague')).id;
|
|
470
|
+
await s.fl.addMember(P(s.alice), s.orgA, nosy, 'viewer');
|
|
471
|
+
await s.fl.addMember(P(s.alice), s.orgA, colleague, 'member');
|
|
472
|
+
|
|
473
|
+
assert.equal(s.fileA.visibility, 'private', 'the default must be the restrictive one');
|
|
474
|
+
await rejects(() => s.fl.read(P(nosy), s.fileA.id), 404);
|
|
475
|
+
await rejects(() => s.fl.read(P(colleague), s.fileA.id), 404);
|
|
476
|
+
await rejects(() => s.fl.read(P(s.anna), s.fileA.id), 404);
|
|
477
|
+
|
|
478
|
+
// The owner and the org's owner/admins still reach it -- otherwise nobody
|
|
479
|
+
// could honour a retention hold or a deletion request.
|
|
480
|
+
assert.equal(text((await s.fl.read(P(s.alice), s.fileA.id)).body), 'ACME CONFIDENTIAL');
|
|
481
|
+
|
|
482
|
+
// ...and an explicit grant is all it takes to let one person in, which is
|
|
483
|
+
// the point: access is a row, not an emergent property of a role table.
|
|
484
|
+
await s.fl.share(P(s.alice), s.fileA.id, {
|
|
485
|
+
subject: { type: 'actor', actorId: colleague },
|
|
486
|
+
});
|
|
487
|
+
assert.equal(text((await s.fl.read(P(colleague), s.fileA.id)).body), 'ACME CONFIDENTIAL');
|
|
488
|
+
await rejects(() => s.fl.read(P(nosy), s.fileA.id), 404);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('org-wide visibility is available, but only as an explicit choice at creation', async () => {
|
|
492
|
+
const s = await twoOrgs();
|
|
493
|
+
const nosy = (await s.fl.createActor('nosy2')).id;
|
|
494
|
+
await s.fl.addMember(P(s.alice), s.orgA, nosy, 'viewer');
|
|
495
|
+
|
|
496
|
+
const shared = await s.fl.upload({ actorId: s.alice }, s.orgA, {
|
|
497
|
+
name: 'handbook.pdf',
|
|
498
|
+
contentType: 'application/pdf',
|
|
499
|
+
body: bytes('EMPLOYEE HANDBOOK'),
|
|
500
|
+
visibility: 'org',
|
|
501
|
+
});
|
|
502
|
+
assert.equal(shared.visibility, 'org');
|
|
503
|
+
assert.equal(text((await s.fl.read(P(nosy), shared.id)).body), 'EMPLOYEE HANDBOOK');
|
|
504
|
+
// Still nothing for another tenant, and still read-only for a viewer.
|
|
505
|
+
await rejects(() => s.fl.read(P(s.bob), shared.id), 404);
|
|
506
|
+
await rejects(() => s.fl.delete(P(nosy), shared.id), 404);
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// =============================================================================
|
|
511
|
+
// 7. THE ROLE MATRIX IS TOTAL
|
|
512
|
+
// =============================================================================
|
|
513
|
+
|
|
514
|
+
describe('PROPERTY 7: the role matrix is total and enumerated', () => {
|
|
515
|
+
// Written out by hand from the documented model. Deliberately NOT derived
|
|
516
|
+
// from fileCapabilities(): a generated table proves only self-consistency.
|
|
517
|
+
//
|
|
518
|
+
// There are now two of these, because `visibility` is the second axis of the
|
|
519
|
+
// file-level model. Both are enumerated in full; the suite asserts 64
|
|
520
|
+
// cells and that the tables themselves contain exactly 64 entries, so a cell
|
|
521
|
+
// cannot be quietly dropped.
|
|
522
|
+
const EXPECTED_PRIVATE: Record<string, boolean> = {
|
|
523
|
+
// Under the default, an org role buys nothing on someone else's file.
|
|
524
|
+
'viewer|owner|read': true,
|
|
525
|
+
'viewer|owner|write': false,
|
|
526
|
+
'viewer|owner|delete': false,
|
|
527
|
+
'viewer|owner|share': false,
|
|
528
|
+
'viewer|other|read': false,
|
|
529
|
+
'viewer|other|write': false,
|
|
530
|
+
'viewer|other|delete': false,
|
|
531
|
+
'viewer|other|share': false,
|
|
532
|
+
|
|
533
|
+
'member|owner|read': true,
|
|
534
|
+
'member|owner|write': true,
|
|
535
|
+
'member|owner|delete': true,
|
|
536
|
+
'member|owner|share': true,
|
|
537
|
+
'member|other|read': false,
|
|
538
|
+
'member|other|write': false,
|
|
539
|
+
'member|other|delete': false,
|
|
540
|
+
'member|other|share': false,
|
|
541
|
+
|
|
542
|
+
// Admins and owners keep full access under both settings: retention,
|
|
543
|
+
// deletion and legal hold are their responsibility.
|
|
544
|
+
'admin|owner|read': true,
|
|
545
|
+
'admin|owner|write': true,
|
|
546
|
+
'admin|owner|delete': true,
|
|
547
|
+
'admin|owner|share': true,
|
|
548
|
+
'admin|other|read': true,
|
|
549
|
+
'admin|other|write': true,
|
|
550
|
+
'admin|other|delete': true,
|
|
551
|
+
'admin|other|share': true,
|
|
552
|
+
|
|
553
|
+
'owner|owner|read': true,
|
|
554
|
+
'owner|owner|write': true,
|
|
555
|
+
'owner|owner|delete': true,
|
|
556
|
+
'owner|owner|share': true,
|
|
557
|
+
'owner|other|read': true,
|
|
558
|
+
'owner|other|write': true,
|
|
559
|
+
'owner|other|delete': true,
|
|
560
|
+
'owner|other|share': true,
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
const EXPECTED_ORG: Record<string, boolean> = {
|
|
564
|
+
// role | owner? | capability -> allowed
|
|
565
|
+
'viewer|owner|read': true,
|
|
566
|
+
'viewer|owner|write': false,
|
|
567
|
+
'viewer|owner|delete': false,
|
|
568
|
+
'viewer|owner|share': false,
|
|
569
|
+
'viewer|other|read': true,
|
|
570
|
+
'viewer|other|write': false,
|
|
571
|
+
'viewer|other|delete': false,
|
|
572
|
+
'viewer|other|share': false,
|
|
573
|
+
|
|
574
|
+
'member|owner|read': true,
|
|
575
|
+
'member|owner|write': true,
|
|
576
|
+
'member|owner|delete': true,
|
|
577
|
+
'member|owner|share': true,
|
|
578
|
+
'member|other|read': true,
|
|
579
|
+
'member|other|write': false,
|
|
580
|
+
'member|other|delete': false,
|
|
581
|
+
'member|other|share': false,
|
|
582
|
+
|
|
583
|
+
'admin|owner|read': true,
|
|
584
|
+
'admin|owner|write': true,
|
|
585
|
+
'admin|owner|delete': true,
|
|
586
|
+
'admin|owner|share': true,
|
|
587
|
+
'admin|other|read': true,
|
|
588
|
+
'admin|other|write': true,
|
|
589
|
+
'admin|other|delete': true,
|
|
590
|
+
'admin|other|share': true,
|
|
591
|
+
|
|
592
|
+
'owner|owner|read': true,
|
|
593
|
+
'owner|owner|write': true,
|
|
594
|
+
'owner|owner|delete': true,
|
|
595
|
+
'owner|owner|share': true,
|
|
596
|
+
'owner|other|read': true,
|
|
597
|
+
'owner|other|write': true,
|
|
598
|
+
'owner|other|delete': true,
|
|
599
|
+
'owner|other|share': true,
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
const ROLES: OrgRole[] = ['viewer', 'member', 'admin', 'owner'];
|
|
603
|
+
const CAPS: Capability[] = ['read', 'write', 'delete', 'share'];
|
|
604
|
+
|
|
605
|
+
it('every (visibility x role x ownership x capability) cell matches the documented model', async () => {
|
|
606
|
+
const s = await twoOrgs();
|
|
607
|
+
const subject = (await s.fl.createActor('matrix-subject')).id;
|
|
608
|
+
const somebodyElse = (await s.fl.createActor('matrix-other')).id;
|
|
609
|
+
await s.fl.addMember(P(s.alice), s.orgA, somebodyElse, 'member');
|
|
610
|
+
|
|
611
|
+
let checked = 0;
|
|
612
|
+
for (const [visibility, table] of [
|
|
613
|
+
['private', EXPECTED_PRIVATE],
|
|
614
|
+
['org', EXPECTED_ORG],
|
|
615
|
+
] as const) {
|
|
616
|
+
await s.db.query(`UPDATE file SET visibility = $1 WHERE id = $2`, [visibility, s.fileA.id]);
|
|
617
|
+
for (const role of ROLES) {
|
|
618
|
+
await s.fl.addMember(P(s.alice), s.orgA, subject, role);
|
|
619
|
+
for (const ownership of ['owner', 'other'] as const) {
|
|
620
|
+
await s.db.query(`UPDATE file SET owner_id = $1 WHERE id = $2`, [
|
|
621
|
+
ownership === 'owner' ? subject : somebodyElse,
|
|
622
|
+
s.fileA.id,
|
|
623
|
+
]);
|
|
624
|
+
for (const cap of CAPS) {
|
|
625
|
+
const key = `${role}|${ownership}|${cap}`;
|
|
626
|
+
const expected = table[key];
|
|
627
|
+
assert.notEqual(expected, undefined, `matrix is missing a cell: ${visibility} ${key}`);
|
|
628
|
+
const d = await authorize(s.fl.store, P(subject), s.fileA.id, cap);
|
|
629
|
+
assert.equal(d.allow, expected, `role matrix mismatch at ${visibility}|${key}`);
|
|
630
|
+
checked++;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
assert.equal(
|
|
636
|
+
checked,
|
|
637
|
+
64,
|
|
638
|
+
'the matrix must be total: 2 visibilities x 4 roles x 2 ownerships x 4 capabilities',
|
|
639
|
+
);
|
|
640
|
+
assert.equal(Object.keys(EXPECTED_ORG).length, 32);
|
|
641
|
+
assert.equal(Object.keys(EXPECTED_PRIVATE).length, 32);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it('a grant can add a capability a role lacks, but never removes one the role has', async () => {
|
|
645
|
+
const s = await twoOrgs();
|
|
646
|
+
const viewer = (await s.fl.createActor('viewer-with-grant')).id;
|
|
647
|
+
await s.fl.addMember(P(s.alice), s.orgA, viewer, 'viewer');
|
|
648
|
+
|
|
649
|
+
// Viewer cannot share...
|
|
650
|
+
let d = await authorize(s.fl.store, P(viewer), s.fileA.id, 'share');
|
|
651
|
+
assert.equal(d.allow, false);
|
|
652
|
+
assert.equal(d.allow === false && d.reason, 'insufficient_role');
|
|
653
|
+
|
|
654
|
+
// ...until explicitly granted it.
|
|
655
|
+
await s.fl.share(P(s.alice), s.fileA.id, {
|
|
656
|
+
subject: { type: 'actor', actorId: viewer },
|
|
657
|
+
capabilities: ['share'],
|
|
658
|
+
});
|
|
659
|
+
d = await authorize(s.fl.store, P(viewer), s.fileA.id, 'share');
|
|
660
|
+
assert.equal(d.allow, true);
|
|
661
|
+
assert.equal(d.allow === true && d.via, 'grant:actor');
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
// =============================================================================
|
|
666
|
+
// 8. RETENTION
|
|
667
|
+
// =============================================================================
|
|
668
|
+
|
|
669
|
+
describe('PROPERTY 8: retention blocks deletion, including for the org owner', () => {
|
|
670
|
+
it('the org owner cannot delete a file under a retention hold', async () => {
|
|
671
|
+
const s = await twoOrgs();
|
|
672
|
+
const held = await s.fl.upload({ actorId: s.alice }, s.orgA, {
|
|
673
|
+
name: 'invoice-2026.pdf',
|
|
674
|
+
contentType: 'application/pdf',
|
|
675
|
+
body: bytes('LEGALLY REQUIRED'),
|
|
676
|
+
retainFor: 3600,
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
// Alice is the org owner AND the file owner. Retention that the most
|
|
680
|
+
// privileged principal can override is not a compliance control.
|
|
681
|
+
const err = await rejects(() => s.fl.delete(P(s.alice), held.id), 409, 'retention_hold');
|
|
682
|
+
assert.equal(err.reason, 'retention_hold');
|
|
683
|
+
|
|
684
|
+
// The bytes are still there.
|
|
685
|
+
assert.equal(text((await s.fl.read(P(s.alice), held.id)).body), 'LEGALLY REQUIRED');
|
|
686
|
+
|
|
687
|
+
// And the attempt is on the record.
|
|
688
|
+
const denials = await s.fl.auditLog(P(s.alice), s.orgA, { decision: 'deny' });
|
|
689
|
+
assert.ok(denials.some((e) => e.reason === 'retention_hold' && e.fileId === held.id));
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it('deletion succeeds once the retention window has passed', async () => {
|
|
693
|
+
const s = await twoOrgs();
|
|
694
|
+
const held = await s.fl.upload({ actorId: s.alice }, s.orgA, {
|
|
695
|
+
name: 'invoice.pdf',
|
|
696
|
+
contentType: 'application/pdf',
|
|
697
|
+
body: bytes('x'),
|
|
698
|
+
retainFor: 3600,
|
|
699
|
+
});
|
|
700
|
+
await rejects(() => s.fl.delete(P(s.alice), held.id), 409);
|
|
701
|
+
await s.db.query(`UPDATE file SET retain_until = now() - interval '1 second' WHERE id = $1`, [
|
|
702
|
+
held.id,
|
|
703
|
+
]);
|
|
704
|
+
await s.fl.delete(P(s.alice), held.id);
|
|
705
|
+
await rejects(() => s.fl.read(P(s.alice), held.id), 404);
|
|
706
|
+
assert.equal(s.storage.keys().includes(held.storageKey), false, 'bytes must be gone too');
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it('the schema refuses a retention floor that outlives the file expiry', async () => {
|
|
710
|
+
const s = await twoOrgs();
|
|
711
|
+
await dbRejects(
|
|
712
|
+
s.db,
|
|
713
|
+
`UPDATE file SET expires_at = now() + interval '1 hour',
|
|
714
|
+
retain_until = now() + interval '2 hours' WHERE id = $1`,
|
|
715
|
+
[s.fileA.id],
|
|
716
|
+
/file_retention_before_expiry/,
|
|
717
|
+
);
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
// =============================================================================
|
|
722
|
+
// 9. ANONYMOUS GRANTS ARE READ-ONLY
|
|
723
|
+
// =============================================================================
|
|
724
|
+
|
|
725
|
+
describe('PROPERTY 9: anonymous grants can never carry write, delete or share', () => {
|
|
726
|
+
for (const caps of [
|
|
727
|
+
['write'],
|
|
728
|
+
['delete'],
|
|
729
|
+
['share'],
|
|
730
|
+
['read', 'write'],
|
|
731
|
+
['read', 'delete'],
|
|
732
|
+
['read', 'share'],
|
|
733
|
+
['read', 'write', 'delete', 'share'],
|
|
734
|
+
]) {
|
|
735
|
+
it(`the DATABASE rejects an anonymous grant with capabilities {${caps.join(',')}}`, async () => {
|
|
736
|
+
const s = await twoOrgs();
|
|
737
|
+
await dbRejects(
|
|
738
|
+
s.db,
|
|
739
|
+
`INSERT INTO file_grant (file_id, org_id, subject_type, capabilities)
|
|
740
|
+
VALUES ($1, $2, 'anonymous', $3::grant_capability[])`,
|
|
741
|
+
[s.fileA.id, s.orgA, `{${caps.join(',')}}`],
|
|
742
|
+
/grant_anonymous_read_only/,
|
|
743
|
+
);
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
it('the API surface cannot smuggle the escalation past the constraint either', async () => {
|
|
748
|
+
const s = await twoOrgs();
|
|
749
|
+
await assert.rejects(
|
|
750
|
+
() =>
|
|
751
|
+
s.fl.share(P(s.alice), s.fileA.id, {
|
|
752
|
+
subject: { type: 'anonymous' },
|
|
753
|
+
capabilities: ['read', 'write'],
|
|
754
|
+
}),
|
|
755
|
+
/grant_anonymous_read_only/,
|
|
756
|
+
);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it('an anonymous grant with exactly {read} is accepted, so the constraint is not vacuous', async () => {
|
|
760
|
+
const s = await twoOrgs();
|
|
761
|
+
const g = await s.fl.share(P(s.alice), s.fileA.id, { subject: { type: 'anonymous' } });
|
|
762
|
+
assert.ok(g.grantId);
|
|
763
|
+
assert.equal(g.secret, undefined, 'an anonymous grant has no secret to leak');
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
it('a subject-type/credential mismatch is unrepresentable', async () => {
|
|
767
|
+
const s = await twoOrgs();
|
|
768
|
+
// A 'link' grant with no secret would be a link anyone could redeem.
|
|
769
|
+
await dbRejects(
|
|
770
|
+
s.db,
|
|
771
|
+
`INSERT INTO file_grant (file_id, org_id, subject_type, capabilities)
|
|
772
|
+
VALUES ($1, $2, 'link', ARRAY['read']::grant_capability[])`,
|
|
773
|
+
[s.fileA.id, s.orgA],
|
|
774
|
+
/grant_subject_coherent/,
|
|
775
|
+
);
|
|
776
|
+
// An 'anonymous' grant carrying a secret_hash would be two things at once.
|
|
777
|
+
await dbRejects(
|
|
778
|
+
s.db,
|
|
779
|
+
`INSERT INTO file_grant (file_id, org_id, subject_type, capabilities, secret_hash)
|
|
780
|
+
VALUES ($1, $2, 'anonymous', ARRAY['read']::grant_capability[], 'deadbeef')`,
|
|
781
|
+
[s.fileA.id, s.orgA],
|
|
782
|
+
/grant_subject_coherent/,
|
|
783
|
+
);
|
|
784
|
+
// A grant with no capabilities at all is meaningless and rejected.
|
|
785
|
+
await dbRejects(
|
|
786
|
+
s.db,
|
|
787
|
+
`INSERT INTO file_grant (file_id, org_id, subject_type, subject_id, capabilities)
|
|
788
|
+
VALUES ($1, $2, 'actor', $3, ARRAY[]::grant_capability[])`,
|
|
789
|
+
[s.fileA.id, s.orgA, s.alice],
|
|
790
|
+
/grant_capabilities_nonempty/,
|
|
791
|
+
);
|
|
792
|
+
});
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
// =============================================================================
|
|
796
|
+
// 10. SECRETS ARE NOT RECOVERABLE FROM THE DATABASE
|
|
797
|
+
// =============================================================================
|
|
798
|
+
|
|
799
|
+
describe('PROPERTY 10: a database dump does not yield working share links', () => {
|
|
800
|
+
it('no plaintext link secret or share password appears in ANY column of ANY table', async () => {
|
|
801
|
+
const s = await twoOrgs();
|
|
802
|
+
const password = 'correct-horse-battery-staple';
|
|
803
|
+
const share = await s.fl.share(P(s.alice), s.fileA.id, {
|
|
804
|
+
subject: { type: 'link' },
|
|
805
|
+
password,
|
|
806
|
+
maxDownloads: 10,
|
|
807
|
+
});
|
|
808
|
+
const secret = share.secret!;
|
|
809
|
+
// Exercise the link so that any incidental logging would have happened.
|
|
810
|
+
await s.fl.redeem(secret, { password });
|
|
811
|
+
|
|
812
|
+
const { rows: tables } = await s.db.query<{ table_name: string }>(
|
|
813
|
+
`SELECT table_name FROM information_schema.tables
|
|
814
|
+
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'`,
|
|
815
|
+
);
|
|
816
|
+
assert.ok(tables.length >= 6, 'sanity: we should be scanning the whole schema');
|
|
817
|
+
|
|
818
|
+
let scanned = 0;
|
|
819
|
+
for (const t of tables) {
|
|
820
|
+
const { rows } = await s.db.query<{ dump: string }>(
|
|
821
|
+
`SELECT to_jsonb(x)::text AS dump FROM "${t.table_name}" x`,
|
|
822
|
+
);
|
|
823
|
+
for (const r of rows) {
|
|
824
|
+
scanned++;
|
|
825
|
+
assert.equal(
|
|
826
|
+
r.dump.includes(secret),
|
|
827
|
+
false,
|
|
828
|
+
`plaintext link secret found in table ${t.table_name}`,
|
|
829
|
+
);
|
|
830
|
+
assert.equal(
|
|
831
|
+
r.dump.includes(password),
|
|
832
|
+
false,
|
|
833
|
+
`plaintext share password found in table ${t.table_name}`,
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
assert.ok(scanned > 0, 'sanity: the scan must actually have read rows');
|
|
838
|
+
|
|
839
|
+
// What IS stored: a SHA-256 of the secret, and a salted scrypt of the
|
|
840
|
+
// password. Neither is usable without the original.
|
|
841
|
+
const { rows: g } = await s.db.query<{ secret_hash: string; password_hash: string }>(
|
|
842
|
+
`SELECT secret_hash, password_hash FROM file_grant WHERE id = $1`,
|
|
843
|
+
[share.grantId],
|
|
844
|
+
);
|
|
845
|
+
assert.match(g[0]!.secret_hash, /^[0-9a-f]{64}$/);
|
|
846
|
+
assert.match(g[0]!.password_hash, /^scrypt\$/);
|
|
847
|
+
|
|
848
|
+
// The stored hash is not itself a credential: presenting it as the secret
|
|
849
|
+
// must fail (otherwise a dump would still be enough).
|
|
850
|
+
await rejects(() => s.fl.redeem(g[0]!.secret_hash, { password }), 404);
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
it('listGrants never returns a secret or password hash', async () => {
|
|
854
|
+
const s = await twoOrgs();
|
|
855
|
+
await s.fl.share(P(s.alice), s.fileA.id, {
|
|
856
|
+
subject: { type: 'link' },
|
|
857
|
+
password: 'hunter2',
|
|
858
|
+
});
|
|
859
|
+
const grants = await s.fl.listGrants(P(s.alice), s.fileA.id);
|
|
860
|
+
assert.equal(grants.length, 1);
|
|
861
|
+
const serialized = JSON.stringify(grants);
|
|
862
|
+
assert.equal(serialized.includes('hunter2'), false);
|
|
863
|
+
assert.equal(/secret/i.test(serialized), false);
|
|
864
|
+
assert.equal(grants[0]!.hasPassword, true, 'but the UI can still say "password protected"');
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
it('the wrong password on a valid link is 401, and the right one works', async () => {
|
|
868
|
+
const s = await twoOrgs();
|
|
869
|
+
const share = await s.fl.share(P(s.alice), s.fileA.id, {
|
|
870
|
+
subject: { type: 'link' },
|
|
871
|
+
password: 's3cret',
|
|
872
|
+
});
|
|
873
|
+
await rejects(() => s.fl.redeem(share.secret!), 401, 'password_required');
|
|
874
|
+
await rejects(() => s.fl.redeem(share.secret!, { password: 'wrong' }), 401);
|
|
875
|
+
const ok = await s.fl.redeem(share.secret!, { password: 's3cret' });
|
|
876
|
+
assert.equal(text(ok.body), 'ACME CONFIDENTIAL');
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
it('a failed password attempt does not burn a download', async () => {
|
|
880
|
+
const s = await twoOrgs();
|
|
881
|
+
const share = await s.fl.share(P(s.alice), s.fileA.id, {
|
|
882
|
+
subject: { type: 'link' },
|
|
883
|
+
password: 's3cret',
|
|
884
|
+
maxDownloads: 1,
|
|
885
|
+
});
|
|
886
|
+
await rejects(() => s.fl.redeem(share.secret!, { password: 'wrong' }), 401);
|
|
887
|
+
const ok = await s.fl.redeem(share.secret!, { password: 's3cret' });
|
|
888
|
+
assert.equal(ok.remainingDownloads, 0);
|
|
889
|
+
});
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
// =============================================================================
|
|
893
|
+
// 11. DENIALS ARE AUDITED
|
|
894
|
+
// =============================================================================
|
|
895
|
+
|
|
896
|
+
describe('PROPERTY 11: denials are audited (P5)', () => {
|
|
897
|
+
it('one denied read produces exactly one deny event with the correct reason', async () => {
|
|
898
|
+
const s = await twoOrgs();
|
|
899
|
+
const before = await countAudit(s.db, s.orgB);
|
|
900
|
+
|
|
901
|
+
await rejects(() => s.fl.read(P(s.alice), s.fileB.id), 404);
|
|
902
|
+
|
|
903
|
+
const after = await s.fl.auditLog(P(s.bob), s.orgB, {});
|
|
904
|
+
assert.equal(after.length - before, 1, 'exactly one event, not zero and not two');
|
|
905
|
+
const e = after[after.length - 1]!;
|
|
906
|
+
assert.equal(e.decision, 'deny');
|
|
907
|
+
assert.equal(e.reason, 'no_membership');
|
|
908
|
+
assert.equal(e.action, 'file.read');
|
|
909
|
+
assert.equal(e.actorId, s.alice);
|
|
910
|
+
assert.equal(e.fileId, s.fileB.id);
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
it('each distinct denial reason is recorded distinctly', async () => {
|
|
914
|
+
const s = await twoOrgs();
|
|
915
|
+
const viewer = (await s.fl.createActor('aud-viewer')).id;
|
|
916
|
+
await s.fl.addMember(P(s.alice), s.orgA, viewer, 'viewer');
|
|
917
|
+
|
|
918
|
+
await rejects(() => s.fl.delete(P(viewer), s.fileA.id), 404); // insufficient_role
|
|
919
|
+
await rejects(() => s.fl.read(P(null, { linkSecret: 'nope' }), s.fileA.id), 404); // bad_link_secret
|
|
920
|
+
|
|
921
|
+
const share = await s.fl.share(P(s.alice), s.fileA.id, {
|
|
922
|
+
subject: { type: 'link' },
|
|
923
|
+
password: 'p',
|
|
924
|
+
});
|
|
925
|
+
await rejects(() => s.fl.redeem(share.secret!, { password: 'q' }), 401); // bad_password
|
|
926
|
+
|
|
927
|
+
const reasons = (await s.fl.auditLog(P(s.alice), s.orgA, { decision: 'deny' })).map(
|
|
928
|
+
(e) => e.reason,
|
|
929
|
+
);
|
|
930
|
+
for (const expected of ['insufficient_role', 'bad_link_secret', 'bad_password']) {
|
|
931
|
+
assert.ok(reasons.includes(expected), `missing deny reason ${expected}; got ${reasons}`);
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
it('an exhausted download cap is audited as grant_exhausted', async () => {
|
|
936
|
+
const s = await twoOrgs();
|
|
937
|
+
const share = await s.fl.share(P(s.alice), s.fileA.id, {
|
|
938
|
+
subject: { type: 'link' },
|
|
939
|
+
maxDownloads: 1,
|
|
940
|
+
});
|
|
941
|
+
await s.fl.redeem(share.secret!);
|
|
942
|
+
await rejects(() => s.fl.redeem(share.secret!), 404);
|
|
943
|
+
const denials = await s.fl.auditLog(P(s.alice), s.orgA, { decision: 'deny' });
|
|
944
|
+
const e = denials.find((x) => x.reason === 'grant_exhausted');
|
|
945
|
+
// An exhausted grant leaves `live_grant`, so it cannot be used to authorize
|
|
946
|
+
// anything -- but it can still be RESOLVED, which is what lets the denial
|
|
947
|
+
// be attributed to the right tenant and named for what it is. Before the
|
|
948
|
+
// fix, "my link stopped working" produced nothing at all.
|
|
949
|
+
assert.ok(e, `expected a grant_exhausted denial; got ${denials.map((x) => x.reason)}`);
|
|
950
|
+
assert.equal(e.grantId, share.grantId, 'and it names the grant that ran out');
|
|
951
|
+
assert.equal(e.fileId, s.fileA.id);
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
it('the audit log is admin-only: a member cannot read it', async () => {
|
|
955
|
+
const s = await twoOrgs();
|
|
956
|
+
await rejects(() => s.fl.auditLog(P(s.anna), s.orgA, {}), 404);
|
|
957
|
+
await rejects(() => s.fl.auditLog(P(s.bob), s.orgA, {}), 404);
|
|
958
|
+
await rejects(() => s.fl.auditLog(P(null), s.orgA, {}), 404);
|
|
959
|
+
const ok = await s.fl.auditLog(P(s.alice), s.orgA, {});
|
|
960
|
+
assert.ok(ok.length > 0);
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
it('an enumeration sweep against unknown file ids is recorded on the system chain', async () => {
|
|
964
|
+
// P5 says every access decision is audited, including denials. It used not
|
|
965
|
+
// to be: `authorize()` returned file_not_found with a null org and skipped
|
|
966
|
+
// the audit entirely, so a file-id enumeration sweep -- the single most
|
|
967
|
+
// characteristic reconnaissance pattern against an object store -- left
|
|
968
|
+
// zero trace anywhere in the system.
|
|
969
|
+
//
|
|
970
|
+
// The events have no tenant to charge them to, and inventing one would
|
|
971
|
+
// itself be an existence oracle, so they go to the system chain.
|
|
972
|
+
const s = await twoOrgs();
|
|
973
|
+
const before = await countAudit(s.db, null);
|
|
974
|
+
const orgBefore = await countAudit(s.db, s.orgA);
|
|
975
|
+
|
|
976
|
+
for (let i = 0; i < 25; i++) {
|
|
977
|
+
await rejects(() => s.fl.read(P(s.mallory), crypto.randomUUID()), 404);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
assert.equal(await countAudit(s.db, null), before + 25, '25 probes, 25 events');
|
|
981
|
+
assert.equal(
|
|
982
|
+
await countAudit(s.db, s.orgA),
|
|
983
|
+
orgBefore,
|
|
984
|
+
'and not one of them was attributed to a real tenant',
|
|
985
|
+
);
|
|
986
|
+
|
|
987
|
+
const { rows } = await s.db.query<{ reason: string; action: string }>(
|
|
988
|
+
`SELECT reason, action FROM audit_event WHERE org_id IS NULL ORDER BY id DESC LIMIT 1`,
|
|
989
|
+
);
|
|
990
|
+
assert.equal(rows[0]!.reason, 'file_not_found');
|
|
991
|
+
assert.equal(rows[0]!.action, 'file.read');
|
|
992
|
+
|
|
993
|
+
// The system chain is chained and verifiable like any other...
|
|
994
|
+
assert.equal((await s.fl.store.verifyAuditChain(null)).valid, true);
|
|
995
|
+
// ...and no tenant can read it: `auditLog` requires an org you administer.
|
|
996
|
+
await rejects(() => s.fl.auditLog(P(s.alice), null as unknown as string, {}), 404);
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
it('a brute-force sweep against LINK SECRETS is recorded too', async () => {
|
|
1000
|
+
// Worse than file-id enumeration, because it is a sweep against the
|
|
1001
|
+
// credential itself. `redeem` used to throw before reaching the engine.
|
|
1002
|
+
const s = await twoOrgs();
|
|
1003
|
+
const before = await countAudit(s.db, null);
|
|
1004
|
+
for (let i = 0; i < 10; i++) {
|
|
1005
|
+
await rejects(() => s.fl.redeem(`forged-${i}`), 404);
|
|
1006
|
+
}
|
|
1007
|
+
assert.equal(await countAudit(s.db, null), before + 10);
|
|
1008
|
+
|
|
1009
|
+
const { rows } = await s.db.query<{ reason: string; context: unknown }>(
|
|
1010
|
+
`SELECT reason, context FROM audit_event WHERE org_id IS NULL ORDER BY id DESC LIMIT 1`,
|
|
1011
|
+
);
|
|
1012
|
+
assert.equal(rows[0]!.reason, 'bad_link_secret');
|
|
1013
|
+
const ctx = (
|
|
1014
|
+
typeof rows[0]!.context === 'string' ? JSON.parse(rows[0]!.context as string) : rows[0]!.context
|
|
1015
|
+
) as Record<string, string>;
|
|
1016
|
+
// Enough to correlate a sweep, not enough to be a credential: a 48-bit
|
|
1017
|
+
// prefix of the SHA-256 of what was presented.
|
|
1018
|
+
assert.match(ctx['secretHashPrefix']!, /^[0-9a-f]{12}$/);
|
|
1019
|
+
});
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
// =============================================================================
|
|
1023
|
+
// 12. AUDIT IS APPEND-ONLY AND TAMPER-EVIDENT
|
|
1024
|
+
// =============================================================================
|
|
1025
|
+
|
|
1026
|
+
describe('PROPERTY 12: the audit log is append-only and tamper-evident', () => {
|
|
1027
|
+
it('UPDATE and DELETE on audit_event are no-ops', async () => {
|
|
1028
|
+
const s = await twoOrgs();
|
|
1029
|
+
const { rows: before } = await s.db.query<{ c: number }>(
|
|
1030
|
+
`SELECT count(*)::int c FROM audit_event WHERE org_id = $1`,
|
|
1031
|
+
[s.orgA],
|
|
1032
|
+
);
|
|
1033
|
+
assert.ok(Number(before[0]!.c) > 0);
|
|
1034
|
+
|
|
1035
|
+
const upd = await s.db.query(`UPDATE audit_event SET decision = 'allow', reason = NULL`);
|
|
1036
|
+
assert.equal(upd.affectedRows ?? 0, 0);
|
|
1037
|
+
|
|
1038
|
+
const del = await s.db.query(`DELETE FROM audit_event`);
|
|
1039
|
+
assert.equal(del.affectedRows ?? 0, 0);
|
|
1040
|
+
|
|
1041
|
+
const { rows: after } = await s.db.query<{ c: number }>(
|
|
1042
|
+
`SELECT count(*)::int c FROM audit_event WHERE org_id = $1`,
|
|
1043
|
+
[s.orgA],
|
|
1044
|
+
);
|
|
1045
|
+
assert.equal(Number(after[0]!.c), Number(before[0]!.c));
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
it('a clean chain verifies', async () => {
|
|
1049
|
+
const s = await twoOrgs();
|
|
1050
|
+
await s.fl.share(P(s.alice), s.fileA.id, { subject: { type: 'link' } });
|
|
1051
|
+
await rejects(() => s.fl.read(P(s.mallory), s.fileA.id), 404);
|
|
1052
|
+
const r = await s.fl.verifyAuditChain(P(s.alice), s.orgA);
|
|
1053
|
+
assert.equal(r.valid, true);
|
|
1054
|
+
assert.ok(r.checked >= 3);
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
it('verifyAuditChain detects a forged row inserted out of chain', async () => {
|
|
1058
|
+
const s = await twoOrgs();
|
|
1059
|
+
assert.equal((await s.fl.verifyAuditChain(P(s.alice), s.orgA)).valid, true);
|
|
1060
|
+
|
|
1061
|
+
// The forgery: an attacker with INSERT rights fabricates an "allow" that
|
|
1062
|
+
// never happened. INSERT is not blocked by the append-only rules, so the
|
|
1063
|
+
// chain is the only thing standing between them and a clean history.
|
|
1064
|
+
await s.db.query(
|
|
1065
|
+
`INSERT INTO audit_event (org_id, action, decision, actor_id, file_id, prev_hash, hash)
|
|
1066
|
+
VALUES ($1, 'file.read', 'allow', $2, $3, 'fabricated-prev', 'fabricated-hash')`,
|
|
1067
|
+
[s.orgA, s.mallory, s.fileA.id],
|
|
1068
|
+
);
|
|
1069
|
+
|
|
1070
|
+
const r = await s.fl.verifyAuditChain(P(s.alice), s.orgA);
|
|
1071
|
+
assert.equal(r.valid, false);
|
|
1072
|
+
assert.equal(r.problem, 'prev_hash_mismatch');
|
|
1073
|
+
assert.ok(typeof r.brokenAt === 'number');
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
it('verifyAuditChain detects a DELETED row even when the rule is bypassed', async () => {
|
|
1077
|
+
const s = await twoOrgs();
|
|
1078
|
+
await s.fl.share(P(s.alice), s.fileA.id, { subject: { type: 'link' } });
|
|
1079
|
+
await rejects(() => s.fl.read(P(s.mallory), s.fileA.id), 404);
|
|
1080
|
+
assert.equal((await s.fl.verifyAuditChain(P(s.alice), s.orgA)).valid, true);
|
|
1081
|
+
|
|
1082
|
+
// Simulate an attacker who has reached the database as superuser and can
|
|
1083
|
+
// drop the protective rule. The chain must still catch them.
|
|
1084
|
+
await s.db.query(`ALTER TABLE audit_event DISABLE RULE audit_no_delete`);
|
|
1085
|
+
const { rows } = await s.db.query<{ id: number }>(
|
|
1086
|
+
`SELECT id FROM audit_event WHERE org_id = $1 ORDER BY id ASC OFFSET 1 LIMIT 1`,
|
|
1087
|
+
[s.orgA],
|
|
1088
|
+
);
|
|
1089
|
+
await s.db.query(`DELETE FROM audit_event WHERE id = $1`, [rows[0]!.id]);
|
|
1090
|
+
|
|
1091
|
+
const r = await s.fl.verifyAuditChain(P(s.alice), s.orgA);
|
|
1092
|
+
assert.equal(r.valid, false, 'a deleted event must break the chain');
|
|
1093
|
+
assert.equal(r.problem, 'prev_hash_mismatch');
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
it('verifyAuditChain detects an in-place edit of a chained field', async () => {
|
|
1097
|
+
const s = await twoOrgs();
|
|
1098
|
+
await s.db.query(`ALTER TABLE audit_event DISABLE RULE audit_no_update`);
|
|
1099
|
+
const { rows } = await s.db.query<{ id: number }>(
|
|
1100
|
+
`SELECT id FROM audit_event WHERE org_id = $1 ORDER BY id ASC LIMIT 1`,
|
|
1101
|
+
[s.orgA],
|
|
1102
|
+
);
|
|
1103
|
+
await s.db.query(`UPDATE audit_event SET decision = 'deny' WHERE id = $1`, [rows[0]!.id]);
|
|
1104
|
+
const r = await s.fl.verifyAuditChain(P(s.alice), s.orgA);
|
|
1105
|
+
assert.equal(r.valid, false);
|
|
1106
|
+
assert.equal(r.problem, 'hash_mismatch');
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
it('the chain covers the FORENSIC fields, not just the structural ones', async () => {
|
|
1110
|
+
// The chain used to cover only (prev_hash, org_id, occurred_at, action,
|
|
1111
|
+
// decision, actor_id, file_id). An attacker who could write to the table
|
|
1112
|
+
// could therefore rewrite WHY a denial happened, WHICH grant was used and
|
|
1113
|
+
// FROM WHERE -- the exact fields an incident responder relies on -- and
|
|
1114
|
+
// `verifyAuditChain` still returned valid. That was the defect.
|
|
1115
|
+
//
|
|
1116
|
+
// Every one of those fields is now inside the commitment. Each is edited
|
|
1117
|
+
// independently below, so this cannot pass because of one lucky field.
|
|
1118
|
+
const edits = [
|
|
1119
|
+
`reason = 'routine_maintenance'`,
|
|
1120
|
+
`grant_id = '00000000-0000-0000-0000-0000000000ff'`,
|
|
1121
|
+
`ip = '10.0.0.1'`,
|
|
1122
|
+
`user_agent = 'not-the-real-agent'`,
|
|
1123
|
+
`context = '{"note":"nothing to see"}'::jsonb`,
|
|
1124
|
+
];
|
|
1125
|
+
|
|
1126
|
+
for (const edit of edits) {
|
|
1127
|
+
const s = await twoOrgs();
|
|
1128
|
+
await rejects(() => s.fl.read(P(s.mallory), s.fileA.id), 404);
|
|
1129
|
+
assert.equal((await s.fl.verifyAuditChain(P(s.alice), s.orgA)).valid, true);
|
|
1130
|
+
|
|
1131
|
+
await s.db.query(`ALTER TABLE audit_event DISABLE RULE audit_no_update`);
|
|
1132
|
+
const { rows } = await s.db.query<{ id: number }>(
|
|
1133
|
+
`SELECT id FROM audit_event WHERE org_id = $1 AND decision = 'deny' ORDER BY id LIMIT 1`,
|
|
1134
|
+
[s.orgA],
|
|
1135
|
+
);
|
|
1136
|
+
await s.db.query(`UPDATE audit_event SET ${edit} WHERE id = $1`, [rows[0]!.id]);
|
|
1137
|
+
|
|
1138
|
+
const r = await s.fl.verifyAuditChain(P(s.alice), s.orgA);
|
|
1139
|
+
assert.equal(r.valid, false, `an edit to ${edit} must break the chain`);
|
|
1140
|
+
assert.equal(r.problem, 'hash_mismatch');
|
|
1141
|
+
assert.equal(r.brokenAt, rows[0]!.id);
|
|
1142
|
+
}
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
it('the hash is a real commitment: recomputing with ANY covered field changed differs', async () => {
|
|
1146
|
+
const base = {
|
|
1147
|
+
prevHash: null,
|
|
1148
|
+
orgId: '00000000-0000-0000-0000-000000000001',
|
|
1149
|
+
occurredAt: new Date('2026-09-05T00:00:00.000Z'),
|
|
1150
|
+
action: 'file.read',
|
|
1151
|
+
decision: 'allow',
|
|
1152
|
+
reason: null,
|
|
1153
|
+
actorId: '00000000-0000-0000-0000-000000000002',
|
|
1154
|
+
fileId: '00000000-0000-0000-0000-000000000003',
|
|
1155
|
+
grantId: null,
|
|
1156
|
+
ip: null,
|
|
1157
|
+
userAgent: null,
|
|
1158
|
+
context: {},
|
|
1159
|
+
};
|
|
1160
|
+
const h = auditHash(base);
|
|
1161
|
+
assert.match(h, /^[0-9a-f]{64}$/);
|
|
1162
|
+
assert.notEqual(h, auditHash({ ...base, decision: 'deny' }));
|
|
1163
|
+
assert.notEqual(h, auditHash({ ...base, action: 'file.delete' }));
|
|
1164
|
+
assert.notEqual(h, auditHash({ ...base, prevHash: 'x' }));
|
|
1165
|
+
assert.notEqual(h, auditHash({ ...base, occurredAt: new Date('2026-09-05T00:00:01.000Z') }));
|
|
1166
|
+
assert.notEqual(h, auditHash({ ...base, reason: 'no_membership' }));
|
|
1167
|
+
assert.notEqual(h, auditHash({ ...base, grantId: base.orgId }));
|
|
1168
|
+
assert.notEqual(h, auditHash({ ...base, ip: '10.0.0.1' }));
|
|
1169
|
+
assert.notEqual(h, auditHash({ ...base, userAgent: 'curl' }));
|
|
1170
|
+
assert.notEqual(h, auditHash({ ...base, context: { via: 'role' } }));
|
|
1171
|
+
assert.equal(h, auditHash({ ...base }));
|
|
1172
|
+
|
|
1173
|
+
// ...but it is stable across jsonb key reordering, or the chain would break
|
|
1174
|
+
// on its own round trip through the database rather than on an attack.
|
|
1175
|
+
assert.equal(
|
|
1176
|
+
auditHash({ ...base, context: { a: 1, b: { y: 2, x: 3 } } }),
|
|
1177
|
+
auditHash({ ...base, context: { b: { x: 3, y: 2 }, a: 1 } }),
|
|
1178
|
+
);
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
it('every org has its own chain: activity in org B cannot be used to explain org A', async () => {
|
|
1182
|
+
const s = await twoOrgs();
|
|
1183
|
+
await s.fl.share(P(s.bob), s.fileB.id, { subject: { type: 'link' } });
|
|
1184
|
+
const a = await s.fl.verifyAuditChain(P(s.alice), s.orgA);
|
|
1185
|
+
const b = await s.fl.verifyAuditChain(P(s.bob), s.orgB);
|
|
1186
|
+
assert.equal(a.valid, true);
|
|
1187
|
+
assert.equal(b.valid, true);
|
|
1188
|
+
const { rows } = await s.db.query<{ org_id: string; prev_hash: string | null }>(
|
|
1189
|
+
`SELECT org_id, prev_hash FROM audit_event ORDER BY id`,
|
|
1190
|
+
);
|
|
1191
|
+
// The first event of each org starts a fresh chain.
|
|
1192
|
+
const firsts = new Map<string, string | null>();
|
|
1193
|
+
for (const r of rows) if (!firsts.has(r.org_id)) firsts.set(r.org_id, r.prev_hash);
|
|
1194
|
+
for (const [, prev] of firsts) assert.equal(prev, null);
|
|
1195
|
+
});
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
// =============================================================================
|
|
1199
|
+
// 13. ERRORS DO NOT LEAK EXISTENCE
|
|
1200
|
+
// =============================================================================
|
|
1201
|
+
|
|
1202
|
+
describe('PROPERTY 13: error responses are indistinguishable across "absent" and "not yours"', () => {
|
|
1203
|
+
it('a nonexistent file and another tenant’s file both return 404 not_found', async () => {
|
|
1204
|
+
const s = await twoOrgs();
|
|
1205
|
+
const ghost = crypto.randomUUID();
|
|
1206
|
+
|
|
1207
|
+
const e1 = await rejects(() => s.fl.read(P(s.alice), ghost), 404);
|
|
1208
|
+
const e2 = await rejects(() => s.fl.read(P(s.alice), s.fileB.id), 404);
|
|
1209
|
+
assert.equal(e1.code, e2.code);
|
|
1210
|
+
assert.equal(e1.status, e2.status);
|
|
1211
|
+
assert.equal(e1.message, e2.message);
|
|
1212
|
+
|
|
1213
|
+
// ...but the audit log knows the difference internally.
|
|
1214
|
+
const bLog = await s.fl.auditLog(P(s.bob), s.orgB, { decision: 'deny' });
|
|
1215
|
+
assert.ok(bLog.some((e) => e.fileId === s.fileB.id && e.reason === 'no_membership'));
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
it('an EXPIRED file in another tenant does not leak its existence via 410', async () => {
|
|
1219
|
+
// The naive implementation leaks here: authz.ts evaluates the file-level
|
|
1220
|
+
// expiry gate before establishing standing, and toPublicError maps
|
|
1221
|
+
// file_expired to 410 Gone. 410 vs 404 is a yes/no oracle for "does this
|
|
1222
|
+
// file id exist".
|
|
1223
|
+
const s = await twoOrgs();
|
|
1224
|
+
await s.db.query(`UPDATE file SET expires_at = now() - interval '1 second' WHERE id = $1`, [
|
|
1225
|
+
s.fileB.id,
|
|
1226
|
+
]);
|
|
1227
|
+
const ghost = crypto.randomUUID();
|
|
1228
|
+
|
|
1229
|
+
const e1 = await rejects(() => s.fl.read(P(s.alice), ghost), 404);
|
|
1230
|
+
const e2 = await rejects(() => s.fl.read(P(s.alice), s.fileB.id), 404);
|
|
1231
|
+
const e3 = await rejects(() => s.fl.read(P(null), s.fileB.id), 404);
|
|
1232
|
+
assert.equal(e1.code, e2.code);
|
|
1233
|
+
assert.equal(e2.code, e3.code);
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
it('a RETENTION-HELD file in another tenant does not leak its existence via 409', async () => {
|
|
1237
|
+
const s = await twoOrgs();
|
|
1238
|
+
await s.db.query(`UPDATE file SET retain_until = now() + interval '1 hour' WHERE id = $1`, [
|
|
1239
|
+
s.fileB.id,
|
|
1240
|
+
]);
|
|
1241
|
+
const ghost = crypto.randomUUID();
|
|
1242
|
+
const e1 = await rejects(() => s.fl.delete(P(s.alice), ghost), 404);
|
|
1243
|
+
const e2 = await rejects(() => s.fl.delete(P(s.alice), s.fileB.id), 404);
|
|
1244
|
+
assert.equal(e1.code, e2.code);
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
it('the ENGINE itself does not leak: standing is established before any lifecycle gate', async () => {
|
|
1248
|
+
// This is the test that matters, because it is about the engine rather
|
|
1249
|
+
// than the API wrapper. `authorize()` used to evaluate the file-level
|
|
1250
|
+
// lifecycle gates before establishing that the caller had any standing, and
|
|
1251
|
+
// `toPublicError` maps file_expired to 410 Gone -- so an unauthenticated
|
|
1252
|
+
// caller who guessed a file id got 410 for a real file and 404 for a fake
|
|
1253
|
+
// one. `Filelayer.raiseIfDenied` compensated for that, which meant any
|
|
1254
|
+
// second entry point built on authorize() reintroduced the oracle.
|
|
1255
|
+
//
|
|
1256
|
+
// The compensation is gone; the evaluation order is fixed instead.
|
|
1257
|
+
const { toPublicError } = await import('../src/authz.ts');
|
|
1258
|
+
const s = await twoOrgs();
|
|
1259
|
+
await s.db.query(`UPDATE file SET expires_at = now() - interval '1 second' WHERE id = $1`, [
|
|
1260
|
+
s.fileB.id,
|
|
1261
|
+
]);
|
|
1262
|
+
await s.db.query(`UPDATE file SET retain_until = now() + interval '1 hour' WHERE id = $1`, [
|
|
1263
|
+
s.fileA.id,
|
|
1264
|
+
]);
|
|
1265
|
+
|
|
1266
|
+
const status = async (p: Principal, id: string, cap: Capability = 'read') => {
|
|
1267
|
+
const d = await authorize(s.fl.store, p, id, cap);
|
|
1268
|
+
assert.equal(d.allow, false);
|
|
1269
|
+
return toPublicError((d as Extract<typeof d, { allow: false }>).reason).status;
|
|
1270
|
+
};
|
|
1271
|
+
|
|
1272
|
+
// Expired file in another tenant vs. a file id that never existed.
|
|
1273
|
+
assert.equal(await status(P(s.alice), s.fileB.id), 404);
|
|
1274
|
+
assert.equal(await status(P(s.alice), crypto.randomUUID()), 404);
|
|
1275
|
+
assert.equal(await status(P(null), s.fileB.id), 404);
|
|
1276
|
+
|
|
1277
|
+
// Retention hold, probed by someone with no standing: still 404, not 409.
|
|
1278
|
+
assert.equal(await status(P(s.bob), s.fileA.id, 'delete'), 404);
|
|
1279
|
+
assert.equal(await status(P(s.mallory), s.fileA.id, 'delete'), 404);
|
|
1280
|
+
|
|
1281
|
+
// ...and an org member with no capability on a private file learns nothing
|
|
1282
|
+
// more than a total stranger does.
|
|
1283
|
+
assert.equal(await status(P(s.anna), s.fileA.id, 'delete'), 404);
|
|
1284
|
+
|
|
1285
|
+
// The statuses that are NOT 404 remain available to those entitled to them,
|
|
1286
|
+
// so this is not passing by collapsing everything into one answer.
|
|
1287
|
+
const owner = await authorize(s.fl.store, P(s.alice), s.fileA.id, 'delete');
|
|
1288
|
+
assert.equal(owner.allow, false);
|
|
1289
|
+
assert.equal(
|
|
1290
|
+
toPublicError((owner as Extract<typeof owner, { allow: false }>).reason).status,
|
|
1291
|
+
409,
|
|
1292
|
+
);
|
|
1293
|
+
const ownerB = await authorize(s.fl.store, P(s.bob), s.fileB.id, 'read');
|
|
1294
|
+
assert.equal(
|
|
1295
|
+
toPublicError((ownerB as Extract<typeof ownerB, { allow: false }>).reason).status,
|
|
1296
|
+
410,
|
|
1297
|
+
);
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
it('revoke() is not a grant-id oracle', async () => {
|
|
1301
|
+
const s = await twoOrgs();
|
|
1302
|
+
const share = await s.fl.share(P(s.bob), s.fileB.id, { subject: { type: 'link' } });
|
|
1303
|
+
const e1 = await rejects(() => s.fl.revoke(P(s.alice), share.grantId), 404);
|
|
1304
|
+
const e2 = await rejects(() => s.fl.revoke(P(s.alice), crypto.randomUUID()), 404);
|
|
1305
|
+
assert.equal(e1.code, e2.code);
|
|
1306
|
+
// ...and the grant still works for its legitimate holder afterwards.
|
|
1307
|
+
assert.equal(text((await s.fl.redeem(share.secret!)).body), 'INITECH PAYROLL');
|
|
1308
|
+
});
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
// =============================================================================
|
|
1312
|
+
// 15. DELEGATION -- the findings that came out of building this
|
|
1313
|
+
// =============================================================================
|
|
1314
|
+
|
|
1315
|
+
describe('PROPERTY 15: delegation cannot amplify or outlive the permission it came from', () => {
|
|
1316
|
+
it('a principal cannot mint a grant carrying a capability they do not hold', async () => {
|
|
1317
|
+
const s = await twoOrgs();
|
|
1318
|
+
const viewer = (await s.fl.createActor('delegate-viewer')).id;
|
|
1319
|
+
await s.fl.addMember(P(s.alice), s.orgA, viewer, 'viewer');
|
|
1320
|
+
|
|
1321
|
+
// The viewer is given exactly one capability: share.
|
|
1322
|
+
await s.fl.share(P(s.alice), s.fileA.id, {
|
|
1323
|
+
subject: { type: 'actor', actorId: viewer },
|
|
1324
|
+
capabilities: ['share'],
|
|
1325
|
+
});
|
|
1326
|
+
await rejects(() => s.fl.delete(P(viewer), s.fileA.id), 404);
|
|
1327
|
+
|
|
1328
|
+
// Without attenuation, this is a one-step escalation from "may pass this
|
|
1329
|
+
// on" to "may destroy it": the viewer mints themselves a delete grant.
|
|
1330
|
+
await rejects(
|
|
1331
|
+
() =>
|
|
1332
|
+
s.fl.share(P(viewer), s.fileA.id, {
|
|
1333
|
+
subject: { type: 'actor', actorId: viewer },
|
|
1334
|
+
capabilities: ['read', 'write', 'delete', 'share'],
|
|
1335
|
+
}),
|
|
1336
|
+
403,
|
|
1337
|
+
'forbidden',
|
|
1338
|
+
);
|
|
1339
|
+
|
|
1340
|
+
// ...and the file is still there.
|
|
1341
|
+
await rejects(() => s.fl.delete(P(viewer), s.fileA.id), 404);
|
|
1342
|
+
assert.equal(text((await s.fl.read(P(s.alice), s.fileA.id)).body), 'ACME CONFIDENTIAL');
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
it('the DATABASE refuses an amplifying child even when the engine is bypassed', async () => {
|
|
1346
|
+
// The rule used to live only in `filelayer.share()`, which meant a psql
|
|
1347
|
+
// session, a migration or a second endpoint bypassed it entirely. It is now
|
|
1348
|
+
// enforced on the row.
|
|
1349
|
+
const s = await twoOrgs();
|
|
1350
|
+
const viewer = (await s.fl.createActor('sql-viewer')).id;
|
|
1351
|
+
const parent = await s.fl.share(P(s.alice), s.fileA.id, {
|
|
1352
|
+
subject: { type: 'actor', actorId: viewer },
|
|
1353
|
+
capabilities: ['read', 'share'],
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
await dbRejects(
|
|
1357
|
+
s.db,
|
|
1358
|
+
`INSERT INTO file_grant (file_id, org_id, parent_grant_id, subject_type, subject_id, capabilities)
|
|
1359
|
+
VALUES ($1, $2, $3, 'actor', $4, ARRAY['read','delete']::grant_capability[])`,
|
|
1360
|
+
[s.fileA.id, s.orgA, parent.grantId, viewer],
|
|
1361
|
+
/grant_capability_amplification/,
|
|
1362
|
+
);
|
|
1363
|
+
|
|
1364
|
+
// Negative control: the same INSERT with an attenuated capability set is
|
|
1365
|
+
// accepted, so the rejection is caused by amplification and not by a
|
|
1366
|
+
// malformed statement.
|
|
1367
|
+
await s.db.query(
|
|
1368
|
+
`INSERT INTO file_grant (file_id, org_id, parent_grant_id, subject_type, subject_id, capabilities)
|
|
1369
|
+
VALUES ($1, $2, $3, 'actor', $4, ARRAY['read']::grant_capability[])`,
|
|
1370
|
+
[s.fileA.id, s.orgA, parent.grantId, viewer],
|
|
1371
|
+
);
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
it('attenuation is not over-broad: a granter CAN pass on what they do hold', async () => {
|
|
1375
|
+
const s = await twoOrgs();
|
|
1376
|
+
const contractor = (await s.fl.createActor('contractor')).id;
|
|
1377
|
+
const g = await s.fl.share(P(s.alice), s.fileA.id, {
|
|
1378
|
+
subject: { type: 'actor', actorId: contractor },
|
|
1379
|
+
capabilities: ['read', 'share'],
|
|
1380
|
+
});
|
|
1381
|
+
assert.ok(g.grantId);
|
|
1382
|
+
const onward = await s.fl.share(P(contractor), s.fileA.id, {
|
|
1383
|
+
subject: { type: 'link' },
|
|
1384
|
+
capabilities: ['read'],
|
|
1385
|
+
});
|
|
1386
|
+
assert.equal(text((await s.fl.redeem(onward.secret!)).body), 'ACME CONFIDENTIAL');
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
it('a delegated grant dies with the grant that created it (P4, transitive)', async () => {
|
|
1390
|
+
// P4: "a signed URL may never outlive the permission that created it."
|
|
1391
|
+
//
|
|
1392
|
+
// It used to. A grant carrying `share` let its holder mint a SECOND grant
|
|
1393
|
+
// with no expiry, no download cap and no link back to the first; revoking
|
|
1394
|
+
// the first left the second working, because `live_grant` had no notion of
|
|
1395
|
+
// a parent and `file_grant` had no parent_grant_id.
|
|
1396
|
+
//
|
|
1397
|
+
// Concretely: a contractor is given a 60-second, single-use, shareable
|
|
1398
|
+
// authority. They pass it on. You revoke theirs. Everything they issued
|
|
1399
|
+
// dies in the same instant.
|
|
1400
|
+
const s = await twoOrgs();
|
|
1401
|
+
const contractor = (await s.fl.createActor('f7-contractor')).id;
|
|
1402
|
+
|
|
1403
|
+
const parent = await s.fl.share(P(s.alice), s.fileA.id, {
|
|
1404
|
+
subject: { type: 'actor', actorId: contractor },
|
|
1405
|
+
capabilities: ['read', 'share'],
|
|
1406
|
+
expiresIn: 60,
|
|
1407
|
+
maxDownloads: 3,
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
const child = await s.fl.share(P(contractor), s.fileA.id, {
|
|
1411
|
+
subject: { type: 'link' },
|
|
1412
|
+
capabilities: ['read'],
|
|
1413
|
+
// Asks for forever, and for unlimited downloads.
|
|
1414
|
+
});
|
|
1415
|
+
|
|
1416
|
+
// ...and is given the parent's remaining lifetime and budget instead.
|
|
1417
|
+
assert.equal(child.parentGrantId, parent.grantId, 'the lineage is recorded');
|
|
1418
|
+
assert.ok(child.expiresAt, 'the child inherits the parent expiry');
|
|
1419
|
+
assert.equal(child.expiresAt!.getTime(), parent.expiresAt!.getTime());
|
|
1420
|
+
assert.equal(child.maxDownloads, 3, 'the child inherits the parent budget');
|
|
1421
|
+
|
|
1422
|
+
// It works while the parent is alive...
|
|
1423
|
+
assert.equal(text((await s.fl.redeem(child.secret!)).body), 'ACME CONFIDENTIAL');
|
|
1424
|
+
|
|
1425
|
+
// ...and stops the instant the parent is revoked. No cascading write, no
|
|
1426
|
+
// background job: liveness is evaluated over the chain.
|
|
1427
|
+
await s.fl.revoke(P(s.alice), parent.grantId);
|
|
1428
|
+
await rejects(() => s.fl.read(P(contractor), s.fileA.id), 404);
|
|
1429
|
+
await rejects(() => s.fl.redeem(child.secret!), 404);
|
|
1430
|
+
|
|
1431
|
+
// The view agrees, so this is not an artifact of the API layer.
|
|
1432
|
+
const live = await s.db.query(`SELECT id FROM live_grant WHERE id = ANY($1::uuid[])`, [
|
|
1433
|
+
`{${parent.grantId},${child.grantId}}`,
|
|
1434
|
+
]);
|
|
1435
|
+
assert.equal(live.rows.length, 0);
|
|
1436
|
+
// ...and both rows still exist, for the audit trail.
|
|
1437
|
+
const raw = await s.db.query(`SELECT id FROM file_grant WHERE id = ANY($1::uuid[])`, [
|
|
1438
|
+
`{${parent.grantId},${child.grantId}}`,
|
|
1439
|
+
]);
|
|
1440
|
+
assert.equal(raw.rows.length, 2);
|
|
1441
|
+
|
|
1442
|
+
// An operator listing what has been shared sees the child as dead, and can
|
|
1443
|
+
// see what killed it.
|
|
1444
|
+
const grants = await s.fl.listGrants(P(s.alice), s.fileA.id);
|
|
1445
|
+
const orphan = grants.find((g) => g.id === child.grantId)!;
|
|
1446
|
+
assert.equal(orphan.live, false);
|
|
1447
|
+
assert.equal(orphan.revokedAt, null, 'it was never revoked itself');
|
|
1448
|
+
assert.equal(orphan.parentGrantId, parent.grantId);
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
it('a child cannot outlive its parent even when it asks for longer', async () => {
|
|
1452
|
+
const s = await twoOrgs();
|
|
1453
|
+
const contractor = (await s.fl.createActor('f7-clamp')).id;
|
|
1454
|
+
const parent = await s.fl.share(P(s.alice), s.fileA.id, {
|
|
1455
|
+
subject: { type: 'actor', actorId: contractor },
|
|
1456
|
+
capabilities: ['read', 'share'],
|
|
1457
|
+
expiresIn: 60,
|
|
1458
|
+
maxDownloads: 2,
|
|
1459
|
+
});
|
|
1460
|
+
const child = await s.fl.share(P(contractor), s.fileA.id, {
|
|
1461
|
+
subject: { type: 'link' },
|
|
1462
|
+
capabilities: ['read'],
|
|
1463
|
+
expiresIn: 86400 * 365,
|
|
1464
|
+
maxDownloads: 1000,
|
|
1465
|
+
});
|
|
1466
|
+
assert.equal(child.expiresAt!.getTime(), parent.expiresAt!.getTime());
|
|
1467
|
+
assert.equal(child.maxDownloads, 2);
|
|
1468
|
+
|
|
1469
|
+
// The budget is shared, not duplicated: spending it through the child
|
|
1470
|
+
// spends it for the parent too, so two children cannot sell it twice.
|
|
1471
|
+
await s.fl.redeem(child.secret!);
|
|
1472
|
+
await s.fl.redeem(child.secret!);
|
|
1473
|
+
await rejects(() => s.fl.redeem(child.secret!), 404);
|
|
1474
|
+
const { rows } = await s.db.query<{ download_count: number }>(
|
|
1475
|
+
`SELECT download_count FROM file_grant WHERE id = $1`,
|
|
1476
|
+
[parent.grantId],
|
|
1477
|
+
);
|
|
1478
|
+
assert.equal(Number(rows[0]!.download_count), 2, 'the parent was charged for both');
|
|
1479
|
+
await rejects(() => s.fl.read(P(contractor), s.fileA.id), 404);
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1482
|
+
it('a link grant can never carry delete: the DATABASE says so', async () => {
|
|
1483
|
+
// A share link is a bearer credential that travels through mail clients,
|
|
1484
|
+
// chat logs and browser history. Anything it carries beyond `read` turns a
|
|
1485
|
+
// disclosure into a destruction. Only `anonymous` used to be constrained.
|
|
1486
|
+
const s = await twoOrgs();
|
|
1487
|
+
for (const caps of [['delete'], ['read', 'delete'], ['read', 'write'], ['read', 'share']]) {
|
|
1488
|
+
await dbRejects(
|
|
1489
|
+
s.db,
|
|
1490
|
+
`INSERT INTO file_grant (file_id, org_id, subject_type, capabilities, secret_hash)
|
|
1491
|
+
VALUES ($1, $2, 'link', $3::grant_capability[], 'deadbeef')`,
|
|
1492
|
+
[s.fileA.id, s.orgA, `{${caps.join(',')}}`],
|
|
1493
|
+
/grant_link_read_only/,
|
|
1494
|
+
);
|
|
1495
|
+
}
|
|
1496
|
+
// The API surface cannot smuggle it past either.
|
|
1497
|
+
await assert.rejects(
|
|
1498
|
+
() =>
|
|
1499
|
+
s.fl.share(P(s.alice), s.fileA.id, {
|
|
1500
|
+
subject: { type: 'link' },
|
|
1501
|
+
capabilities: ['read', 'delete'],
|
|
1502
|
+
}),
|
|
1503
|
+
/grant_link_read_only/,
|
|
1504
|
+
);
|
|
1505
|
+
// Positive control, so the constraint is not vacuous.
|
|
1506
|
+
const ok = await s.fl.share(P(s.alice), s.fileA.id, {
|
|
1507
|
+
subject: { type: 'link' },
|
|
1508
|
+
capabilities: ['read'],
|
|
1509
|
+
});
|
|
1510
|
+
assert.equal(text((await s.fl.redeem(ok.secret!)).body), 'ACME CONFIDENTIAL');
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
it('membership management is authorized and audited like everything else', async () => {
|
|
1514
|
+
// `addMember` used to take no principal at all: any code path that reached
|
|
1515
|
+
// it could make anyone an owner of any org, and nothing was written to the
|
|
1516
|
+
// audit log. It was the single largest security-sensitive decision left to
|
|
1517
|
+
// the developer.
|
|
1518
|
+
const s = await twoOrgs();
|
|
1519
|
+
|
|
1520
|
+
// An outsider cannot let themselves in...
|
|
1521
|
+
await rejects(() => s.fl.addMember(P(s.mallory), s.orgB, s.mallory, 'owner'), 404);
|
|
1522
|
+
await rejects(() => s.fl.read(P(s.mallory), s.fileB.id), 404);
|
|
1523
|
+
// ...nor can a member of the org, nor an unauthenticated caller.
|
|
1524
|
+
await rejects(() => s.fl.addMember(P(s.anna), s.orgA, s.mallory, 'owner'), 404);
|
|
1525
|
+
await rejects(() => s.fl.addMember(P(null), s.orgA, s.mallory, 'owner'), 404);
|
|
1526
|
+
// ...nor can the owner of a DIFFERENT org.
|
|
1527
|
+
await rejects(() => s.fl.addMember(P(s.bob), s.orgA, s.mallory, 'owner'), 404);
|
|
1528
|
+
|
|
1529
|
+
// The org's own owner can, and it is on the record.
|
|
1530
|
+
await s.fl.addMember(P(s.alice), s.orgA, s.mallory, 'member');
|
|
1531
|
+
const events = await s.fl.auditLog(P(s.alice), s.orgA, {});
|
|
1532
|
+
const added = events.filter((e) => e.action === 'member.add' && e.decision === 'allow');
|
|
1533
|
+
assert.equal(added.length, 2, 'anna at setup, mallory just now');
|
|
1534
|
+
const last = added[added.length - 1]!;
|
|
1535
|
+
assert.equal(last.decision, 'allow');
|
|
1536
|
+
assert.equal(last.actorId, s.alice);
|
|
1537
|
+
assert.equal(last.context['targetActorId'], s.mallory);
|
|
1538
|
+
assert.equal(last.context['toRole'], 'member');
|
|
1539
|
+
|
|
1540
|
+
// Refusals are recorded too -- that is the half that evidences an attempt.
|
|
1541
|
+
const denied = events.filter((e) => e.action === 'member.add' && e.decision === 'deny');
|
|
1542
|
+
assert.ok(denied.length >= 2, `expected refused membership changes on the record`);
|
|
1543
|
+
assert.ok(denied.every((e) => e.reason === 'insufficient_role' || e.reason === 'no_membership'));
|
|
1544
|
+
|
|
1545
|
+
// A role change is a distinct action, not an indistinguishable upsert.
|
|
1546
|
+
await s.fl.addMember(P(s.alice), s.orgA, s.mallory, 'admin');
|
|
1547
|
+
const changes = (await s.fl.auditLog(P(s.alice), s.orgA, { action: 'member.role_change' }));
|
|
1548
|
+
assert.equal(changes.length, 1);
|
|
1549
|
+
assert.equal(changes[0]!.context['fromRole'], 'member');
|
|
1550
|
+
assert.equal(changes[0]!.context['toRole'], 'admin');
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1553
|
+
it('an admin cannot mint an owner, nor touch one, nor orphan the org', async () => {
|
|
1554
|
+
const s = await twoOrgs();
|
|
1555
|
+
const admin = (await s.fl.createActor('an-admin')).id;
|
|
1556
|
+
await s.fl.addMember(P(s.alice), s.orgA, admin, 'admin');
|
|
1557
|
+
|
|
1558
|
+
// Privilege escalation by proxy: an admin who can create owners is an
|
|
1559
|
+
// owner with an extra step.
|
|
1560
|
+
await rejects(() => s.fl.addMember(P(admin), s.orgA, s.mallory, 'owner'), 403, 'forbidden');
|
|
1561
|
+
await rejects(() => s.fl.addMember(P(admin), s.orgA, admin, 'owner'), 403);
|
|
1562
|
+
// ...and they cannot demote or remove the person above them.
|
|
1563
|
+
await rejects(() => s.fl.addMember(P(admin), s.orgA, s.alice, 'viewer'), 403);
|
|
1564
|
+
await rejects(() => s.fl.removeMember(P(admin), s.orgA, s.alice), 403);
|
|
1565
|
+
// An admin CAN manage roles at or below their own.
|
|
1566
|
+
await s.fl.addMember(P(admin), s.orgA, s.mallory, 'member');
|
|
1567
|
+
await s.fl.removeMember(P(admin), s.orgA, s.mallory);
|
|
1568
|
+
|
|
1569
|
+
// The last owner cannot walk out of the org and leave it unadministrable.
|
|
1570
|
+
await rejects(() => s.fl.removeMember(P(s.alice), s.orgA, s.alice), 403, 'forbidden');
|
|
1571
|
+
await rejects(() => s.fl.addMember(P(s.alice), s.orgA, s.alice, 'member'), 403);
|
|
1572
|
+
|
|
1573
|
+
const denials = await s.fl.auditLog(P(s.alice), s.orgA, { decision: 'deny' });
|
|
1574
|
+
for (const reason of ['role_escalation', 'superior_target', 'last_owner']) {
|
|
1575
|
+
assert.ok(
|
|
1576
|
+
denials.some((e) => e.reason === reason),
|
|
1577
|
+
`missing membership deny reason ${reason}`,
|
|
1578
|
+
);
|
|
1579
|
+
}
|
|
1580
|
+
});
|
|
1581
|
+
|
|
1582
|
+
it('a redemption refused by the download cap IS audited', async () => {
|
|
1583
|
+
// Once download_count reached max_downloads the grant left `live_grant`, so
|
|
1584
|
+
// `findLiveGrantBySecret` returned null and `redeem` threw before
|
|
1585
|
+
// `authorize` was ever called: "my link stopped working" was invisible in
|
|
1586
|
+
// the compliance log. Same root cause as the unaudited enumeration sweep --
|
|
1587
|
+
// liveness filtering happened before attribution.
|
|
1588
|
+
const s = await twoOrgs();
|
|
1589
|
+
const share = await s.fl.share(P(s.alice), s.fileA.id, {
|
|
1590
|
+
subject: { type: 'link' },
|
|
1591
|
+
maxDownloads: 1,
|
|
1592
|
+
});
|
|
1593
|
+
await s.fl.redeem(share.secret!);
|
|
1594
|
+
const before = await countAudit(s.db, s.orgA);
|
|
1595
|
+
for (let i = 0; i < 5; i++) await rejects(() => s.fl.redeem(share.secret!), 404);
|
|
1596
|
+
assert.equal(await countAudit(s.db, s.orgA), before + 5, 'five refusals, five events');
|
|
1597
|
+
|
|
1598
|
+
const denials = await s.fl.auditLog(P(s.alice), s.orgA, { decision: 'deny' });
|
|
1599
|
+
const exhausted = denials.filter((e) => e.reason === 'grant_exhausted');
|
|
1600
|
+
assert.equal(exhausted.length, 5);
|
|
1601
|
+
assert.ok(exhausted.every((e) => e.grantId === share.grantId));
|
|
1602
|
+
});
|
|
1603
|
+
|
|
1604
|
+
it('a revoked or expired link is audited with the reason, not as a forgery', async () => {
|
|
1605
|
+
const s = await twoOrgs();
|
|
1606
|
+
const revoked = await s.fl.share(P(s.alice), s.fileA.id, { subject: { type: 'link' } });
|
|
1607
|
+
const expired = await s.fl.share(P(s.alice), s.fileA.id, { subject: { type: 'link' } });
|
|
1608
|
+
await s.fl.revoke(P(s.alice), revoked.grantId);
|
|
1609
|
+
await s.db.query(`UPDATE file_grant SET expires_at = now() - interval '1s' WHERE id = $1`, [
|
|
1610
|
+
expired.grantId,
|
|
1611
|
+
]);
|
|
1612
|
+
|
|
1613
|
+
await rejects(() => s.fl.redeem(revoked.secret!), 404);
|
|
1614
|
+
await rejects(() => s.fl.redeem(expired.secret!), 404);
|
|
1615
|
+
|
|
1616
|
+
const reasons = (await s.fl.auditLog(P(s.alice), s.orgA, { decision: 'deny' })).map(
|
|
1617
|
+
(e) => e.reason,
|
|
1618
|
+
);
|
|
1619
|
+
assert.ok(reasons.includes('grant_revoked'), `got ${reasons}`);
|
|
1620
|
+
assert.ok(reasons.includes('grant_expired'), `got ${reasons}`);
|
|
1621
|
+
// The caller still cannot tell any of these apart from a forged secret.
|
|
1622
|
+
const e1 = await rejects(() => s.fl.redeem(revoked.secret!), 404);
|
|
1623
|
+
const e2 = await rejects(() => s.fl.redeem('completely-made-up'), 404);
|
|
1624
|
+
assert.equal(e1.code, e2.code);
|
|
1625
|
+
});
|
|
1626
|
+
});
|
|
1627
|
+
|
|
1628
|
+
// =============================================================================
|
|
1629
|
+
// 14. LIFECYCLE / STATE
|
|
1630
|
+
// =============================================================================
|
|
1631
|
+
|
|
1632
|
+
describe('PROPERTY 14: deleted files are unreachable by every path', () => {
|
|
1633
|
+
it('after delete, owner, admin, link holder and anonymous grant all get 404', async () => {
|
|
1634
|
+
const s = await twoOrgs();
|
|
1635
|
+
const link = await s.fl.share(P(s.alice), s.fileA.id, { subject: { type: 'link' } });
|
|
1636
|
+
await s.fl.share(P(s.alice), s.fileA.id, { subject: { type: 'anonymous' } });
|
|
1637
|
+
|
|
1638
|
+
await s.fl.delete(P(s.alice), s.fileA.id);
|
|
1639
|
+
|
|
1640
|
+
await rejects(() => s.fl.read(P(s.alice), s.fileA.id), 404);
|
|
1641
|
+
await rejects(() => s.fl.read(P(s.anna), s.fileA.id), 404);
|
|
1642
|
+
await rejects(() => s.fl.redeem(link.secret!), 404);
|
|
1643
|
+
await rejects(() => s.fl.read(P(null), s.fileA.id), 404);
|
|
1644
|
+
assert.equal(s.storage.keys().includes(s.fileA.storageKey), false);
|
|
1645
|
+
});
|
|
1646
|
+
|
|
1647
|
+
it('a pending (not yet uploaded) file is not readable', async () => {
|
|
1648
|
+
const s = await twoOrgs();
|
|
1649
|
+
await s.db.query(`UPDATE file SET state = 'pending' WHERE id = $1`, [s.fileA.id]);
|
|
1650
|
+
await rejects(() => s.fl.read(P(s.alice), s.fileA.id), 404);
|
|
1651
|
+
});
|
|
1652
|
+
});
|