@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,888 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SEMANTICS SUITE -- deletion, lifecycle, revocation, metering, ingest.
|
|
3
|
+
*
|
|
4
|
+
* Every test in this file encodes a DECISION, not an observation. Where a
|
|
5
|
+
* question was previously an open ambiguity -- what a soft delete reaches, what
|
|
6
|
+
* `maxDownloads` counts, whether the audit chain can fork, whose id space
|
|
7
|
+
* `external_id` lives in, what the ingest boundary owns -- the test is named for
|
|
8
|
+
* the answer we chose, and its comment says why that answer and not the other
|
|
9
|
+
* one. If someone later changes the behaviour, the test that goes red should
|
|
10
|
+
* tell them which product decision they are reversing.
|
|
11
|
+
*
|
|
12
|
+
* The prose companion is SEMANTICS.md. This file is the enforceable copy.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it } from 'node:test';
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
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 { auditHash, auditHashTail, DEFAULT_PROJECT_ID } from '../src/store.ts';
|
|
22
|
+
import { newWorld, bytes, rejects, dbRejects, countAudit } from './helpers.ts';
|
|
23
|
+
|
|
24
|
+
// -----------------------------------------------------------------------------
|
|
25
|
+
// A tenant with one owner, one member, one outsider, one private file, and two
|
|
26
|
+
// live grants over it: a link and an actor grant. An anonymous grant is opt-in,
|
|
27
|
+
// because a file that anyone may read makes most denial assertions vacuous.
|
|
28
|
+
// -----------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
async function tenant(opts: { anonymous?: boolean } = {}) {
|
|
31
|
+
const w = await newWorld();
|
|
32
|
+
const alice = (await w.fl.createActor('alice')).id; // owner
|
|
33
|
+
const anna = (await w.fl.createActor('anna')).id; // member
|
|
34
|
+
const bob = (await w.fl.createActor('bob')).id; // outsider with a grant
|
|
35
|
+
const org = (await w.fl.createOrg('acme', 'Acme', { ownerActorId: alice })).id;
|
|
36
|
+
await w.fl.addMember({ actorId: alice }, org, anna, 'member');
|
|
37
|
+
|
|
38
|
+
const file = await w.fl.upload({ actorId: alice }, org, {
|
|
39
|
+
name: 'plan.pdf',
|
|
40
|
+
contentType: 'application/pdf',
|
|
41
|
+
body: bytes('ACME CONFIDENTIAL'),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const link = await w.fl.share({ actorId: alice }, file.id, { subject: { type: 'link' } });
|
|
45
|
+
const toBob = await w.fl.share({ actorId: alice }, file.id, {
|
|
46
|
+
subject: { type: 'actor', actorId: bob },
|
|
47
|
+
});
|
|
48
|
+
const anon = opts.anonymous
|
|
49
|
+
? await w.fl.share({ actorId: alice }, file.id, { subject: { type: 'anonymous' } })
|
|
50
|
+
: null;
|
|
51
|
+
|
|
52
|
+
return { ...w, org, alice, anna, bob, file, link, toBob, anon };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Is this grant live according to the schema predicate itself? */
|
|
56
|
+
async function isLive(db: Queryable, grantId: string): Promise<boolean> {
|
|
57
|
+
const { rows } = await db.query<{ live: boolean }>(`SELECT grant_is_live($1) AS live`, [grantId]);
|
|
58
|
+
return Boolean(rows[0]!.live);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The two INDEPENDENT liveness formulations must agree on every row. This is
|
|
63
|
+
* the cross-check that makes "grant_is_live is correct" a stronger claim than
|
|
64
|
+
* "grant_is_live agrees with itself": the soft-delete rule had to be added to
|
|
65
|
+
* both formulations independently.
|
|
66
|
+
*/
|
|
67
|
+
async function livenessFormulationsAgree(db: Queryable): Promise<void> {
|
|
68
|
+
const { rows } = await db.query<{ id: string; fn: boolean; view: boolean }>(
|
|
69
|
+
`SELECT g.id,
|
|
70
|
+
grant_is_live(g.id) AS fn,
|
|
71
|
+
EXISTS (SELECT 1 FROM live_grant_recursive r WHERE r.id = g.id) AS view
|
|
72
|
+
FROM file_grant g`,
|
|
73
|
+
);
|
|
74
|
+
for (const r of rows) {
|
|
75
|
+
assert.equal(
|
|
76
|
+
Boolean(r.fn),
|
|
77
|
+
Boolean(r.view),
|
|
78
|
+
`grant_is_live and live_grant_recursive disagree on ${r.id}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
assert.ok(rows.length > 0, 'the cross-check must not pass vacuously');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// =============================================================================
|
|
85
|
+
// SOFT DELETE (a). DELETING A TENANT
|
|
86
|
+
// =============================================================================
|
|
87
|
+
|
|
88
|
+
describe('soft-deleting an ORG kills every grant in it, immediately', () => {
|
|
89
|
+
/**
|
|
90
|
+
* THE DECISION: everything dies.
|
|
91
|
+
*
|
|
92
|
+
* The alternative -- grants survive the tenant -- was the previous behaviour
|
|
93
|
+
* and nobody chose it: `getMembership()` filtered on `org.deleted_at` and
|
|
94
|
+
* `getActorGrants()` did not, so revoking a customer removed the OWNER's
|
|
95
|
+
* access and left the CONTRACTOR's share links serving bytes. "We deleted
|
|
96
|
+
* that tenant" has exactly one honest meaning, and it is this one.
|
|
97
|
+
*/
|
|
98
|
+
it('a live share link, actor grant and anonymous grant all stop working the instant the org is deleted', async () => {
|
|
99
|
+
const s = await tenant({ anonymous: true });
|
|
100
|
+
|
|
101
|
+
// All three work first, so the test cannot pass vacuously.
|
|
102
|
+
assert.ok((await s.fl.redeem(s.link.secret!)).body.byteLength > 0);
|
|
103
|
+
assert.ok((await s.fl.read({ actorId: s.bob }, s.file.id)).body.byteLength > 0);
|
|
104
|
+
assert.ok((await s.fl.read({ actorId: null }, s.file.id)).body.byteLength > 0);
|
|
105
|
+
|
|
106
|
+
await s.fl.softDeleteOrg(s.org);
|
|
107
|
+
|
|
108
|
+
await rejects(() => s.fl.redeem(s.link.secret!), 404);
|
|
109
|
+
await rejects(() => s.fl.read({ actorId: s.bob }, s.file.id), 404);
|
|
110
|
+
await rejects(() => s.fl.read({ actorId: null }, s.file.id), 404);
|
|
111
|
+
// ...and membership-derived access is gone too, as it always was.
|
|
112
|
+
await rejects(() => s.fl.read({ actorId: s.alice }, s.file.id), 404);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('the SCHEMA is the enforcement point, not the application: live_grant is empty', async () => {
|
|
116
|
+
const s = await tenant({ anonymous: true });
|
|
117
|
+
const { rows: before } = await s.db.query(`SELECT id FROM live_grant`);
|
|
118
|
+
assert.equal(before.length, 3);
|
|
119
|
+
|
|
120
|
+
await s.fl.softDeleteOrg(s.org);
|
|
121
|
+
|
|
122
|
+
// A future query that reaches for live_grant -- any query, from any writer,
|
|
123
|
+
// including psql -- inherits the semantic. That is the whole reason this
|
|
124
|
+
// lives in the predicate rather than in authz.ts.
|
|
125
|
+
const { rows: after } = await s.db.query(`SELECT id FROM live_grant`);
|
|
126
|
+
assert.deepEqual(after, []);
|
|
127
|
+
await livenessFormulationsAgree(s.db);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('is REVERSIBLE: restoring the org revives the same links, with no re-issue', async () => {
|
|
131
|
+
const s = await tenant();
|
|
132
|
+
await s.fl.softDeleteOrg(s.org);
|
|
133
|
+
await rejects(() => s.fl.redeem(s.link.secret!), 404);
|
|
134
|
+
|
|
135
|
+
await s.fl.restoreOrg(s.org);
|
|
136
|
+
|
|
137
|
+
// The SAME secret, unmodified. Liveness is derived, so undelete costs
|
|
138
|
+
// nothing and cannot restore a grant that was independently revoked.
|
|
139
|
+
assert.ok((await s.fl.redeem(s.link.secret!)).body.byteLength > 0);
|
|
140
|
+
assert.ok((await s.fl.read({ actorId: s.alice }, s.file.id)).body.byteLength > 0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('an independently revoked grant is NOT resurrected by undelete', async () => {
|
|
144
|
+
const s = await tenant();
|
|
145
|
+
await s.fl.revoke({ actorId: s.alice }, s.link.grantId);
|
|
146
|
+
await s.fl.softDeleteOrg(s.org);
|
|
147
|
+
await s.fl.restoreOrg(s.org);
|
|
148
|
+
|
|
149
|
+
await rejects(() => s.fl.redeem(s.link.secret!), 404);
|
|
150
|
+
assert.equal(await isLive(s.db, s.link.grantId), false);
|
|
151
|
+
assert.equal(await isLive(s.db, s.toBob.grantId), true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* THE DECISION on the retention/legal-hold interaction: soft-deleting a
|
|
156
|
+
* tenant SUSPENDS ACCESS and PRESERVES EVIDENCE. It is not an erasure
|
|
157
|
+
* primitive, it writes no row that a retention hold protects, and the audit
|
|
158
|
+
* chain of the deleted tenant remains intact and verifiable.
|
|
159
|
+
*
|
|
160
|
+
* The alternative -- "delete the org, delete the data" -- would make tenant
|
|
161
|
+
* deletion a way to destroy records under legal hold, which is the exact
|
|
162
|
+
* failure the retention control exists to prevent. Erasure is a separate,
|
|
163
|
+
* privileged operation gated on retention, and it is not this one.
|
|
164
|
+
*/
|
|
165
|
+
it('does NOT defeat retention: the file, its hold, and the tenant audit chain all survive', async () => {
|
|
166
|
+
const s = await tenant();
|
|
167
|
+
const held = await s.fl.upload({ actorId: s.alice }, s.org, {
|
|
168
|
+
name: 'held.pdf',
|
|
169
|
+
contentType: 'application/pdf',
|
|
170
|
+
body: bytes('UNDER LEGAL HOLD'),
|
|
171
|
+
retainFor: 3600,
|
|
172
|
+
});
|
|
173
|
+
const before = await countAudit(s.db, s.org);
|
|
174
|
+
|
|
175
|
+
await s.fl.softDeleteOrg(s.org);
|
|
176
|
+
|
|
177
|
+
const { rows } = await s.db.query<{ n: number; retain: string | null; del: string | null }>(
|
|
178
|
+
`SELECT count(*)::int AS n, max(retain_until::text) AS retain, max(deleted_at::text) AS del
|
|
179
|
+
FROM file WHERE id = $1`,
|
|
180
|
+
[held.id],
|
|
181
|
+
);
|
|
182
|
+
assert.equal(Number(rows[0]!.n), 1, 'the row is still there');
|
|
183
|
+
assert.ok(rows[0]!.retain, 'the retention floor is untouched');
|
|
184
|
+
assert.equal(rows[0]!.del, null, 'the FILE was not deleted; the ORG was');
|
|
185
|
+
|
|
186
|
+
// The chain is unbroken and still verifiable through the deletion.
|
|
187
|
+
const chain = await s.fl.store.verifyAuditChain(s.org);
|
|
188
|
+
assert.equal(chain.valid, true);
|
|
189
|
+
assert.ok(chain.checked > before, 'the deletion itself is on the record');
|
|
190
|
+
|
|
191
|
+
// And the deletion is attributed, not silent.
|
|
192
|
+
const log = await s.fl.store.listAudit(s.org, { action: 'org.delete' });
|
|
193
|
+
assert.equal(log.length, 1);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('listFiles and authorize still agree after the org is deleted: both return nothing', async () => {
|
|
197
|
+
const s = await tenant();
|
|
198
|
+
await s.fl.softDeleteOrg(s.org);
|
|
199
|
+
for (const p of [{ actorId: s.alice }, { actorId: s.bob }, { actorId: null }]) {
|
|
200
|
+
const page = await s.fl.listFiles(p, s.org);
|
|
201
|
+
assert.deepEqual(page.files, []);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// =============================================================================
|
|
207
|
+
// SOFT DELETE (b). DELETING AN IDENTITY
|
|
208
|
+
// =============================================================================
|
|
209
|
+
|
|
210
|
+
describe('soft-deleting an ACTOR kills what they hold AND what they issued', () => {
|
|
211
|
+
/** The easy half, and it did not exist: `actor.deleted_at` was decorative. */
|
|
212
|
+
it('a deleted SUBJECT loses both their role-derived access and every grant made to them', async () => {
|
|
213
|
+
const s = await tenant();
|
|
214
|
+
assert.ok((await s.fl.read({ actorId: s.bob }, s.file.id)).body.byteLength > 0);
|
|
215
|
+
|
|
216
|
+
await s.fl.softDeleteActor(s.bob);
|
|
217
|
+
await rejects(() => s.fl.read({ actorId: s.bob }, s.file.id), 404);
|
|
218
|
+
assert.equal(await isLive(s.db, s.toBob.grantId), false);
|
|
219
|
+
|
|
220
|
+
// The membership half, on a different identity, so both are covered.
|
|
221
|
+
const own = await s.fl.upload({ actorId: s.anna }, s.org, {
|
|
222
|
+
name: 'annas.txt',
|
|
223
|
+
contentType: 'text/plain',
|
|
224
|
+
body: bytes('mine'),
|
|
225
|
+
});
|
|
226
|
+
assert.ok((await s.fl.read({ actorId: s.anna }, own.id)).body.byteLength > 0);
|
|
227
|
+
await s.fl.softDeleteActor(s.anna);
|
|
228
|
+
await rejects(() => s.fl.read({ actorId: s.anna }, own.id), 404);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* THE JUDGEMENT CALL, and the call is P4.
|
|
233
|
+
*
|
|
234
|
+
* "A signed URL may never outlive the permission that created it" is the
|
|
235
|
+
* property this product is. A root grant is minted from the issuer's
|
|
236
|
+
* role-derived authority; delete the identity and that authority no longer
|
|
237
|
+
* exists, so the grant must go with it. The alternative is the "the intern
|
|
238
|
+
* left two years ago and their Dropbox link still works" failure, which is
|
|
239
|
+
* the failure we sell against.
|
|
240
|
+
*
|
|
241
|
+
* It is deliberately LOUD: deleting a prolific sharer revokes a lot of links
|
|
242
|
+
* at once. That is the correct reading of the rule, not a side effect, and
|
|
243
|
+
* SEMANTICS.md tells operators to expect it.
|
|
244
|
+
*/
|
|
245
|
+
it('a deleted ISSUER takes their links with them, including links held by third parties', async () => {
|
|
246
|
+
const s = await tenant({ anonymous: true });
|
|
247
|
+
assert.ok((await s.fl.redeem(s.link.secret!)).body.byteLength > 0);
|
|
248
|
+
assert.ok((await s.fl.read({ actorId: s.bob }, s.file.id)).body.byteLength > 0);
|
|
249
|
+
|
|
250
|
+
// Alice issued all three grants. Deleting her kills all three, even though
|
|
251
|
+
// none of them is ADDRESSED to her.
|
|
252
|
+
await s.fl.softDeleteActor(s.alice);
|
|
253
|
+
|
|
254
|
+
await rejects(() => s.fl.redeem(s.link.secret!), 404);
|
|
255
|
+
await rejects(() => s.fl.read({ actorId: s.bob }, s.file.id), 404);
|
|
256
|
+
await rejects(() => s.fl.read({ actorId: null }, s.file.id), 404);
|
|
257
|
+
await livenessFormulationsAgree(s.db);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('the whole delegated subtree below a deleted issuer dies with it, at any depth', async () => {
|
|
261
|
+
const s = await tenant();
|
|
262
|
+
// bob holds {read, share} and delegates onward to a link.
|
|
263
|
+
await s.fl.share({ actorId: s.alice }, s.file.id, {
|
|
264
|
+
subject: { type: 'actor', actorId: s.bob },
|
|
265
|
+
capabilities: ['read', 'share'],
|
|
266
|
+
});
|
|
267
|
+
const delegated = await s.fl.share({ actorId: s.bob }, s.file.id, { subject: { type: 'link' } });
|
|
268
|
+
assert.ok((await s.fl.redeem(delegated.secret!)).body.byteLength > 0);
|
|
269
|
+
|
|
270
|
+
// Delete the MIDDLE of the chain, not the root.
|
|
271
|
+
await s.fl.softDeleteActor(s.bob);
|
|
272
|
+
await rejects(() => s.fl.redeem(delegated.secret!), 404);
|
|
273
|
+
assert.equal(await isLive(s.db, delegated.grantId), false);
|
|
274
|
+
|
|
275
|
+
// ...and Alice's own root link, issued by a living actor, is untouched.
|
|
276
|
+
assert.ok((await s.fl.redeem(s.link.secret!)).body.byteLength > 0);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('is REVERSIBLE: restoring the identity revives exactly what deleting it killed', async () => {
|
|
280
|
+
const s = await tenant();
|
|
281
|
+
await s.fl.softDeleteActor(s.alice);
|
|
282
|
+
await rejects(() => s.fl.redeem(s.link.secret!), 404);
|
|
283
|
+
await s.fl.restoreActor(s.alice);
|
|
284
|
+
assert.ok((await s.fl.redeem(s.link.secret!)).body.byteLength > 0);
|
|
285
|
+
assert.ok((await s.fl.read({ actorId: s.bob }, s.file.id)).body.byteLength > 0);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* THE DELIBERATE ASYMMETRY, stated as a test so nobody "fixes" it by
|
|
290
|
+
* accident.
|
|
291
|
+
*
|
|
292
|
+
* Removing a MEMBERSHIP does not kill grants that member issued. Membership
|
|
293
|
+
* removal is a role change inside a living tenant -- someone changed teams,
|
|
294
|
+
* duties were transferred -- and mass-revoking a departing colleague's
|
|
295
|
+
* customer-facing links as a side effect of a role edit would be a surprise
|
|
296
|
+
* with real business cost. Identity DELETION is a different statement: this
|
|
297
|
+
* person is gone. The operator who wants the strong effect has an operation
|
|
298
|
+
* that produces it, and `listGrants` + `revoke` for anything narrower.
|
|
299
|
+
*/
|
|
300
|
+
it('REMOVING a membership does NOT revoke grants that member issued (deletion is the strong verb)', async () => {
|
|
301
|
+
const s = await tenant();
|
|
302
|
+
const annas = await s.fl.upload({ actorId: s.anna }, s.org, {
|
|
303
|
+
name: 'annas.pdf',
|
|
304
|
+
contentType: 'application/pdf',
|
|
305
|
+
body: bytes('annas work'),
|
|
306
|
+
});
|
|
307
|
+
const annasLink = await s.fl.share({ actorId: s.anna }, annas.id, { subject: { type: 'link' } });
|
|
308
|
+
|
|
309
|
+
await s.fl.removeMember({ actorId: s.alice }, s.org, s.anna);
|
|
310
|
+
|
|
311
|
+
// Anna herself can no longer reach it -- she has no standing at all.
|
|
312
|
+
await rejects(() => s.fl.read({ actorId: s.anna }, annas.id), 404);
|
|
313
|
+
// But the link she handed a customer keeps working, on purpose.
|
|
314
|
+
assert.ok((await s.fl.redeem(annasLink.secret!)).body.byteLength > 0);
|
|
315
|
+
|
|
316
|
+
// Deleting her, in contrast, does kill it.
|
|
317
|
+
await s.fl.softDeleteActor(s.anna);
|
|
318
|
+
await rejects(() => s.fl.redeem(annasLink.secret!), 404);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// =============================================================================
|
|
323
|
+
// SOFT DELETE (c). DELETING A FILE
|
|
324
|
+
// =============================================================================
|
|
325
|
+
|
|
326
|
+
describe('a deleted FILE has no live grants, not merely unusable ones', () => {
|
|
327
|
+
/**
|
|
328
|
+
* `authorize()` already denied on a deleted file via the lifecycle gate, so
|
|
329
|
+
* nothing was reachable. But `grant_is_live()` still said TRUE, which made
|
|
330
|
+
* `live_grant` -- documented as THE enforcement point that every store query
|
|
331
|
+
* must read through -- state something false. Any second consumer of that
|
|
332
|
+
* view would have inherited the lie. The predicate now tells the truth.
|
|
333
|
+
*/
|
|
334
|
+
it('deleting a file makes its grants non-live in the schema, and listGrants says so', async () => {
|
|
335
|
+
const s = await tenant({ anonymous: true });
|
|
336
|
+
assert.equal(await isLive(s.db, s.link.grantId), true);
|
|
337
|
+
|
|
338
|
+
await s.fl.delete({ actorId: s.alice }, s.file.id);
|
|
339
|
+
|
|
340
|
+
assert.equal(await isLive(s.db, s.link.grantId), false);
|
|
341
|
+
assert.equal(await isLive(s.db, s.toBob.grantId), false);
|
|
342
|
+
assert.equal(await isLive(s.db, s.anon!.grantId), false);
|
|
343
|
+
const { rows } = await s.db.query(`SELECT id FROM live_grant`);
|
|
344
|
+
assert.deepEqual(rows, []);
|
|
345
|
+
await livenessFormulationsAgree(s.db);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('a grant on a deleted file cannot be delegated from: the DATABASE refuses', async () => {
|
|
349
|
+
const s = await tenant();
|
|
350
|
+
await s.fl.share({ actorId: s.alice }, s.file.id, {
|
|
351
|
+
subject: { type: 'actor', actorId: s.bob },
|
|
352
|
+
capabilities: ['read', 'share'],
|
|
353
|
+
});
|
|
354
|
+
await s.fl.delete({ actorId: s.alice }, s.file.id);
|
|
355
|
+
|
|
356
|
+
// Through the API the engine refuses first (the file is gone), and through
|
|
357
|
+
// raw SQL the attenuation trigger refuses. Both doors, one semantic.
|
|
358
|
+
await rejects(
|
|
359
|
+
() => s.fl.share({ actorId: s.bob }, s.file.id, { subject: { type: 'link' } }),
|
|
360
|
+
404,
|
|
361
|
+
);
|
|
362
|
+
const { rows } = await s.db.query<{ id: string }>(
|
|
363
|
+
`SELECT id FROM file_grant WHERE subject_id = $1 AND 'share' = ANY(capabilities)`,
|
|
364
|
+
[s.bob],
|
|
365
|
+
);
|
|
366
|
+
await dbRejects(
|
|
367
|
+
s.db,
|
|
368
|
+
`INSERT INTO file_grant (file_id, org_id, parent_grant_id, subject_type, capabilities, secret_hash)
|
|
369
|
+
VALUES ($1,$2,$3,'link','{read}'::grant_capability[],'deadbeef')`,
|
|
370
|
+
[s.file.id, s.org, rows[0]!.id],
|
|
371
|
+
/grant_parent_not_live/,
|
|
372
|
+
);
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// =============================================================================
|
|
377
|
+
// SOFT DELETE (d). DELETING A CUSTOMER
|
|
378
|
+
// =============================================================================
|
|
379
|
+
|
|
380
|
+
describe('soft-deleting a PROJECT revokes the whole customer', () => {
|
|
381
|
+
it('every org, file and grant under a deleted project is dead, and restore brings it all back', async () => {
|
|
382
|
+
const s = await tenant();
|
|
383
|
+
assert.ok((await s.fl.redeem(s.link.secret!)).body.byteLength > 0);
|
|
384
|
+
|
|
385
|
+
await s.fl.softDeleteProject(DEFAULT_PROJECT_ID);
|
|
386
|
+
|
|
387
|
+
await rejects(() => s.fl.redeem(s.link.secret!), 404);
|
|
388
|
+
await rejects(() => s.fl.read({ actorId: s.alice }, s.file.id), 404);
|
|
389
|
+
const { rows } = await s.db.query(`SELECT id FROM live_grant`);
|
|
390
|
+
assert.deepEqual(rows, []);
|
|
391
|
+
|
|
392
|
+
await s.fl.restoreProject(DEFAULT_PROJECT_ID);
|
|
393
|
+
assert.ok((await s.fl.redeem(s.link.secret!)).body.byteLength > 0);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('a project-level action is recorded on the SYSTEM chain, not fanned out over tenants', async () => {
|
|
397
|
+
const s = await tenant();
|
|
398
|
+
const tenantBefore = await countAudit(s.db, s.org);
|
|
399
|
+
await s.fl.softDeleteProject(DEFAULT_PROJECT_ID);
|
|
400
|
+
// Not on the tenant chain: a control-plane action must not let one call
|
|
401
|
+
// append an unbounded number of rows to an unbounded number of customer
|
|
402
|
+
// chains. That is the ingest-boundary amplification concern in a different
|
|
403
|
+
// costume.
|
|
404
|
+
assert.equal(await countAudit(s.db, s.org), tenantBefore);
|
|
405
|
+
const sys = await s.fl.store.listAudit(null, { action: 'project.delete' });
|
|
406
|
+
assert.equal(sys.length, 1);
|
|
407
|
+
assert.equal((await s.fl.store.verifyAuditChain(null)).valid, true);
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// =============================================================================
|
|
412
|
+
// WHAT `maxDownloads` COUNTS
|
|
413
|
+
// =============================================================================
|
|
414
|
+
|
|
415
|
+
describe('maxDownloads counts BYTE DELIVERIES, on every path', () => {
|
|
416
|
+
/**
|
|
417
|
+
* THE DECISION: charge on every delivery.
|
|
418
|
+
*
|
|
419
|
+
* It used to be charged only by `redeem()`. An actor grant with
|
|
420
|
+
* `maxDownloads: 1` therefore permitted unlimited direct `read()` calls --
|
|
421
|
+
* the cap was a lie on the path the SDK actually uses. The two alternatives
|
|
422
|
+
* were rejected in the comment on `Filelayer.deliver()`: renaming it
|
|
423
|
+
* `maxRedemptions` moves the ambiguity rather than closing it, and refusing
|
|
424
|
+
* the field on non-link grants removes a capability instead of defining one.
|
|
425
|
+
*/
|
|
426
|
+
it('N direct read() calls through a capped actor grant DO decrement it', async () => {
|
|
427
|
+
const s = await tenant();
|
|
428
|
+
const capped = await s.fl.share({ actorId: s.alice }, s.file.id, {
|
|
429
|
+
subject: { type: 'actor', actorId: s.bob },
|
|
430
|
+
maxDownloads: 2,
|
|
431
|
+
});
|
|
432
|
+
// The pre-existing uncapped grant to bob would mask the cap, so remove it.
|
|
433
|
+
await s.fl.revoke({ actorId: s.alice }, s.toBob.grantId);
|
|
434
|
+
|
|
435
|
+
const first = await s.fl.read({ actorId: s.bob }, s.file.id);
|
|
436
|
+
assert.equal(first.remainingDownloads, 1);
|
|
437
|
+
const second = await s.fl.read({ actorId: s.bob }, s.file.id);
|
|
438
|
+
assert.equal(second.remainingDownloads, 0);
|
|
439
|
+
await rejects(() => s.fl.read({ actorId: s.bob }, s.file.id), 404);
|
|
440
|
+
|
|
441
|
+
assert.equal(await isLive(s.db, capped.grantId), false);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('read() and redeem() draw on ONE budget shared across the delegation chain', async () => {
|
|
445
|
+
const s = await tenant();
|
|
446
|
+
await s.fl.revoke({ actorId: s.alice }, s.toBob.grantId);
|
|
447
|
+
const parent = await s.fl.share({ actorId: s.alice }, s.file.id, {
|
|
448
|
+
subject: { type: 'actor', actorId: s.bob },
|
|
449
|
+
capabilities: ['read', 'share'],
|
|
450
|
+
maxDownloads: 2,
|
|
451
|
+
});
|
|
452
|
+
const child = await s.fl.share({ actorId: s.bob }, s.file.id, { subject: { type: 'link' } });
|
|
453
|
+
assert.equal(child.maxDownloads, 2, 'the child is clamped to the parent budget');
|
|
454
|
+
|
|
455
|
+
// One direct read by bob spends one of the two...
|
|
456
|
+
assert.equal((await s.fl.read({ actorId: s.bob }, s.file.id)).remainingDownloads, 1);
|
|
457
|
+
// ...and one redemption of the delegated link spends the last one.
|
|
458
|
+
assert.equal((await s.fl.redeem(child.secret!)).remainingDownloads, 0);
|
|
459
|
+
|
|
460
|
+
await rejects(() => s.fl.redeem(child.secret!), 404);
|
|
461
|
+
await rejects(() => s.fl.read({ actorId: s.bob }, s.file.id), 404);
|
|
462
|
+
assert.equal(await isLive(s.db, parent.grantId), false);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* THE BOUNDARY of the rule, and it is the reason the rule is stated as "via a
|
|
467
|
+
* grant" rather than "on every read": an administrator doing their job must
|
|
468
|
+
* not silently spend a contractor's link budget.
|
|
469
|
+
*/
|
|
470
|
+
it('a role-derived read does NOT charge anyone: the cap binds the CREDENTIAL, not the file', async () => {
|
|
471
|
+
const s = await tenant();
|
|
472
|
+
await s.fl.revoke({ actorId: s.alice }, s.toBob.grantId);
|
|
473
|
+
const capped = await s.fl.share({ actorId: s.alice }, s.file.id, {
|
|
474
|
+
subject: { type: 'actor', actorId: s.bob },
|
|
475
|
+
maxDownloads: 1,
|
|
476
|
+
});
|
|
477
|
+
// The owner, and an admin exercising the retention/compliance access the
|
|
478
|
+
// role matrix gives them. Neither is spending anyone's credential.
|
|
479
|
+
await s.fl.addMember({ actorId: s.alice }, s.org, s.anna, 'admin');
|
|
480
|
+
for (let i = 0; i < 5; i++) await s.fl.read({ actorId: s.alice }, s.file.id);
|
|
481
|
+
for (let i = 0; i < 5; i++) await s.fl.read({ actorId: s.anna }, s.file.id);
|
|
482
|
+
|
|
483
|
+
const { rows } = await s.db.query<{ c: number }>(
|
|
484
|
+
`SELECT download_count AS c FROM file_grant WHERE id = $1`,
|
|
485
|
+
[capped.grantId],
|
|
486
|
+
);
|
|
487
|
+
assert.equal(Number(rows[0]!.c), 0);
|
|
488
|
+
assert.equal((await s.fl.read({ actorId: s.bob }, s.file.id)).remainingDownloads, 0);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('stat() is metadata, not delivery, and never spends a download', async () => {
|
|
492
|
+
const s = await tenant();
|
|
493
|
+
await s.fl.revoke({ actorId: s.alice }, s.toBob.grantId);
|
|
494
|
+
const capped = await s.fl.share({ actorId: s.alice }, s.file.id, {
|
|
495
|
+
subject: { type: 'actor', actorId: s.bob },
|
|
496
|
+
maxDownloads: 1,
|
|
497
|
+
});
|
|
498
|
+
for (let i = 0; i < 4; i++) {
|
|
499
|
+
const rec = await s.fl.stat({ actorId: s.bob }, s.file.id);
|
|
500
|
+
assert.equal(rec.name, 'plan.pdf');
|
|
501
|
+
}
|
|
502
|
+
const { rows } = await s.db.query<{ c: number }>(
|
|
503
|
+
`SELECT download_count AS c FROM file_grant WHERE id = $1`,
|
|
504
|
+
[capped.grantId],
|
|
505
|
+
);
|
|
506
|
+
assert.equal(Number(rows[0]!.c), 0);
|
|
507
|
+
// The one delivery still works, so the cap was genuinely unspent.
|
|
508
|
+
assert.ok((await s.fl.read({ actorId: s.bob }, s.file.id)).body.byteLength > 0);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('the anonymous public path is capped too, so a public URL can be a budget', async () => {
|
|
512
|
+
const w = await newWorld();
|
|
513
|
+
const alice = (await w.fl.createActor('alice')).id;
|
|
514
|
+
const org = (await w.fl.createOrg('acme', 'Acme', { ownerActorId: alice })).id;
|
|
515
|
+
const file = await w.fl.upload({ actorId: alice }, org, {
|
|
516
|
+
name: 'poster.png',
|
|
517
|
+
contentType: 'image/png',
|
|
518
|
+
body: bytes('POSTER'),
|
|
519
|
+
});
|
|
520
|
+
await w.fl.share({ actorId: alice }, file.id, {
|
|
521
|
+
subject: { type: 'anonymous' },
|
|
522
|
+
maxDownloads: 2,
|
|
523
|
+
});
|
|
524
|
+
assert.equal((await w.fl.read({ actorId: null }, file.id)).remainingDownloads, 1);
|
|
525
|
+
assert.equal((await w.fl.read({ actorId: null }, file.id)).remainingDownloads, 0);
|
|
526
|
+
await rejects(() => w.fl.read({ actorId: null }, file.id), 404);
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// =============================================================================
|
|
531
|
+
// THE AUDIT CHAIN CANNOT FORK
|
|
532
|
+
// =============================================================================
|
|
533
|
+
|
|
534
|
+
describe('the audit chain is appended under a per-chain lock', () => {
|
|
535
|
+
it('the write path is a single statement that takes pg_advisory_xact_lock', async () => {
|
|
536
|
+
const w = await newWorld();
|
|
537
|
+
const { rows } = await w.db.query<{ def: string }>(
|
|
538
|
+
`SELECT pg_get_functiondef(oid) AS def FROM pg_proc WHERE proname = 'audit_append'`,
|
|
539
|
+
);
|
|
540
|
+
assert.equal(rows.length, 1, 'there is exactly one append path');
|
|
541
|
+
const def = rows[0]!.def;
|
|
542
|
+
assert.match(def, /pg_advisory_xact_lock\(audit_chain_lock_key/);
|
|
543
|
+
// The lock must be taken BEFORE the predecessor is read, or the window it
|
|
544
|
+
// exists to close is still open.
|
|
545
|
+
assert.ok(
|
|
546
|
+
def.indexOf('pg_advisory_xact_lock') < def.indexOf('ORDER BY a.id DESC'),
|
|
547
|
+
'the lock must precede the read of the predecessor',
|
|
548
|
+
);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('the lock key is per-chain: two orgs do not serialize against each other, and the system chain is its own', async () => {
|
|
552
|
+
const w = await newWorld();
|
|
553
|
+
const a = (await w.fl.createOrg('a')).id;
|
|
554
|
+
const b = (await w.fl.createOrg('b')).id;
|
|
555
|
+
const { rows } = await w.db.query<{ ka: string; kb: string; ks: string; ka2: string }>(
|
|
556
|
+
`SELECT audit_chain_lock_key($1)::text AS ka,
|
|
557
|
+
audit_chain_lock_key($2)::text AS kb,
|
|
558
|
+
audit_chain_lock_key(NULL)::text AS ks,
|
|
559
|
+
audit_chain_lock_key($1)::text AS ka2`,
|
|
560
|
+
[a, b],
|
|
561
|
+
);
|
|
562
|
+
const r = rows[0]!;
|
|
563
|
+
assert.equal(r.ka, r.ka2, 'the key is deterministic');
|
|
564
|
+
assert.notEqual(r.ka, r.kb);
|
|
565
|
+
assert.notEqual(r.ka, r.ks);
|
|
566
|
+
assert.notEqual(r.kb, r.ks);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* The digest is now computed in SQL on write and in TypeScript on read. That
|
|
571
|
+
* is only safe if the two agree bit for bit, so this asserts it directly
|
|
572
|
+
* rather than relying on `verifyAuditChain` to notice later.
|
|
573
|
+
*/
|
|
574
|
+
it('the SQL digest equals the TypeScript digest, field for field', async () => {
|
|
575
|
+
const w = await newWorld();
|
|
576
|
+
const occurredAt = new Date('2026-09-05T12:34:56.789Z');
|
|
577
|
+
const fields = {
|
|
578
|
+
orgId: null,
|
|
579
|
+
occurredAt,
|
|
580
|
+
action: 'file.read',
|
|
581
|
+
decision: 'deny' as const,
|
|
582
|
+
reason: 'no_grant',
|
|
583
|
+
actorId: null,
|
|
584
|
+
fileId: '11111111-2222-4333-8444-555555555555',
|
|
585
|
+
grantId: null,
|
|
586
|
+
ip: '203.0.113.9',
|
|
587
|
+
userAgent: 'probe/1.0 "quoted" \\ backslash',
|
|
588
|
+
context: { z: 1, a: { nested: 'é ☃', arr: [1, null, 'x'] } },
|
|
589
|
+
};
|
|
590
|
+
const { rows } = await w.db.query<{ hash: string; prev_hash: string | null }>(
|
|
591
|
+
`SELECT hash, prev_hash FROM audit_append($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12)`,
|
|
592
|
+
[
|
|
593
|
+
fields.orgId,
|
|
594
|
+
occurredAt.toISOString(),
|
|
595
|
+
fields.action,
|
|
596
|
+
fields.decision,
|
|
597
|
+
fields.reason,
|
|
598
|
+
fields.actorId,
|
|
599
|
+
fields.fileId,
|
|
600
|
+
fields.grantId,
|
|
601
|
+
fields.ip,
|
|
602
|
+
fields.userAgent,
|
|
603
|
+
JSON.stringify(fields.context),
|
|
604
|
+
auditHashTail(fields),
|
|
605
|
+
],
|
|
606
|
+
);
|
|
607
|
+
assert.equal(rows[0]!.hash, auditHash({ prevHash: rows[0]!.prev_hash, ...fields }));
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it('prev_hash is the FIRST element of the digest, which is what makes the SQL side a concatenation and not a re-implementation', async () => {
|
|
611
|
+
const fields = {
|
|
612
|
+
orgId: null,
|
|
613
|
+
occurredAt: new Date(0),
|
|
614
|
+
action: 'x',
|
|
615
|
+
decision: 'allow',
|
|
616
|
+
actorId: null,
|
|
617
|
+
fileId: null,
|
|
618
|
+
};
|
|
619
|
+
const tail = auditHashTail(fields);
|
|
620
|
+
assert.equal(tail.startsWith('['), false, 'the tail carries no leading bracket');
|
|
621
|
+
for (const prev of [null, 'abc']) {
|
|
622
|
+
assert.equal(
|
|
623
|
+
auditHash({ prevHash: prev, ...fields }),
|
|
624
|
+
createHash('sha256')
|
|
625
|
+
.update(`[${JSON.stringify(prev)},${tail}`, 'utf8')
|
|
626
|
+
.digest('hex'),
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
// ...and the tail is genuinely independent of the predecessor.
|
|
630
|
+
assert.notEqual(auditHash({ prevHash: null, ...fields }), auditHash({ prevHash: 'abc', ...fields }));
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* WHAT PGlite CANNOT PROVE, stated as a test name so it is impossible to
|
|
635
|
+
* forget. PGlite is a single backend: two transactions cannot exist at the
|
|
636
|
+
* same instant, so no test here can demonstrate contention, a waiting writer,
|
|
637
|
+
* or a fork actually prevented. What this test does is show that the FORK
|
|
638
|
+
* THE OLD CODE PRODUCED is detectable -- i.e. that the failure mode being
|
|
639
|
+
* eliminated is real and observable -- and leave the serialization proof to a
|
|
640
|
+
* multi-connection deployment suite.
|
|
641
|
+
*/
|
|
642
|
+
it('CONTROL: a fork of the kind the old SELECT-then-INSERT could produce IS detected (PGlite cannot prove serialization)', async () => {
|
|
643
|
+
const s = await tenant();
|
|
644
|
+
const { rows } = await s.db.query<{ prev_hash: string | null; hash: string }>(
|
|
645
|
+
`SELECT prev_hash, hash FROM audit_event WHERE org_id = $1 ORDER BY id DESC LIMIT 1`,
|
|
646
|
+
[s.org],
|
|
647
|
+
);
|
|
648
|
+
assert.equal((await s.fl.store.verifyAuditChain(s.org)).valid, true);
|
|
649
|
+
|
|
650
|
+
// Two writers that both read the same predecessor: this is exactly what the
|
|
651
|
+
// unlocked SELECT-then-INSERT would have written under concurrency.
|
|
652
|
+
await s.db.query(
|
|
653
|
+
`INSERT INTO audit_event (org_id, action, decision, prev_hash, hash)
|
|
654
|
+
VALUES ($1, 'file.read', 'allow', $2, 'forked')`,
|
|
655
|
+
[s.org, rows[0]!.prev_hash],
|
|
656
|
+
);
|
|
657
|
+
const after = await s.fl.store.verifyAuditChain(s.org);
|
|
658
|
+
assert.equal(after.valid, false);
|
|
659
|
+
assert.equal(after.problem, 'prev_hash_mismatch');
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// =============================================================================
|
|
664
|
+
// THE CUSTOMER'S ID SPACE IS THE CUSTOMER'S
|
|
665
|
+
// =============================================================================
|
|
666
|
+
|
|
667
|
+
describe('external_id is scoped to the project, not global', () => {
|
|
668
|
+
async function twoProjects() {
|
|
669
|
+
const { db } = await createTestDb();
|
|
670
|
+
const storage = new MemoryStorage();
|
|
671
|
+
const a = new Filelayer(db, storage, { baseUrl: 'https://a.test' });
|
|
672
|
+
const pb = (await a.createProject('customer-b', 'Customer B')).id;
|
|
673
|
+
const b = new Filelayer(db, storage, { baseUrl: 'https://b.test', projectId: pb });
|
|
674
|
+
return { db, storage, a, b, pb };
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* THE DEFECT, and it was reachable from the most ergonomic entry point we
|
|
679
|
+
* ship. Under a GLOBAL unique index on `external_id`, customer B calling
|
|
680
|
+
* `files.put({ org: 'acme', owner: 'mallory' })` did not get an error: the
|
|
681
|
+
* `ON CONFLICT (external_id) DO UPDATE ... RETURNING id` in `Identities.org`
|
|
682
|
+
* returned CUSTOMER A's org id, and `Identities.membership` then added
|
|
683
|
+
* mallory to A's tenant as a member. A common org name was a complete
|
|
684
|
+
* cross-customer compromise.
|
|
685
|
+
*/
|
|
686
|
+
it('two customers may both call their tenant "acme", and the second does not take over the first', async () => {
|
|
687
|
+
const { a, b } = await twoProjects();
|
|
688
|
+
const fileA = await a.files.put(bytes('A CONFIDENTIAL'), { org: 'acme', owner: 'alice' });
|
|
689
|
+
const fileB = await b.files.put(bytes('B CONFIDENTIAL'), { org: 'acme', owner: 'mallory' });
|
|
690
|
+
|
|
691
|
+
const orgA = await a.store.db.query<{ id: string }>(
|
|
692
|
+
`SELECT id FROM org WHERE external_id = 'acme' AND project_id = $1`,
|
|
693
|
+
[DEFAULT_PROJECT_ID],
|
|
694
|
+
);
|
|
695
|
+
const orgB = await a.store.db.query<{ id: string }>(
|
|
696
|
+
`SELECT id FROM org WHERE external_id = 'acme' AND project_id <> $1`,
|
|
697
|
+
[DEFAULT_PROJECT_ID],
|
|
698
|
+
);
|
|
699
|
+
assert.notEqual(orgA.rows[0]!.id, orgB.rows[0]!.id, 'two rows, not one shared row');
|
|
700
|
+
|
|
701
|
+
// Mallory is a member of B's acme and of nothing in A's.
|
|
702
|
+
const { rows: cross } = await a.store.db.query<{ n: number }>(
|
|
703
|
+
`SELECT count(*)::int AS n FROM membership m JOIN actor ac ON ac.id = m.actor_id
|
|
704
|
+
WHERE m.org_id = $1 AND ac.external_id = 'mallory'`,
|
|
705
|
+
[orgA.rows[0]!.id],
|
|
706
|
+
);
|
|
707
|
+
assert.equal(Number(cross[0]!.n), 0);
|
|
708
|
+
|
|
709
|
+
assert.notEqual(fileA.id, fileB.id);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it("the same user id in two customers' apps is two different people", async () => {
|
|
713
|
+
const { a, b } = await twoProjects();
|
|
714
|
+
await a.files.put(bytes('A doc'), { org: 'acme', owner: 'alice' });
|
|
715
|
+
await b.files.put(bytes('B doc'), { org: 'acme', owner: 'alice' });
|
|
716
|
+
const { rows } = await a.store.db.query<{ n: number }>(
|
|
717
|
+
`SELECT count(*)::int AS n FROM actor WHERE external_id = 'alice'`,
|
|
718
|
+
);
|
|
719
|
+
assert.equal(Number(rows[0]!.n), 2);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
it('a project-bound instance cannot read another project’s file even holding its UUID', async () => {
|
|
723
|
+
const { a, b } = await twoProjects();
|
|
724
|
+
const fileA = await a.files.put(bytes('A CONFIDENTIAL'), { org: 'acme', owner: 'alice' });
|
|
725
|
+
const aliceA = (
|
|
726
|
+
await a.store.db.query<{ id: string }>(
|
|
727
|
+
`SELECT id FROM actor WHERE external_id = 'alice' AND project_id = $1`,
|
|
728
|
+
[DEFAULT_PROJECT_ID],
|
|
729
|
+
)
|
|
730
|
+
).rows[0]!.id;
|
|
731
|
+
|
|
732
|
+
// The real id, the real owner, the wrong project.
|
|
733
|
+
await rejects(() => b.read({ actorId: aliceA }, fileA.id), 404);
|
|
734
|
+
await rejects(() => b.stat({ actorId: aliceA }, fileA.id), 404);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* THIS IS THE PART OF THE AUDIT-FLOODING EXPOSURE THAT IS STRUCTURALLY
|
|
739
|
+
* FIXABLE. A caller who guesses an org UUID belonging to a different project
|
|
740
|
+
* cannot append to that org's audit chain at all: `orgExists` is
|
|
741
|
+
* project-scoped, so the denial is written to the system chain instead. The
|
|
742
|
+
* remaining exposure -- an authenticated customer flooding their OWN tenant's
|
|
743
|
+
* chain -- is a quota problem, and the ingest requirement is recorded in
|
|
744
|
+
* SEMANTICS.md.
|
|
745
|
+
*/
|
|
746
|
+
it('probing another project’s org id lands on the SYSTEM chain, not that tenant’s', async () => {
|
|
747
|
+
const { a, b } = await twoProjects();
|
|
748
|
+
await a.files.put(bytes('A doc'), { org: 'acme', owner: 'alice' });
|
|
749
|
+
const orgA = (
|
|
750
|
+
await a.store.db.query<{ id: string }>(
|
|
751
|
+
`SELECT id FROM org WHERE external_id = 'acme' AND project_id = $1`,
|
|
752
|
+
[DEFAULT_PROJECT_ID],
|
|
753
|
+
)
|
|
754
|
+
).rows[0]!.id;
|
|
755
|
+
|
|
756
|
+
const before = await countAudit(a.store.db, orgA);
|
|
757
|
+
const beforeSystem = await countAudit(a.store.db, null);
|
|
758
|
+
|
|
759
|
+
for (let i = 0; i < 5; i++) {
|
|
760
|
+
const page = await b.listFiles({ actorId: null }, orgA);
|
|
761
|
+
assert.deepEqual(page.files, []);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
assert.equal(await countAudit(a.store.db, orgA), before, 'the victim tenant chain is untouched');
|
|
765
|
+
assert.equal(await countAudit(a.store.db, null), beforeSystem + 5, 'the probes are still recorded');
|
|
766
|
+
assert.equal((await a.store.verifyAuditChain(null)).valid, true);
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it('the DATABASE refuses a cross-project membership and a cross-project grant subject', async () => {
|
|
770
|
+
const { db, a, b } = await twoProjects();
|
|
771
|
+
await a.files.put(bytes('A doc'), { org: 'acme', owner: 'alice', public: true });
|
|
772
|
+
await b.files.put(bytes('B doc'), { org: 'acme', owner: 'mallory' });
|
|
773
|
+
|
|
774
|
+
const orgA = (
|
|
775
|
+
await db.query<{ id: string }>(
|
|
776
|
+
`SELECT id FROM org WHERE external_id = 'acme' AND project_id = $1`,
|
|
777
|
+
[DEFAULT_PROJECT_ID],
|
|
778
|
+
)
|
|
779
|
+
).rows[0]!.id;
|
|
780
|
+
const fileA = (
|
|
781
|
+
await db.query<{ id: string }>(`SELECT id FROM file WHERE org_id = $1`, [orgA])
|
|
782
|
+
).rows[0]!.id;
|
|
783
|
+
const mallory = (
|
|
784
|
+
await db.query<{ id: string }>(
|
|
785
|
+
`SELECT id FROM actor WHERE external_id = 'mallory' AND project_id <> $1`,
|
|
786
|
+
[DEFAULT_PROJECT_ID],
|
|
787
|
+
)
|
|
788
|
+
).rows[0]!.id;
|
|
789
|
+
|
|
790
|
+
// P3, one level up: not "prevented by a WHERE clause" -- unrepresentable.
|
|
791
|
+
await dbRejects(
|
|
792
|
+
db,
|
|
793
|
+
`INSERT INTO membership (org_id, actor_id, role) VALUES ($1,$2,'admin')`,
|
|
794
|
+
[orgA, mallory],
|
|
795
|
+
/foreign key|violates/i,
|
|
796
|
+
);
|
|
797
|
+
await dbRejects(
|
|
798
|
+
db,
|
|
799
|
+
`INSERT INTO file_grant (file_id, org_id, subject_type, subject_id, capabilities)
|
|
800
|
+
VALUES ($1,$2,'actor',$3,'{read}'::grant_capability[])`,
|
|
801
|
+
[fileA, orgA, mallory],
|
|
802
|
+
/foreign key|violates/i,
|
|
803
|
+
);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
it('a file’s owner cannot be an identity from another project', async () => {
|
|
807
|
+
const { db, a, b } = await twoProjects();
|
|
808
|
+
await a.files.put(bytes('A doc'), { org: 'acme', owner: 'alice' });
|
|
809
|
+
await b.files.put(bytes('B doc'), { org: 'acme', owner: 'mallory' });
|
|
810
|
+
const orgA = (
|
|
811
|
+
await db.query<{ id: string }>(
|
|
812
|
+
`SELECT id FROM org WHERE external_id = 'acme' AND project_id = $1`,
|
|
813
|
+
[DEFAULT_PROJECT_ID],
|
|
814
|
+
)
|
|
815
|
+
).rows[0]!.id;
|
|
816
|
+
const mallory = (
|
|
817
|
+
await db.query<{ id: string }>(
|
|
818
|
+
`SELECT id FROM actor WHERE external_id = 'mallory' AND project_id <> $1`,
|
|
819
|
+
[DEFAULT_PROJECT_ID],
|
|
820
|
+
)
|
|
821
|
+
).rows[0]!.id;
|
|
822
|
+
await dbRejects(
|
|
823
|
+
db,
|
|
824
|
+
`INSERT INTO file (org_id, owner_id, name, content_type, storage_key)
|
|
825
|
+
VALUES ($1,$2,'x','text/plain','k1')`,
|
|
826
|
+
[orgA, mallory],
|
|
827
|
+
/foreign key|violates/i,
|
|
828
|
+
);
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
it('auto-provisioning never resurrects a deleted identity', async () => {
|
|
832
|
+
const { a } = await twoProjects();
|
|
833
|
+
await a.files.put(bytes('doc'), { owner: 'alice' });
|
|
834
|
+
const alice = (
|
|
835
|
+
await a.store.db.query<{ id: string }>(`SELECT id FROM actor WHERE external_id = 'alice'`)
|
|
836
|
+
).rows[0]!.id;
|
|
837
|
+
await a.softDeleteActor(alice);
|
|
838
|
+
await rejects(() => a.files.put(bytes('doc2'), { owner: 'alice' }), 404);
|
|
839
|
+
});
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
// =============================================================================
|
|
843
|
+
// getFileRecord, and the sweep for principal-free resource access
|
|
844
|
+
// =============================================================================
|
|
845
|
+
|
|
846
|
+
describe('a resource id is not a question the system answers without a principal', () => {
|
|
847
|
+
/**
|
|
848
|
+
* `getFileRecord(fileId)` was PUBLIC, took no principal, and returned the
|
|
849
|
+
* whole record for any file in the database -- name, size, owner, org,
|
|
850
|
+
* storage key -- with no decision, no denial and no audit event. It sat under
|
|
851
|
+
* an `// Internals` comment, which binds nobody.
|
|
852
|
+
*
|
|
853
|
+
* It is private now. `stat()` is the authorized replacement, and it is
|
|
854
|
+
* authorized by the same engine, with the same 404, and the same audit event
|
|
855
|
+
* as `read()`.
|
|
856
|
+
*/
|
|
857
|
+
it('stat() denies a stranger and a foreign tenant exactly as read() does, and audits it', async () => {
|
|
858
|
+
const s = await tenant();
|
|
859
|
+
const mallory = (await s.fl.createActor('mallory')).id;
|
|
860
|
+
|
|
861
|
+
const before = await countAudit(s.db, s.org);
|
|
862
|
+
await rejects(() => s.fl.stat({ actorId: mallory }, s.file.id), 404, 'not_found');
|
|
863
|
+
await rejects(() => s.fl.stat({ actorId: null }, s.file.id), 404, 'not_found');
|
|
864
|
+
assert.ok(await countAudit(s.db, s.org) > before, 'the metadata probe is on the record');
|
|
865
|
+
|
|
866
|
+
const denials = await s.fl.store.listAudit(s.org, { decision: 'deny' });
|
|
867
|
+
assert.ok(denials.some((d) => d.actorId === mallory && d.action === 'file.read'));
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
it('stat() on an id that does not exist is a system-chain event, not a tenant one', async () => {
|
|
871
|
+
const s = await tenant();
|
|
872
|
+
const before = await countAudit(s.db, s.org);
|
|
873
|
+
const beforeSystem = await countAudit(s.db, null);
|
|
874
|
+
await rejects(
|
|
875
|
+
() => s.fl.stat({ actorId: s.alice }, '11111111-2222-4333-8444-555555555555'),
|
|
876
|
+
404,
|
|
877
|
+
);
|
|
878
|
+
assert.equal(await countAudit(s.db, s.org), before);
|
|
879
|
+
assert.equal(await countAudit(s.db, null), beforeSystem + 1);
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
it('stat() returns the record to someone who is actually allowed to read it', async () => {
|
|
883
|
+
const s = await tenant();
|
|
884
|
+
const rec = await s.fl.stat({ actorId: s.alice }, s.file.id);
|
|
885
|
+
assert.equal(rec.name, 'plan.pdf');
|
|
886
|
+
assert.equal(rec.visibility, 'private');
|
|
887
|
+
});
|
|
888
|
+
});
|