@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,689 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE DIFFERENTIAL TEST.
|
|
3
|
+
*
|
|
4
|
+
* `listFiles` answers, in ONE SQL query, the question `authorize()` answers one
|
|
5
|
+
* file at a time. Two implementations of one predicate is exactly the structure
|
|
6
|
+
* that produces a silent leak: the point check is what the security tests
|
|
7
|
+
* exercise, the set query is what the listing screen actually calls, and a
|
|
8
|
+
* widening drift in the set query is a cross-tenant disclosure that no test
|
|
9
|
+
* written against `authorize()` would ever see.
|
|
10
|
+
*
|
|
11
|
+
* So this file asserts the only property that makes the pair safe:
|
|
12
|
+
*
|
|
13
|
+
* for every capability c, every principal p, every org o:
|
|
14
|
+
* set(listFiles(p, o, c)) == { f in o : authorize(p, f, c).allow }
|
|
15
|
+
*
|
|
16
|
+
* over a randomized corpus of orgs, actors, roles, visibilities, ownerships,
|
|
17
|
+
* direct grants, delegated grants, revocations, expiries, download caps,
|
|
18
|
+
* retention holds, soft deletes and pending uploads. Equality, not containment
|
|
19
|
+
* -- a set query that is too NARROW is a bug too, and one that would otherwise
|
|
20
|
+
* be discovered by a customer.
|
|
21
|
+
*
|
|
22
|
+
* The corpus is generated from a seeded PRNG so a failure is reproducible: the
|
|
23
|
+
* seed is printed in the assertion message.
|
|
24
|
+
*
|
|
25
|
+
* NOTE ON THE ORACLE. `authorize()` writes an audit event per call, so running
|
|
26
|
+
* it over the whole corpus is what makes these tests slow. That cost is the
|
|
27
|
+
* point: the oracle is the real production code path, not a reimplementation of
|
|
28
|
+
* it.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { describe, it } from 'node:test';
|
|
32
|
+
import assert from 'node:assert/strict';
|
|
33
|
+
import { createTestDb, type Queryable } from '../src/db.ts';
|
|
34
|
+
import { Filelayer } from '../src/filelayer.ts';
|
|
35
|
+
import { MemoryStorage } from '../src/storage.ts';
|
|
36
|
+
import {
|
|
37
|
+
ALL_CAPABILITIES,
|
|
38
|
+
authorize,
|
|
39
|
+
fileCapabilities,
|
|
40
|
+
listPredicate,
|
|
41
|
+
membershipCells,
|
|
42
|
+
roleMeets,
|
|
43
|
+
type Capability,
|
|
44
|
+
type OrgRole,
|
|
45
|
+
type Principal,
|
|
46
|
+
} from '../src/authz.ts';
|
|
47
|
+
import { membershipCellSql } from '../src/store.ts';
|
|
48
|
+
import { bytes, rejects } from './helpers.ts';
|
|
49
|
+
|
|
50
|
+
// -----------------------------------------------------------------------------
|
|
51
|
+
// A tiny deterministic PRNG (mulberry32). No dependency, reproducible seeds.
|
|
52
|
+
// -----------------------------------------------------------------------------
|
|
53
|
+
function rng(seed: number): () => number {
|
|
54
|
+
let a = seed >>> 0;
|
|
55
|
+
return () => {
|
|
56
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
57
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
58
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
59
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const ROLES: OrgRole[] = ['viewer', 'member', 'admin', 'owner'];
|
|
64
|
+
|
|
65
|
+
interface Corpus {
|
|
66
|
+
db: Queryable;
|
|
67
|
+
fl: Filelayer;
|
|
68
|
+
orgs: string[];
|
|
69
|
+
actors: string[];
|
|
70
|
+
/** Every file id we created, by org. */
|
|
71
|
+
filesByOrg: Map<string, string[]>;
|
|
72
|
+
principals: Principal[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build a world that exercises every branch of `resolveStanding` and every
|
|
77
|
+
* branch of `lifecycleDenial`.
|
|
78
|
+
*
|
|
79
|
+
* Written with raw SQL for the parts that the public API refuses to create
|
|
80
|
+
* (expired grants, soft-deleted files, a grant to a non-member) precisely
|
|
81
|
+
* because the set query must agree with the point check on rows the API would
|
|
82
|
+
* not have produced. A predicate that is only correct for well-formed data is
|
|
83
|
+
* not a predicate, it is a coincidence.
|
|
84
|
+
*/
|
|
85
|
+
async function buildCorpus(seed: number, size = 'normal' as 'normal' | 'small'): Promise<Corpus> {
|
|
86
|
+
const rand = rng(seed);
|
|
87
|
+
const pick = <T>(xs: readonly T[]): T => xs[Math.floor(rand() * xs.length)]!;
|
|
88
|
+
const { db } = await createTestDb();
|
|
89
|
+
const fl = new Filelayer(db, new MemoryStorage(), { baseUrl: 'https://t.test' });
|
|
90
|
+
|
|
91
|
+
const nOrgs = size === 'small' ? 2 : 3;
|
|
92
|
+
const nActorsPerOrg = 4;
|
|
93
|
+
const nFiles = size === 'small' ? 8 : 14;
|
|
94
|
+
|
|
95
|
+
const orgs: string[] = [];
|
|
96
|
+
const actors: string[] = [];
|
|
97
|
+
const filesByOrg = new Map<string, string[]>();
|
|
98
|
+
const owners: string[] = [];
|
|
99
|
+
|
|
100
|
+
// A pool of actors, some of whom belong to no org at all.
|
|
101
|
+
for (let i = 0; i < nOrgs * nActorsPerOrg + 3; i++) {
|
|
102
|
+
actors.push((await fl.createActor(`actor-${seed}-${i}`)).id);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (let o = 0; o < nOrgs; o++) {
|
|
106
|
+
const owner = actors[o * nActorsPerOrg]!;
|
|
107
|
+
const org = (await fl.createOrg(`org-${seed}-${o}`, `Org ${o}`, { ownerActorId: owner })).id;
|
|
108
|
+
orgs.push(org);
|
|
109
|
+
owners.push(owner);
|
|
110
|
+
filesByOrg.set(org, []);
|
|
111
|
+
for (let m = 1; m < nActorsPerOrg; m++) {
|
|
112
|
+
await fl.addMember({ actorId: owner }, org, actors[o * nActorsPerOrg + m]!, pick(ROLES));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Files: every visibility, several owners, and the awkward lifecycle states.
|
|
117
|
+
for (let o = 0; o < nOrgs; o++) {
|
|
118
|
+
const org = orgs[o]!;
|
|
119
|
+
const orgActors = actors.slice(o * nActorsPerOrg, (o + 1) * nActorsPerOrg);
|
|
120
|
+
for (let f = 0; f < nFiles; f++) {
|
|
121
|
+
// Upload as an actor who can create: owner always can.
|
|
122
|
+
const uploader = rand() < 0.5 ? owners[o]! : pick(orgActors);
|
|
123
|
+
let id: string;
|
|
124
|
+
try {
|
|
125
|
+
const rec = await fl.upload({ actorId: uploader }, org, {
|
|
126
|
+
name: `f${o}-${f}.bin`,
|
|
127
|
+
contentType: 'application/octet-stream',
|
|
128
|
+
body: bytes(`payload ${o}/${f}`),
|
|
129
|
+
visibility: rand() < 0.5 ? 'org' : 'private',
|
|
130
|
+
...(rand() < 0.15 ? { retainFor: 3600 } : {}),
|
|
131
|
+
});
|
|
132
|
+
id = rec.id;
|
|
133
|
+
} catch {
|
|
134
|
+
// A viewer cannot create; fall back to the owner so the corpus keeps
|
|
135
|
+
// its shape.
|
|
136
|
+
const rec = await fl.upload({ actorId: owners[o]! }, org, {
|
|
137
|
+
name: `f${o}-${f}.bin`,
|
|
138
|
+
contentType: 'application/octet-stream',
|
|
139
|
+
body: bytes(`payload ${o}/${f}`),
|
|
140
|
+
visibility: rand() < 0.5 ? 'org' : 'private',
|
|
141
|
+
});
|
|
142
|
+
id = rec.id;
|
|
143
|
+
}
|
|
144
|
+
filesByOrg.get(org)!.push(id);
|
|
145
|
+
|
|
146
|
+
// Lifecycle mutations the public API will not perform for us.
|
|
147
|
+
const roll = rand();
|
|
148
|
+
if (roll < 0.1) {
|
|
149
|
+
await db.query(`UPDATE file SET state = 'pending' WHERE id = $1`, [id]);
|
|
150
|
+
} else if (roll < 0.2) {
|
|
151
|
+
// `retain_until <= expires_at` is a CHECK, so an expired file cannot
|
|
152
|
+
// also carry a future retention hold. Clearing it keeps the row legal.
|
|
153
|
+
await db.query(
|
|
154
|
+
`UPDATE file SET expires_at = now() - interval '1 hour', retain_until = NULL
|
|
155
|
+
WHERE id = $1`,
|
|
156
|
+
[id],
|
|
157
|
+
);
|
|
158
|
+
} else if (roll < 0.28) {
|
|
159
|
+
await db.query(`UPDATE file SET deleted_at = now() WHERE id = $1`, [id]);
|
|
160
|
+
} else if (roll < 0.33) {
|
|
161
|
+
// A file whose owner has been deleted: owner_id is NULL. The `isOwner`
|
|
162
|
+
// comparison must not treat NULL as "matches everybody".
|
|
163
|
+
await db.query(`UPDATE file SET owner_id = NULL WHERE id = $1`, [id]);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Grants: direct, delegated, cross-org-subject, revoked, expired, exhausted.
|
|
169
|
+
for (const [org, ids] of filesByOrg) {
|
|
170
|
+
const o = orgs.indexOf(org);
|
|
171
|
+
for (const id of ids) {
|
|
172
|
+
if (rand() < 0.45) {
|
|
173
|
+
// A grant to ANY actor in the pool -- including one in another org and
|
|
174
|
+
// one in no org at all. This is legal, and it is the case that breaks a
|
|
175
|
+
// "listing requires membership" design.
|
|
176
|
+
const subject = pick(actors);
|
|
177
|
+
const caps: Capability[] = rand() < 0.3 ? ['read', 'share'] : ['read'];
|
|
178
|
+
await db.query(
|
|
179
|
+
`INSERT INTO file_grant (file_id, org_id, subject_type, subject_id, capabilities,
|
|
180
|
+
expires_at, max_downloads, revoked_at)
|
|
181
|
+
VALUES ($1,$2,'actor',$3,$4::grant_capability[],$5,$6,$7)`,
|
|
182
|
+
[
|
|
183
|
+
id,
|
|
184
|
+
org,
|
|
185
|
+
subject,
|
|
186
|
+
`{${caps.join(',')}}`,
|
|
187
|
+
rand() < 0.2 ? new Date(Date.now() - 3600_000).toISOString() : null,
|
|
188
|
+
rand() < 0.2 ? 1 : null,
|
|
189
|
+
rand() < 0.2 ? new Date().toISOString() : null,
|
|
190
|
+
],
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (rand() < 0.15) {
|
|
194
|
+
await db.query(
|
|
195
|
+
`INSERT INTO file_grant (file_id, org_id, subject_type, capabilities, revoked_at)
|
|
196
|
+
VALUES ($1,$2,'anonymous','{read}'::grant_capability[],$3)`,
|
|
197
|
+
[id, org, rand() < 0.3 ? new Date().toISOString() : null],
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
// GROUP GRANTS (RFC-001). The subject org is picked from the whole pool,
|
|
201
|
+
// so the corpus contains same-org group grants, CROSS-ORG ones (the case
|
|
202
|
+
// the feature exists for), and -- because the last org is soft-deleted at
|
|
203
|
+
// the end of this function -- group grants whose subject org is dead.
|
|
204
|
+
// Every role floor appears, including floors no member of the named org
|
|
205
|
+
// reaches, which is the case where the point check and the set query
|
|
206
|
+
// would disagree if either restated the threshold instead of deriving it.
|
|
207
|
+
if (rand() < 0.35) {
|
|
208
|
+
const subjectOrg = pick(orgs);
|
|
209
|
+
const isRole = rand() < 0.5;
|
|
210
|
+
const caps: Capability[] = rand() < 0.25 ? ['read', 'share'] : ['read'];
|
|
211
|
+
await db.query(
|
|
212
|
+
`INSERT INTO file_grant (file_id, org_id, subject_type, subject_org_id,
|
|
213
|
+
subject_min_role, capabilities, expires_at, revoked_at)
|
|
214
|
+
VALUES ($1,$2,$3,$4,$5,$6::grant_capability[],$7,$8)`,
|
|
215
|
+
[
|
|
216
|
+
id,
|
|
217
|
+
org,
|
|
218
|
+
isRole ? 'role' : 'org',
|
|
219
|
+
subjectOrg,
|
|
220
|
+
isRole ? pick(ROLES) : null,
|
|
221
|
+
`{${caps.join(',')}}`,
|
|
222
|
+
rand() < 0.15 ? new Date(Date.now() - 3600_000).toISOString() : null,
|
|
223
|
+
rand() < 0.15 ? new Date().toISOString() : null,
|
|
224
|
+
],
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
// A delegation chain, so recursive liveness is in play on both sides.
|
|
228
|
+
if (rand() < 0.15) {
|
|
229
|
+
const parent = await db.query<{ id: string }>(
|
|
230
|
+
`INSERT INTO file_grant (file_id, org_id, subject_type, subject_id, capabilities)
|
|
231
|
+
VALUES ($1,$2,'actor',$3,'{read,share}'::grant_capability[]) RETURNING id`,
|
|
232
|
+
[id, org, actors[o * 4]!],
|
|
233
|
+
);
|
|
234
|
+
// Some of these files were soft-deleted above, and a grant on a
|
|
235
|
+
// deleted file is no longer live, so the attenuation trigger refuses to
|
|
236
|
+
// delegate from it. That refusal is the semantic under test, not a
|
|
237
|
+
// corpus bug -- so it is tolerated HERE and asserted to be exactly the
|
|
238
|
+
// documented refusal, rather than swallowed.
|
|
239
|
+
let childId: string | null = null;
|
|
240
|
+
try {
|
|
241
|
+
const child = await db.query<{ id: string }>(
|
|
242
|
+
`INSERT INTO file_grant (file_id, org_id, parent_grant_id, subject_type, subject_id,
|
|
243
|
+
capabilities)
|
|
244
|
+
VALUES ($1,$2,$3,'actor',$4,'{read}'::grant_capability[]) RETURNING id`,
|
|
245
|
+
[id, org, parent.rows[0]!.id, pick(actors)],
|
|
246
|
+
);
|
|
247
|
+
childId = child.rows[0]!.id;
|
|
248
|
+
} catch (err) {
|
|
249
|
+
assert.match((err as Error).message, /^grant_parent_not_live/);
|
|
250
|
+
}
|
|
251
|
+
// Kill the parent half the time: the child must die with it, in BOTH
|
|
252
|
+
// the point check and the set query.
|
|
253
|
+
if (childId !== null && rand() < 0.5) {
|
|
254
|
+
await db.query(`UPDATE file_grant SET revoked_at = now() WHERE id = $1`, [
|
|
255
|
+
parent.rows[0]!.id,
|
|
256
|
+
]);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// A soft-deleted org. Deleting one used to kill memberships and leave grants
|
|
263
|
+
// alive, which is the asymmetry this very test discovered. It now kills both,
|
|
264
|
+
// so the corpus contains an org in which NOTHING is reachable by any path --
|
|
265
|
+
// and the differential test still has to agree about that, element for
|
|
266
|
+
// element, on both sides.
|
|
267
|
+
if (size === 'normal') {
|
|
268
|
+
await db.query(`UPDATE org SET deleted_at = now() WHERE id = $1`, [orgs[nOrgs - 1]!]);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const principals: Principal[] = [
|
|
272
|
+
...actors.map((a) => ({ actorId: a })),
|
|
273
|
+
{ actorId: null }, // anonymous
|
|
274
|
+
{ actorId: '00000000-0000-4000-8000-000000000001' }, // well-formed, unknown
|
|
275
|
+
];
|
|
276
|
+
|
|
277
|
+
return { db, fl, orgs, actors, filesByOrg, principals };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The oracle: the point check, per file, exactly as production runs it.
|
|
282
|
+
*
|
|
283
|
+
* `viaSeen` accumulates the authorization PATHS the corpus actually exercised.
|
|
284
|
+
* A differential test over a corpus that never produces a group grant would
|
|
285
|
+
* pass while proving nothing about group grants, so the paths are counted and
|
|
286
|
+
* asserted rather than assumed.
|
|
287
|
+
*/
|
|
288
|
+
async function oracle(
|
|
289
|
+
c: Corpus,
|
|
290
|
+
principal: Principal,
|
|
291
|
+
org: string,
|
|
292
|
+
capability: Capability,
|
|
293
|
+
viaSeen?: Map<string, number>,
|
|
294
|
+
): Promise<Set<string>> {
|
|
295
|
+
const allowed = new Set<string>();
|
|
296
|
+
for (const id of c.filesByOrg.get(org)!) {
|
|
297
|
+
const d = await authorize(c.fl.store, principal, id, capability);
|
|
298
|
+
if (d.allow) {
|
|
299
|
+
allowed.add(id);
|
|
300
|
+
if (viaSeen) viaSeen.set(d.via, (viaSeen.get(d.via) ?? 0) + 1);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return allowed;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** The subject: the set query, drained through every page. */
|
|
307
|
+
async function subject(
|
|
308
|
+
c: Corpus,
|
|
309
|
+
principal: Principal,
|
|
310
|
+
org: string,
|
|
311
|
+
capability: Capability,
|
|
312
|
+
pageSize = 3,
|
|
313
|
+
): Promise<Set<string>> {
|
|
314
|
+
const seen = new Set<string>();
|
|
315
|
+
let cursor: string | null = null;
|
|
316
|
+
let guard = 0;
|
|
317
|
+
do {
|
|
318
|
+
const page: { files: Array<{ id: string }>; nextCursor: string | null } = await c.fl.listFiles(
|
|
319
|
+
principal,
|
|
320
|
+
org,
|
|
321
|
+
{ capability, limit: pageSize, cursor },
|
|
322
|
+
);
|
|
323
|
+
for (const f of page.files) {
|
|
324
|
+
assert.equal(seen.has(f.id), false, 'pagination returned the same file twice');
|
|
325
|
+
seen.add(f.id);
|
|
326
|
+
}
|
|
327
|
+
cursor = page.nextCursor;
|
|
328
|
+
assert.ok(guard++ < 200, 'pagination did not terminate');
|
|
329
|
+
} while (cursor !== null);
|
|
330
|
+
return seen;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
describe('listFiles agrees with authorize(), exactly (the differential test)', () => {
|
|
334
|
+
// Three independent corpora. Each one is a different random world; a leak
|
|
335
|
+
// that depends on a particular shape has three chances to show up, and the
|
|
336
|
+
// seeds are fixed so a failure is reproducible.
|
|
337
|
+
for (const seed of [20260905, 424242, 7]) {
|
|
338
|
+
it(`seed ${seed}: set equality on every capability, principal and org`, async () => {
|
|
339
|
+
const c = await buildCorpus(seed);
|
|
340
|
+
let comparisons = 0;
|
|
341
|
+
let nonEmpty = 0;
|
|
342
|
+
const viaSeen = new Map<string, number>();
|
|
343
|
+
|
|
344
|
+
for (const org of c.orgs) {
|
|
345
|
+
for (const principal of c.principals) {
|
|
346
|
+
for (const capability of ALL_CAPABILITIES) {
|
|
347
|
+
const expected = await oracle(c, principal, org, capability, viaSeen);
|
|
348
|
+
const actual = await subject(c, principal, org, capability);
|
|
349
|
+
comparisons++;
|
|
350
|
+
if (expected.size > 0) nonEmpty++;
|
|
351
|
+
|
|
352
|
+
const missing = [...expected].filter((x) => !actual.has(x));
|
|
353
|
+
const extra = [...actual].filter((x) => !expected.has(x));
|
|
354
|
+
assert.deepEqual(
|
|
355
|
+
{ missing, extra },
|
|
356
|
+
{ missing: [], extra: [] },
|
|
357
|
+
`seed=${seed} org=${org} actor=${principal.actorId} cap=${capability}\n` +
|
|
358
|
+
` LEAK (in list, not authorized): ${extra.join(', ') || 'none'}\n` +
|
|
359
|
+
` LOSS (authorized, not listed): ${missing.join(', ') || 'none'}`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// A test that compares two empty sets forever would pass vacuously. Assert
|
|
366
|
+
// the corpus actually produced authorized results to compare.
|
|
367
|
+
assert.ok(comparisons > 200, `expected a large comparison count, got ${comparisons}`);
|
|
368
|
+
assert.ok(
|
|
369
|
+
nonEmpty > comparisons * 0.05,
|
|
370
|
+
`corpus too sparse to be meaningful: only ${nonEmpty}/${comparisons} non-empty`,
|
|
371
|
+
);
|
|
372
|
+
// ...and that it exercised the GROUP paths specifically. Without this the
|
|
373
|
+
// test would keep passing if group grants silently stopped conferring
|
|
374
|
+
// anything at all, which is the failure mode a set-vs-point comparison
|
|
375
|
+
// cannot see on its own: both sides would agree on "nothing".
|
|
376
|
+
const groupAllows = (viaSeen.get('grant:org') ?? 0) + (viaSeen.get('grant:role') ?? 0);
|
|
377
|
+
assert.ok(
|
|
378
|
+
groupAllows > 0,
|
|
379
|
+
`seed=${seed}: the corpus produced no group-grant allows, so the ` +
|
|
380
|
+
`differential comparison proves nothing about org/role subjects ` +
|
|
381
|
+
`(paths seen: ${JSON.stringify(Object.fromEntries(viaSeen))})`,
|
|
382
|
+
);
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
it('a corpus of ONLY group grants: set equality still holds exactly', async () => {
|
|
387
|
+
// The general corpus mixes every source of authority, so a group-grant leak
|
|
388
|
+
// could in principle be masked by a role or actor-grant allow on the same
|
|
389
|
+
// file. This world has nothing else in it: no anonymous grants, no actor
|
|
390
|
+
// grants, and the reader is a member of NEITHER org that owns a file, so
|
|
391
|
+
// every allow that happens is a group allow and every disagreement is one.
|
|
392
|
+
const { db } = await createTestDb();
|
|
393
|
+
const fl = new Filelayer(db, new MemoryStorage(), { baseUrl: 'https://t.test' });
|
|
394
|
+
const rand = rng(31337);
|
|
395
|
+
|
|
396
|
+
const owner = (await fl.createActor('go-owner')).id;
|
|
397
|
+
const home = (await fl.createOrg('go-home', 'home', { ownerActorId: owner })).id;
|
|
398
|
+
const partner = (await fl.createOrg('go-partner', 'partner', { ownerActorId: owner })).id;
|
|
399
|
+
|
|
400
|
+
// Readers hold every role in the partner org, and one holds none at all.
|
|
401
|
+
const readers: Array<{ id: string; role: OrgRole | null }> = [];
|
|
402
|
+
for (const role of [...ROLES, null] as Array<OrgRole | null>) {
|
|
403
|
+
const id = (await fl.createActor(`go-reader-${role ?? 'none'}`)).id;
|
|
404
|
+
if (role) await fl.addMember({ actorId: owner }, partner, id, role);
|
|
405
|
+
readers.push({ id, role });
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const files: string[] = [];
|
|
409
|
+
for (let i = 0; i < 12; i++) {
|
|
410
|
+
const f = await fl.upload({ actorId: owner }, home, {
|
|
411
|
+
name: `g${i}.bin`,
|
|
412
|
+
contentType: 'application/octet-stream',
|
|
413
|
+
body: bytes(`g${i}`),
|
|
414
|
+
// 'private' throughout: `visibility='org'` would let the role path
|
|
415
|
+
// answer, and only members of `home` have a role there anyway.
|
|
416
|
+
visibility: 'private',
|
|
417
|
+
});
|
|
418
|
+
files.push(f.id);
|
|
419
|
+
// Every floor, plus the floorless 'org' form, plus a grant naming the
|
|
420
|
+
// WRONG org (home), which must confer nothing on a partner-only reader.
|
|
421
|
+
const shape = i % 6;
|
|
422
|
+
const subjectSpec =
|
|
423
|
+
shape === 0
|
|
424
|
+
? ({ type: 'org', orgId: partner } as const)
|
|
425
|
+
: shape === 5
|
|
426
|
+
? ({ type: 'org', orgId: home } as const)
|
|
427
|
+
: ({ type: 'role', orgId: partner, minRole: ROLES[shape - 1]! } as const);
|
|
428
|
+
const g = await fl.share({ actorId: owner }, f.id, {
|
|
429
|
+
subject: subjectSpec,
|
|
430
|
+
capabilities: rand() < 0.3 ? ['read', 'share'] : ['read'],
|
|
431
|
+
});
|
|
432
|
+
if (rand() < 0.25) await fl.revoke({ actorId: owner }, g.grantId);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const corpus: Corpus = {
|
|
436
|
+
db,
|
|
437
|
+
fl,
|
|
438
|
+
orgs: [home],
|
|
439
|
+
actors: readers.map((r) => r.id),
|
|
440
|
+
filesByOrg: new Map([[home, files]]),
|
|
441
|
+
principals: readers.map((r) => ({ actorId: r.id })),
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
let groupAllows = 0;
|
|
445
|
+
for (const principal of corpus.principals) {
|
|
446
|
+
for (const capability of ALL_CAPABILITIES) {
|
|
447
|
+
const viaSeen = new Map<string, number>();
|
|
448
|
+
const expected = await oracle(corpus, principal, home, capability, viaSeen);
|
|
449
|
+
const actual = await subject(corpus, principal, home, capability);
|
|
450
|
+
groupAllows += (viaSeen.get('grant:org') ?? 0) + (viaSeen.get('grant:role') ?? 0);
|
|
451
|
+
assert.deepEqual(
|
|
452
|
+
{
|
|
453
|
+
missing: [...expected].filter((x) => !actual.has(x)),
|
|
454
|
+
extra: [...actual].filter((x) => !expected.has(x)),
|
|
455
|
+
},
|
|
456
|
+
{ missing: [], extra: [] },
|
|
457
|
+
`group-only corpus: actor=${principal.actorId} cap=${capability}`,
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
assert.ok(groupAllows > 0, 'the group-only corpus authorized nothing at all');
|
|
462
|
+
|
|
463
|
+
// The role FLOOR is real in both paths: the reader with no membership sees
|
|
464
|
+
// nothing, and the viewer sees strictly less than the owner.
|
|
465
|
+
const seen = async (id: string) =>
|
|
466
|
+
(await fl.listFiles({ actorId: id }, home, { limit: 100 })).files.length;
|
|
467
|
+
const none = readers.find((r) => r.role === null)!;
|
|
468
|
+
const viewer = readers.find((r) => r.role === 'viewer')!;
|
|
469
|
+
const ownerRole = readers.find((r) => r.role === 'owner')!;
|
|
470
|
+
assert.equal(await seen(none.id), 0, 'a non-member matched a group grant');
|
|
471
|
+
assert.ok(
|
|
472
|
+
(await seen(viewer.id)) < (await seen(ownerRole.id)),
|
|
473
|
+
'subject_min_role did not narrow anything: the floor is not being applied',
|
|
474
|
+
);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('the negative control: a deliberately widened predicate is caught', async () => {
|
|
478
|
+
// If this test can pass with a broken predicate, it proves nothing. Here we
|
|
479
|
+
// hand the store a predicate that claims every role cell carries `read`
|
|
480
|
+
// regardless of visibility or ownership -- the exact mistake a hand-written
|
|
481
|
+
// listing query makes -- and assert the comparison fails.
|
|
482
|
+
const c = await buildCorpus(99, 'small');
|
|
483
|
+
const org = c.orgs[0]!;
|
|
484
|
+
const broken = {
|
|
485
|
+
capability: 'read' as Capability,
|
|
486
|
+
roleCells: ROLES.flatMap((role) =>
|
|
487
|
+
[false, true].flatMap((isOwner) =>
|
|
488
|
+
(['private', 'org'] as const).map((visibility) => ({ role, isOwner, visibility })),
|
|
489
|
+
),
|
|
490
|
+
),
|
|
491
|
+
lifecycleCells: listPredicate('read').lifecycleCells,
|
|
492
|
+
membershipCells: listPredicate('read').membershipCells,
|
|
493
|
+
anonymousEligible: true,
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
let sawDivergence = false;
|
|
497
|
+
for (const principal of c.principals) {
|
|
498
|
+
if (!principal.actorId) continue;
|
|
499
|
+
const expected = await oracle(c, principal, org, 'read');
|
|
500
|
+
const leaked = await c.fl.store.listAuthorizedFiles({
|
|
501
|
+
orgId: org,
|
|
502
|
+
actorId: principal.actorId,
|
|
503
|
+
predicate: broken,
|
|
504
|
+
now: new Date(),
|
|
505
|
+
limit: 100,
|
|
506
|
+
cursor: null,
|
|
507
|
+
});
|
|
508
|
+
if (leaked.some((f) => !expected.has(f.id))) sawDivergence = true;
|
|
509
|
+
}
|
|
510
|
+
assert.equal(
|
|
511
|
+
sawDivergence,
|
|
512
|
+
true,
|
|
513
|
+
'the differential comparison did not detect an over-permissive predicate; ' +
|
|
514
|
+
'the corpus is not exercising visibility/ownership',
|
|
515
|
+
);
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
describe('listPredicate is derived from the role table, not restated', () => {
|
|
520
|
+
it('every generated role cell agrees with fileCapabilities(), and no cell is missing', () => {
|
|
521
|
+
for (const capability of ALL_CAPABILITIES) {
|
|
522
|
+
const p = listPredicate(capability);
|
|
523
|
+
let expectedCount = 0;
|
|
524
|
+
for (const role of ROLES) {
|
|
525
|
+
for (const isOwner of [false, true]) {
|
|
526
|
+
for (const visibility of ['private', 'org'] as const) {
|
|
527
|
+
const has = fileCapabilities(role, isOwner, visibility).has(capability);
|
|
528
|
+
if (has) expectedCount++;
|
|
529
|
+
const generated = p.roleCells.some(
|
|
530
|
+
(c) => c.role === role && c.isOwner === isOwner && c.visibility === visibility,
|
|
531
|
+
);
|
|
532
|
+
assert.equal(generated, has, `${capability}/${role}/owner=${isOwner}/${visibility}`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
assert.equal(p.roleCells.length, expectedCount);
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('the lifecycle cells exclude deleted, expired, and (for read) pending files', () => {
|
|
541
|
+
const read = listPredicate('read');
|
|
542
|
+
assert.equal(read.lifecycleCells.some((c) => c.state === 'deleted'), false);
|
|
543
|
+
assert.equal(read.lifecycleCells.some((c) => c.expired), false);
|
|
544
|
+
assert.equal(read.lifecycleCells.some((c) => c.state === 'pending'), false);
|
|
545
|
+
// Delete is blocked by a retention hold, and only delete is.
|
|
546
|
+
const del = listPredicate('delete');
|
|
547
|
+
assert.equal(del.lifecycleCells.some((c) => c.retained), false);
|
|
548
|
+
assert.equal(listPredicate('write').lifecycleCells.some((c) => c.retained), true);
|
|
549
|
+
// Only `read` consults an anonymous grant, mirroring resolveStanding.
|
|
550
|
+
assert.equal(read.anonymousEligible, true);
|
|
551
|
+
assert.equal(listPredicate('share').anonymousEligible, false);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('the membership cells are exactly roleMeets(), for every (floor, held) pair', () => {
|
|
555
|
+
// The group threshold is the one rule the set query could plausibly have
|
|
556
|
+
// restated as `m.role >= g.subject_min_role`. It is derived instead, so
|
|
557
|
+
// assert the derivation is total and faithful: 16 pairs probed, 10 hold.
|
|
558
|
+
const cells = membershipCells();
|
|
559
|
+
let expected = 0;
|
|
560
|
+
for (const minRole of ROLES) {
|
|
561
|
+
for (const role of ROLES) {
|
|
562
|
+
const holds = roleMeets(role, minRole);
|
|
563
|
+
if (holds) expected++;
|
|
564
|
+
assert.equal(
|
|
565
|
+
cells.some((c) => c.minRole === minRole && c.role === role),
|
|
566
|
+
holds,
|
|
567
|
+
`floor=${minRole} held=${role}`,
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
assert.equal(cells.length, expected);
|
|
572
|
+
assert.equal(expected, 10);
|
|
573
|
+
// Every capability's predicate carries the same cells: a group grant is not
|
|
574
|
+
// capability-gated the way an anonymous grant is.
|
|
575
|
+
for (const capability of ALL_CAPABILITIES) {
|
|
576
|
+
assert.deepEqual(listPredicate(capability).membershipCells, cells);
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it('the generated threshold SQL is a closed tuple list, not an inequality', () => {
|
|
581
|
+
// If this ever becomes `>=`, the role ordering lives in the enum's
|
|
582
|
+
// declaration order as well as in ROLE_RANK, and reordering the enum
|
|
583
|
+
// silently changes who can read what. Assert the shape.
|
|
584
|
+
const sql = membershipCellSql(membershipCells());
|
|
585
|
+
assert.match(sql, /IN \(/);
|
|
586
|
+
assert.equal(/[<>]=?/.test(sql), false, `threshold SQL contains a comparison: ${sql}`);
|
|
587
|
+
assert.equal(membershipCellSql([]), 'false', 'an empty cell list must fail closed');
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
describe('listFiles fails closed at its edges', () => {
|
|
592
|
+
it('refuses a principal carrying a link secret rather than silently ignoring it', async () => {
|
|
593
|
+
const c = await buildCorpus(11, 'small');
|
|
594
|
+
await rejects(
|
|
595
|
+
() => c.fl.listFiles({ actorId: null, linkSecret: 'anything' }, c.orgs[0]!),
|
|
596
|
+
400,
|
|
597
|
+
'link_principal_cannot_list',
|
|
598
|
+
);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it('a malformed cursor is refused, not treated as "no cursor"', async () => {
|
|
602
|
+
const c = await buildCorpus(12, 'small');
|
|
603
|
+
await rejects(() => c.fl.listFiles({ actorId: null }, c.orgs[0]!, { cursor: 'nope' }), 400);
|
|
604
|
+
await rejects(
|
|
605
|
+
() =>
|
|
606
|
+
c.fl.listFiles({ actorId: null }, c.orgs[0]!, {
|
|
607
|
+
cursor: Buffer.from('2020-01-01T00:00:00.000Z|not-a-uuid').toString('base64url'),
|
|
608
|
+
}),
|
|
609
|
+
400,
|
|
610
|
+
);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it('a nonexistent org and an org you cannot see are indistinguishable', async () => {
|
|
614
|
+
const c = await buildCorpus(13, 'small');
|
|
615
|
+
// A fresh actor, and no anonymous grants: a live anonymous grant is
|
|
616
|
+
// readable by ANY principal, so leaving one in place would make this
|
|
617
|
+
// assertion pass or fail on a coin flip.
|
|
618
|
+
await c.db.query(`DELETE FROM file_grant WHERE subject_type = 'anonymous'`);
|
|
619
|
+
const outsider = { actorId: (await c.fl.createActor('outsider-13')).id };
|
|
620
|
+
const real = await c.fl.listFiles(outsider, c.orgs[0]!);
|
|
621
|
+
const fake = await c.fl.listFiles(outsider, '00000000-0000-4000-8000-00000000dead');
|
|
622
|
+
const junk = await c.fl.listFiles(outsider, 'not-a-uuid');
|
|
623
|
+
assert.deepEqual(real, fake);
|
|
624
|
+
assert.deepEqual(real, junk);
|
|
625
|
+
assert.deepEqual(real.files, []);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it('the page size is clamped, so one request cannot become an unbounded scan', async () => {
|
|
629
|
+
const c = await buildCorpus(14, 'small');
|
|
630
|
+
const owner = { actorId: c.actors[0]! };
|
|
631
|
+
const page = await c.fl.listFiles(owner, c.orgs[0]!, { limit: 10_000 });
|
|
632
|
+
assert.ok(page.files.length <= 200);
|
|
633
|
+
const one = await c.fl.listFiles(owner, c.orgs[0]!, { limit: 0 });
|
|
634
|
+
assert.ok(one.files.length <= 1);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it('a list emits exactly ONE audit event, naming the files it disclosed', async () => {
|
|
638
|
+
const c = await buildCorpus(15, 'small');
|
|
639
|
+
const org = c.orgs[0]!;
|
|
640
|
+
const before = await c.db.query<{ n: number }>(
|
|
641
|
+
`SELECT count(*)::int AS n FROM audit_event WHERE action = 'file.list'`,
|
|
642
|
+
);
|
|
643
|
+
const page = await c.fl.listFiles({ actorId: c.actors[0]! }, org, { limit: 100 });
|
|
644
|
+
const after = await c.db.query<{ n: number }>(
|
|
645
|
+
`SELECT count(*)::int AS n FROM audit_event WHERE action = 'file.list'`,
|
|
646
|
+
);
|
|
647
|
+
assert.equal(Number(after.rows[0]!.n) - Number(before.rows[0]!.n), 1);
|
|
648
|
+
|
|
649
|
+
const { rows } = await c.db.query<Record<string, unknown>>(
|
|
650
|
+
`SELECT decision, reason, context FROM audit_event
|
|
651
|
+
WHERE action = 'file.list' ORDER BY id DESC LIMIT 1`,
|
|
652
|
+
);
|
|
653
|
+
const ctx =
|
|
654
|
+
typeof rows[0]!['context'] === 'string'
|
|
655
|
+
? JSON.parse(rows[0]!['context'] as string)
|
|
656
|
+
: (rows[0]!['context'] as Record<string, unknown>);
|
|
657
|
+
assert.equal(ctx['count'], page.files.length);
|
|
658
|
+
assert.deepEqual(ctx['fileIds'], page.files.map((f) => f.id));
|
|
659
|
+
assert.equal(ctx['capability'], 'read');
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it('an empty list from a non-member is recorded as a DENY, so enumeration is visible', async () => {
|
|
663
|
+
const c = await buildCorpus(16, 'small');
|
|
664
|
+
// A freshly created actor: no membership anywhere, and no grant can name it
|
|
665
|
+
// because it did not exist when the corpus was built. Anonymous grants are
|
|
666
|
+
// cleared for the same reason -- they are readable by everyone, including
|
|
667
|
+
// this actor, which would make the list non-empty for a legitimate reason.
|
|
668
|
+
await c.db.query(`DELETE FROM file_grant WHERE subject_type = 'anonymous'`);
|
|
669
|
+
const outsider = (await c.fl.createActor('outsider-16')).id;
|
|
670
|
+
await c.fl.listFiles({ actorId: outsider }, c.orgs[0]!);
|
|
671
|
+
const { rows } = await c.db.query<Record<string, unknown>>(
|
|
672
|
+
`SELECT decision, reason FROM audit_event
|
|
673
|
+
WHERE action = 'file.list' AND actor_id = $1 ORDER BY id DESC LIMIT 1`,
|
|
674
|
+
[outsider],
|
|
675
|
+
);
|
|
676
|
+
assert.equal(rows[0]!['decision'], 'deny');
|
|
677
|
+
assert.equal(rows[0]!['reason'], 'no_membership');
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it('probing an org that does not exist goes to the SYSTEM chain, not a guessed tenant', async () => {
|
|
681
|
+
const c = await buildCorpus(17, 'small');
|
|
682
|
+
await c.fl.listFiles({ actorId: c.actors[0]! }, '00000000-0000-4000-8000-0000000000ff');
|
|
683
|
+
const { rows } = await c.db.query<{ n: number }>(
|
|
684
|
+
`SELECT count(*)::int AS n FROM audit_event
|
|
685
|
+
WHERE action = 'file.list' AND org_id IS NULL`,
|
|
686
|
+
);
|
|
687
|
+
assert.ok(Number(rows[0]!.n) >= 1);
|
|
688
|
+
});
|
|
689
|
+
});
|