@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,1072 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GROUP GRANT SUBJECTS -- RFC-001.
|
|
3
|
+
*
|
|
4
|
+
* grant_subject := actor | org | role | link | anonymous
|
|
5
|
+
*
|
|
6
|
+
* A grant's subject is a PRINCIPAL SET. `org` and `role` are the missing middle
|
|
7
|
+
* between "one person" and "everyone", and they are resolved BY JOIN against
|
|
8
|
+
* `membership`, never by fanning a group out into per-member rows.
|
|
9
|
+
*
|
|
10
|
+
* The invariants this file asserts, in the order the RFC states them:
|
|
11
|
+
*
|
|
12
|
+
* I1/P3/P8 the subject org must be in the same PROJECT as the file, by
|
|
13
|
+
* composite foreign key. Cross-ORG within a project is legal and is
|
|
14
|
+
* the point; cross-PROJECT is unrepresentable.
|
|
15
|
+
* I2/P7 a grant is live only while its subject org is live.
|
|
16
|
+
* I3/P4 group grants are ordinary rows: revocable, delegable, and killed
|
|
17
|
+
* transitively by revoking an ancestor.
|
|
18
|
+
* I4/P5 `via` gains `grant:org` / `grant:role`, and the audit event names
|
|
19
|
+
* the membership that conferred access.
|
|
20
|
+
* I5/P1 there is still no boolean anywhere that opens a file to a
|
|
21
|
+
* population.
|
|
22
|
+
* I6 DELEGATION MAY ATTENUATE AUTHORITY BUT MAY NEVER AMPLIFY SUBJECT
|
|
23
|
+
* BREADTH. Enforced in the engine AND in the kernel, so it binds a
|
|
24
|
+
* caller issuing raw SQL. Tested both ways, every case.
|
|
25
|
+
*
|
|
26
|
+
* ...and the property the whole design exists for:
|
|
27
|
+
*
|
|
28
|
+
* MEMBERSHIP CHANGES CHANGE ACCESS ON THE NEXT REQUEST, WITHOUT
|
|
29
|
+
* RECOMPUTATION. Asserted by comparing every `file_grant` row before and
|
|
30
|
+
* after a join and a leave: not one row is written, updated, or touched.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { describe, it } from 'node:test';
|
|
34
|
+
import assert from 'node:assert/strict';
|
|
35
|
+
import {
|
|
36
|
+
authorize,
|
|
37
|
+
DELEGABLE_SUBJECT_TYPES,
|
|
38
|
+
type GrantSubjectType,
|
|
39
|
+
type OrgRole,
|
|
40
|
+
} from '../src/authz.ts';
|
|
41
|
+
import { createTestDb } from '../src/db.ts';
|
|
42
|
+
import { Filelayer } from '../src/filelayer.ts';
|
|
43
|
+
import { MemoryStorage } from '../src/storage.ts';
|
|
44
|
+
import { newWorld, bytes, rejects, dbRejects, type World } from './helpers.ts';
|
|
45
|
+
|
|
46
|
+
const ROLES: OrgRole[] = ['viewer', 'member', 'admin', 'owner'];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Two orgs in ONE project: `home` owns the document, `partner` is the other
|
|
50
|
+
* company. This is the shape of every case the RFC opens with -- the job board,
|
|
51
|
+
* the client portal, the supplier -- and it is the shape a synthetic robot actor
|
|
52
|
+
* plus an impedance-matching table used to be needed for.
|
|
53
|
+
*/
|
|
54
|
+
interface TwoOrgs extends World {
|
|
55
|
+
alice: string; // owner of `home`
|
|
56
|
+
home: string;
|
|
57
|
+
bosses: string; // owner of `partner`
|
|
58
|
+
partner: string;
|
|
59
|
+
members: Record<OrgRole, string>; // one actor of `partner` at each role
|
|
60
|
+
fileId: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function twoOrgs(): Promise<TwoOrgs> {
|
|
64
|
+
const w = await newWorld();
|
|
65
|
+
const alice = (await w.fl.createActor('alice')).id;
|
|
66
|
+
const bosses = (await w.fl.createActor('bosses')).id;
|
|
67
|
+
const home = (await w.fl.createOrg('home', 'Home', { ownerActorId: alice })).id;
|
|
68
|
+
const partner = (await w.fl.createOrg('partner', 'Partner', { ownerActorId: bosses })).id;
|
|
69
|
+
|
|
70
|
+
const members = {} as Record<OrgRole, string>;
|
|
71
|
+
for (const role of ROLES) {
|
|
72
|
+
if (role === 'owner') {
|
|
73
|
+
members.owner = bosses;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const id = (await w.fl.createActor(`partner-${role}`)).id;
|
|
77
|
+
await w.fl.addMember({ actorId: bosses }, partner, id, role);
|
|
78
|
+
members[role] = id;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const file = await w.fl.upload({ actorId: alice }, home, {
|
|
82
|
+
name: 'cv.pdf',
|
|
83
|
+
contentType: 'application/pdf',
|
|
84
|
+
body: bytes('CONFIDENTIAL CV'),
|
|
85
|
+
// private throughout: nothing here may be explained by the role path.
|
|
86
|
+
visibility: 'private',
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return { ...w, alice, home, bosses, partner, members, fileId: file.id };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const can = async (w: World, actorId: string | null, fileId: string, cap = 'read' as const) =>
|
|
93
|
+
(await authorize(w.fl.store, { actorId }, fileId, cap)).allow;
|
|
94
|
+
|
|
95
|
+
/** Every column of every grant row, ordered -- a fingerprint of the table. */
|
|
96
|
+
async function grantSnapshot(w: World): Promise<string> {
|
|
97
|
+
const { rows } = await w.db.query<{ s: string | null }>(
|
|
98
|
+
`SELECT coalesce(string_agg(t.r, '|' ORDER BY t.r), '') AS s
|
|
99
|
+
FROM (SELECT g::text AS r FROM file_grant g) t`,
|
|
100
|
+
);
|
|
101
|
+
return rows[0]!.s ?? '';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// =============================================================================
|
|
105
|
+
// THE SUBJECT TYPES THEMSELVES
|
|
106
|
+
// =============================================================================
|
|
107
|
+
|
|
108
|
+
describe('RFC-001: org and role are grant subjects, not a new mechanism', () => {
|
|
109
|
+
it('an `org` grant reaches EVERY member of the named org, at any role', async () => {
|
|
110
|
+
const w = await twoOrgs();
|
|
111
|
+
for (const role of ROLES) {
|
|
112
|
+
assert.equal(await can(w, w.members[role], w.fileId), false, `${role} before the grant`);
|
|
113
|
+
}
|
|
114
|
+
await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
115
|
+
subject: { type: 'org', orgId: w.partner },
|
|
116
|
+
});
|
|
117
|
+
for (const role of ROLES) {
|
|
118
|
+
assert.equal(await can(w, w.members[role], w.fileId), true, `${role} after the grant`);
|
|
119
|
+
}
|
|
120
|
+
// ...and nobody else. An actor in no org matches nothing.
|
|
121
|
+
const stranger = (await w.fl.createActor('stranger')).id;
|
|
122
|
+
assert.equal(await can(w, stranger, w.fileId), false);
|
|
123
|
+
assert.equal(await can(w, null, w.fileId), false, 'anonymous matched a group grant');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('a `role` grant applies the floor, and the floor is a threshold over the existing enum', async () => {
|
|
127
|
+
const w = await twoOrgs();
|
|
128
|
+
await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
129
|
+
subject: { type: 'role', orgId: w.partner, minRole: 'admin' },
|
|
130
|
+
});
|
|
131
|
+
assert.equal(await can(w, w.members.viewer, w.fileId), false);
|
|
132
|
+
assert.equal(await can(w, w.members.member, w.fileId), false);
|
|
133
|
+
assert.equal(await can(w, w.members.admin, w.fileId), true);
|
|
134
|
+
assert.equal(await can(w, w.members.owner, w.fileId), true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('every floor behaves as "that role or above", exhaustively', async () => {
|
|
138
|
+
for (const floor of ROLES) {
|
|
139
|
+
const w = await twoOrgs();
|
|
140
|
+
await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
141
|
+
subject: { type: 'role', orgId: w.partner, minRole: floor },
|
|
142
|
+
});
|
|
143
|
+
const rank = ROLES.indexOf(floor);
|
|
144
|
+
for (const held of ROLES) {
|
|
145
|
+
assert.equal(
|
|
146
|
+
await can(w, w.members[held], w.fileId),
|
|
147
|
+
ROLES.indexOf(held) >= rank,
|
|
148
|
+
`floor=${floor} held=${held}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('`org` is `role` with the floor at viewer -- the two agree exactly', async () => {
|
|
155
|
+
const a = await twoOrgs();
|
|
156
|
+
await a.fl.share({ actorId: a.alice }, a.fileId, {
|
|
157
|
+
subject: { type: 'org', orgId: a.partner },
|
|
158
|
+
});
|
|
159
|
+
const b = await twoOrgs();
|
|
160
|
+
await b.fl.share({ actorId: b.alice }, b.fileId, {
|
|
161
|
+
subject: { type: 'role', orgId: b.partner, minRole: 'viewer' },
|
|
162
|
+
});
|
|
163
|
+
for (const role of ROLES) {
|
|
164
|
+
assert.equal(await can(a, a.members[role], a.fileId), await can(b, b.members[role], b.fileId));
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('a group grant carries capabilities like any other grant, and only what it was given', async () => {
|
|
169
|
+
const w = await twoOrgs();
|
|
170
|
+
await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
171
|
+
subject: { type: 'role', orgId: w.partner, minRole: 'member' },
|
|
172
|
+
capabilities: ['read', 'write'],
|
|
173
|
+
});
|
|
174
|
+
const p = { actorId: w.members.admin };
|
|
175
|
+
assert.equal((await authorize(w.fl.store, p, w.fileId, 'read')).allow, true);
|
|
176
|
+
assert.equal((await authorize(w.fl.store, p, w.fileId, 'write')).allow, true);
|
|
177
|
+
assert.equal((await authorize(w.fl.store, p, w.fileId, 'delete')).allow, false);
|
|
178
|
+
assert.equal((await authorize(w.fl.store, p, w.fileId, 'share')).allow, false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('I3: a group grant is an ordinary row -- revoking it is immediate for the whole population', async () => {
|
|
182
|
+
const w = await twoOrgs();
|
|
183
|
+
const g = await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
184
|
+
subject: { type: 'org', orgId: w.partner },
|
|
185
|
+
});
|
|
186
|
+
assert.equal(await can(w, w.members.viewer, w.fileId), true);
|
|
187
|
+
await w.fl.revoke({ actorId: w.alice }, g.grantId);
|
|
188
|
+
for (const role of ROLES) {
|
|
189
|
+
assert.equal(await can(w, w.members[role], w.fileId), false, `${role} after revoke`);
|
|
190
|
+
}
|
|
191
|
+
// One revoked row. Nothing cascaded, nothing was fanned out to undo.
|
|
192
|
+
const { rows } = await w.db.query<{ n: number }>(
|
|
193
|
+
`SELECT count(*)::int AS n FROM file_grant WHERE file_id = $1`,
|
|
194
|
+
[w.fileId],
|
|
195
|
+
);
|
|
196
|
+
assert.equal(Number(rows[0]!.n), 1);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('I3: a group grant expires, and caps downloads, like any other grant', async () => {
|
|
200
|
+
const w = await twoOrgs();
|
|
201
|
+
await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
202
|
+
subject: { type: 'org', orgId: w.partner },
|
|
203
|
+
maxDownloads: 1,
|
|
204
|
+
});
|
|
205
|
+
const p = { actorId: w.members.member };
|
|
206
|
+
await w.fl.read(p, w.fileId);
|
|
207
|
+
await rejects(() => w.fl.read(p, w.fileId), 404);
|
|
208
|
+
// The cap binds the CREDENTIAL, so it is spent for the whole population --
|
|
209
|
+
// a second member does not get a fresh budget.
|
|
210
|
+
await rejects(() => w.fl.read({ actorId: w.members.viewer }, w.fileId), 404);
|
|
211
|
+
|
|
212
|
+
const w2 = await twoOrgs();
|
|
213
|
+
await w2.db.query(
|
|
214
|
+
`INSERT INTO file_grant (file_id, org_id, subject_type, subject_org_id, capabilities, expires_at)
|
|
215
|
+
VALUES ($1,$2,'org',$3,'{read}'::grant_capability[], now() - interval '1 hour')`,
|
|
216
|
+
[w2.fileId, w2.home, w2.partner],
|
|
217
|
+
);
|
|
218
|
+
assert.equal(await can(w2, w2.members.owner, w2.fileId), false, 'an expired org grant allowed');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('the group grant is visible and describable in listGrants', async () => {
|
|
222
|
+
const w = await twoOrgs();
|
|
223
|
+
await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
224
|
+
subject: { type: 'role', orgId: w.partner, minRole: 'admin' },
|
|
225
|
+
});
|
|
226
|
+
const grants = await w.fl.listGrants({ actorId: w.alice }, w.fileId);
|
|
227
|
+
const g = grants.find((x) => x.subjectType === 'role')!;
|
|
228
|
+
assert.ok(g, 'the role grant is not listed');
|
|
229
|
+
assert.equal(g.subjectOrgId, w.partner);
|
|
230
|
+
assert.equal(g.subjectMinRole, 'admin');
|
|
231
|
+
assert.equal(g.subjectId, null);
|
|
232
|
+
assert.equal(g.live, true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('I5: a group grant is a ROW, not a flag -- there is still no boolean that opens a file', async () => {
|
|
236
|
+
const w = await twoOrgs();
|
|
237
|
+
await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
238
|
+
subject: { type: 'org', orgId: w.partner },
|
|
239
|
+
});
|
|
240
|
+
// No new column on `file`, and `visibility` is untouched by sharing.
|
|
241
|
+
const { rows } = await w.db.query<Record<string, unknown>>(
|
|
242
|
+
`SELECT column_name FROM information_schema.columns WHERE table_name = 'file'`,
|
|
243
|
+
);
|
|
244
|
+
const names = rows.map((r) => String(r['column_name']));
|
|
245
|
+
for (const n of names) {
|
|
246
|
+
assert.equal(
|
|
247
|
+
/^(is_|allow_|public|shared|open)/.test(n),
|
|
248
|
+
false,
|
|
249
|
+
`a population-opening boolean appeared on file: ${n}`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
const f = await w.fl.stat({ actorId: w.alice }, w.fileId);
|
|
253
|
+
assert.equal(f.visibility, 'private');
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// =============================================================================
|
|
258
|
+
// THE PROPERTY THE WHOLE DESIGN EXISTS FOR
|
|
259
|
+
// =============================================================================
|
|
260
|
+
|
|
261
|
+
describe('membership changes access on the NEXT REQUEST, with no recomputation', () => {
|
|
262
|
+
/**
|
|
263
|
+
* THE HEADLINE PROPERTY, AND THE ONLY WAY TO PROVE IT.
|
|
264
|
+
*
|
|
265
|
+
* "Access changes immediately" is not provable by timing; it is provable by
|
|
266
|
+
* showing there was nothing to change. So this test fingerprints every column
|
|
267
|
+
* of every `file_grant` row before and after a join and a leave, and asserts
|
|
268
|
+
* the fingerprint is byte-identical while the ANSWER flips. If a future
|
|
269
|
+
* implementation ever materializes a group into per-member rows -- which is
|
|
270
|
+
* the obvious "optimization" -- the fingerprint moves and this fails.
|
|
271
|
+
*/
|
|
272
|
+
it('adding a member grants access, and writes NO grant row', async () => {
|
|
273
|
+
const w = await twoOrgs();
|
|
274
|
+
await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
275
|
+
subject: { type: 'org', orgId: w.partner },
|
|
276
|
+
});
|
|
277
|
+
const newcomer = (await w.fl.createActor('newcomer')).id;
|
|
278
|
+
assert.equal(await can(w, newcomer, w.fileId), false);
|
|
279
|
+
|
|
280
|
+
const before = await grantSnapshot(w);
|
|
281
|
+
const beforeCount = (
|
|
282
|
+
await w.db.query<{ n: number }>(`SELECT count(*)::int AS n FROM file_grant`)
|
|
283
|
+
).rows[0]!.n;
|
|
284
|
+
|
|
285
|
+
await w.fl.addMember({ actorId: w.bosses }, w.partner, newcomer, 'viewer');
|
|
286
|
+
|
|
287
|
+
assert.equal(await can(w, newcomer, w.fileId), true, 'the join did not confer access');
|
|
288
|
+
const after = await grantSnapshot(w);
|
|
289
|
+
const afterCount = (
|
|
290
|
+
await w.db.query<{ n: number }>(`SELECT count(*)::int AS n FROM file_grant`)
|
|
291
|
+
).rows[0]!.n;
|
|
292
|
+
|
|
293
|
+
assert.equal(afterCount, beforeCount, 'a grant row was created by a membership change');
|
|
294
|
+
assert.equal(after, before, 'a grant row was MODIFIED by a membership change');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('removing a member revokes access, and writes NO grant row', async () => {
|
|
298
|
+
const w = await twoOrgs();
|
|
299
|
+
await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
300
|
+
subject: { type: 'org', orgId: w.partner },
|
|
301
|
+
});
|
|
302
|
+
assert.equal(await can(w, w.members.viewer, w.fileId), true);
|
|
303
|
+
|
|
304
|
+
const before = await grantSnapshot(w);
|
|
305
|
+
const beforeCount = (
|
|
306
|
+
await w.db.query<{ n: number }>(`SELECT count(*)::int AS n FROM file_grant`)
|
|
307
|
+
).rows[0]!.n;
|
|
308
|
+
|
|
309
|
+
await w.fl.removeMember({ actorId: w.bosses }, w.partner, w.members.viewer);
|
|
310
|
+
|
|
311
|
+
assert.equal(await can(w, w.members.viewer, w.fileId), false, 'the leave did not end access');
|
|
312
|
+
const after = await grantSnapshot(w);
|
|
313
|
+
const afterCount = (
|
|
314
|
+
await w.db.query<{ n: number }>(`SELECT count(*)::int AS n FROM file_grant`)
|
|
315
|
+
).rows[0]!.n;
|
|
316
|
+
|
|
317
|
+
assert.equal(afterCount, beforeCount, 'a grant row was deleted by a membership change');
|
|
318
|
+
assert.equal(after, before, 'a grant row was MODIFIED by a membership change');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('a ROLE CHANGE moves a person across the floor in both directions, still with no grant write', async () => {
|
|
322
|
+
const w = await twoOrgs();
|
|
323
|
+
await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
324
|
+
subject: { type: 'role', orgId: w.partner, minRole: 'admin' },
|
|
325
|
+
});
|
|
326
|
+
const before = await grantSnapshot(w);
|
|
327
|
+
const target = w.members.member;
|
|
328
|
+
assert.equal(await can(w, target, w.fileId), false);
|
|
329
|
+
|
|
330
|
+
await w.fl.addMember({ actorId: w.bosses }, w.partner, target, 'admin');
|
|
331
|
+
assert.equal(await can(w, target, w.fileId), true, 'promotion did not confer access');
|
|
332
|
+
|
|
333
|
+
await w.fl.addMember({ actorId: w.bosses }, w.partner, target, 'viewer');
|
|
334
|
+
assert.equal(await can(w, target, w.fileId), false, 'demotion did not remove access');
|
|
335
|
+
|
|
336
|
+
assert.equal(await grantSnapshot(w), before, 'a role change touched a grant row');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('the same is true of the LISTING path: no grant row, and the set answer moves too', async () => {
|
|
340
|
+
const w = await twoOrgs();
|
|
341
|
+
await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
342
|
+
subject: { type: 'org', orgId: w.partner },
|
|
343
|
+
});
|
|
344
|
+
const newcomer = (await w.fl.createActor('list-newcomer')).id;
|
|
345
|
+
const seen = async () =>
|
|
346
|
+
(await w.fl.listFiles({ actorId: newcomer }, w.home, { limit: 50 })).files.map((f) => f.id);
|
|
347
|
+
|
|
348
|
+
assert.deepEqual(await seen(), []);
|
|
349
|
+
const before = await grantSnapshot(w);
|
|
350
|
+
await w.fl.addMember({ actorId: w.bosses }, w.partner, newcomer, 'member');
|
|
351
|
+
assert.deepEqual(await seen(), [w.fileId], 'the set query did not see the new membership');
|
|
352
|
+
assert.equal(await grantSnapshot(w), before);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('there is no materialization: N members cost N membership rows and ONE grant row', async () => {
|
|
356
|
+
const w = await twoOrgs();
|
|
357
|
+
for (let i = 0; i < 25; i++) {
|
|
358
|
+
const id = (await w.fl.createActor(`bulk-${i}`)).id;
|
|
359
|
+
await w.fl.addMember({ actorId: w.bosses }, w.partner, id, 'member');
|
|
360
|
+
}
|
|
361
|
+
await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
362
|
+
subject: { type: 'org', orgId: w.partner },
|
|
363
|
+
});
|
|
364
|
+
const { rows } = await w.db.query<{ n: number }>(
|
|
365
|
+
`SELECT count(*)::int AS n FROM file_grant WHERE file_id = $1`,
|
|
366
|
+
[w.fileId],
|
|
367
|
+
);
|
|
368
|
+
assert.equal(Number(rows[0]!.n), 1, 'the group was fanned out into per-member rows');
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// =============================================================================
|
|
373
|
+
// I1 / P3 / P8 -- CROSS-ORG YES, CROSS-PROJECT UNREPRESENTABLE
|
|
374
|
+
// =============================================================================
|
|
375
|
+
|
|
376
|
+
describe('I1: the subject org must be in the same PROJECT as the file', () => {
|
|
377
|
+
it('a CROSS-ORG grant inside one project works -- this is the point of the feature', async () => {
|
|
378
|
+
const w = await twoOrgs();
|
|
379
|
+
// The grant's own org is the FILE's org; the subject org is a different
|
|
380
|
+
// tenant entirely. Both facts hold on the same row.
|
|
381
|
+
await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
382
|
+
subject: { type: 'org', orgId: w.partner },
|
|
383
|
+
});
|
|
384
|
+
const { rows } = await w.db.query<{ org_id: string; subject_org_id: string }>(
|
|
385
|
+
`SELECT org_id, subject_org_id FROM file_grant WHERE file_id = $1`,
|
|
386
|
+
[w.fileId],
|
|
387
|
+
);
|
|
388
|
+
assert.equal(rows[0]!.org_id, w.home);
|
|
389
|
+
assert.equal(rows[0]!.subject_org_id, w.partner);
|
|
390
|
+
assert.notEqual(rows[0]!.org_id, rows[0]!.subject_org_id);
|
|
391
|
+
assert.equal(await can(w, w.members.member, w.fileId), true);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('the DATABASE refuses a subject org in another project (composite FK, not a WHERE clause)', async () => {
|
|
395
|
+
const { db } = await createTestDb();
|
|
396
|
+
const storage = new MemoryStorage();
|
|
397
|
+
const a = new Filelayer(db, storage, { baseUrl: 'https://a.test' });
|
|
398
|
+
const pb = (await a.createProject('customer-b', 'Customer B')).id;
|
|
399
|
+
const b = new Filelayer(db, storage, { baseUrl: 'https://b.test', projectId: pb });
|
|
400
|
+
|
|
401
|
+
const alice = (await a.createActor('a-alice')).id;
|
|
402
|
+
const orgA = (await a.createOrg('a-org', 'A', { ownerActorId: alice })).id;
|
|
403
|
+
const mallory = (await b.createActor('b-mallory')).id;
|
|
404
|
+
const orgB = (await b.createOrg('b-org', 'B', { ownerActorId: mallory })).id;
|
|
405
|
+
|
|
406
|
+
const file = await a.upload({ actorId: alice }, orgA, {
|
|
407
|
+
name: 'a.pdf',
|
|
408
|
+
contentType: 'application/pdf',
|
|
409
|
+
body: bytes('A'),
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// Raw SQL, engine bypassed entirely. `project_id` is derived from the
|
|
413
|
+
// file's org by trigger, so the composite FK has nothing to negotiate with.
|
|
414
|
+
await dbRejects(
|
|
415
|
+
db,
|
|
416
|
+
`INSERT INTO file_grant (file_id, org_id, subject_type, subject_org_id, capabilities)
|
|
417
|
+
VALUES ($1,$2,'org',$3,'{read}'::grant_capability[])`,
|
|
418
|
+
[file.id, orgA, orgB],
|
|
419
|
+
/file_grant_subject_org_id_project_id_fkey|foreign key/i,
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
// ...and through the API it is the uniform 404, not a constraint error and
|
|
423
|
+
// not a message that would confirm the org id resolves to a row somewhere.
|
|
424
|
+
await rejects(
|
|
425
|
+
() => a.share({ actorId: alice }, file.id, { subject: { type: 'org', orgId: orgB } }),
|
|
426
|
+
404,
|
|
427
|
+
'not_found',
|
|
428
|
+
);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('a subject org that does not exist at all is the same 404', async () => {
|
|
432
|
+
const w = await twoOrgs();
|
|
433
|
+
await rejects(
|
|
434
|
+
() =>
|
|
435
|
+
w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
436
|
+
subject: { type: 'org', orgId: '00000000-0000-4000-8000-0000000000ff' },
|
|
437
|
+
}),
|
|
438
|
+
404,
|
|
439
|
+
'not_found',
|
|
440
|
+
);
|
|
441
|
+
await rejects(
|
|
442
|
+
() =>
|
|
443
|
+
w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
444
|
+
subject: { type: 'org', orgId: 'not-a-uuid' },
|
|
445
|
+
}),
|
|
446
|
+
404,
|
|
447
|
+
'not_found',
|
|
448
|
+
);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('the file-owning org may name ITSELF, and that is just an explicit org grant', async () => {
|
|
452
|
+
const w = await twoOrgs();
|
|
453
|
+
const bystander = (await w.fl.createActor('home-viewer')).id;
|
|
454
|
+
await w.fl.addMember({ actorId: w.alice }, w.home, bystander, 'viewer');
|
|
455
|
+
// Private file, viewer role, not the owner: no role-derived access.
|
|
456
|
+
assert.equal(await can(w, bystander, w.fileId), false);
|
|
457
|
+
await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
458
|
+
subject: { type: 'org', orgId: w.home },
|
|
459
|
+
});
|
|
460
|
+
assert.equal(await can(w, bystander, w.fileId), true);
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// =============================================================================
|
|
465
|
+
// I2 / P7 -- THE SUBJECT ORG IS PART OF THE GRANT'S SCOPE
|
|
466
|
+
// =============================================================================
|
|
467
|
+
|
|
468
|
+
describe('I2: a grant is live only while its SUBJECT ORG is live', () => {
|
|
469
|
+
it('deleting the subject org kills the grants held by its members', async () => {
|
|
470
|
+
const w = await twoOrgs();
|
|
471
|
+
await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
472
|
+
subject: { type: 'org', orgId: w.partner },
|
|
473
|
+
});
|
|
474
|
+
assert.equal(await can(w, w.members.admin, w.fileId), true);
|
|
475
|
+
|
|
476
|
+
await w.fl.softDeleteOrg(w.partner);
|
|
477
|
+
for (const role of ROLES) {
|
|
478
|
+
assert.equal(
|
|
479
|
+
await can(w, w.members[role], w.fileId),
|
|
480
|
+
false,
|
|
481
|
+
`${role} still had access after their org was deleted`,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
// ...and the set query agrees, for the same reason, in the same predicate.
|
|
485
|
+
const page = await w.fl.listFiles({ actorId: w.members.admin }, w.home, { limit: 50 });
|
|
486
|
+
assert.deepEqual(page.files, []);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it('restoring the subject org revives exactly what its deletion suspended', async () => {
|
|
490
|
+
const w = await twoOrgs();
|
|
491
|
+
const kept = await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
492
|
+
subject: { type: 'org', orgId: w.partner },
|
|
493
|
+
});
|
|
494
|
+
const alsoRevoked = await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
495
|
+
subject: { type: 'role', orgId: w.partner, minRole: 'viewer' },
|
|
496
|
+
});
|
|
497
|
+
// Independently revoked BEFORE the delete: it must stay revoked after the
|
|
498
|
+
// restore, exactly as the actor case does.
|
|
499
|
+
await w.fl.revoke({ actorId: w.alice }, alsoRevoked.grantId);
|
|
500
|
+
|
|
501
|
+
await w.fl.softDeleteOrg(w.partner);
|
|
502
|
+
assert.equal(await can(w, w.members.admin, w.fileId), false);
|
|
503
|
+
await w.fl.restoreOrg(w.partner);
|
|
504
|
+
assert.equal(await can(w, w.members.admin, w.fileId), true, 'restore did not revive the grant');
|
|
505
|
+
|
|
506
|
+
const grants = await w.fl.listGrants({ actorId: w.alice }, w.fileId);
|
|
507
|
+
assert.equal(grants.find((g) => g.id === kept.grantId)!.live, true);
|
|
508
|
+
assert.equal(grants.find((g) => g.id === alsoRevoked.grantId)!.live, false);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('deletion is DERIVED: nothing is written to any grant when the subject org dies', async () => {
|
|
512
|
+
const w = await twoOrgs();
|
|
513
|
+
await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
514
|
+
subject: { type: 'org', orgId: w.partner },
|
|
515
|
+
});
|
|
516
|
+
const before = await grantSnapshot(w);
|
|
517
|
+
await w.fl.softDeleteOrg(w.partner);
|
|
518
|
+
assert.equal(await grantSnapshot(w), before, 'org deletion cascaded a write into file_grant');
|
|
519
|
+
await w.fl.restoreOrg(w.partner);
|
|
520
|
+
assert.equal(await grantSnapshot(w), before);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it('a dead subject org also stops the DOWNLOAD path, not merely the decision', async () => {
|
|
524
|
+
const w = await twoOrgs();
|
|
525
|
+
await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
526
|
+
subject: { type: 'org', orgId: w.partner },
|
|
527
|
+
});
|
|
528
|
+
await w.fl.read({ actorId: w.members.member }, w.fileId); // works
|
|
529
|
+
await w.fl.softDeleteOrg(w.partner);
|
|
530
|
+
await rejects(() => w.fl.read({ actorId: w.members.member }, w.fileId), 404);
|
|
531
|
+
// `consume_download` re-evaluates scope liveness under the row locks, so
|
|
532
|
+
// the grant cannot spend a download in the window after the decision either.
|
|
533
|
+
const { rows } = await w.db.query<{ granted: boolean }>(
|
|
534
|
+
`SELECT granted FROM consume_download((SELECT id FROM file_grant WHERE file_id = $1 LIMIT 1))`,
|
|
535
|
+
[w.fileId],
|
|
536
|
+
);
|
|
537
|
+
assert.equal(rows[0]!.granted, false);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('you cannot delegate from a grant whose subject org is dead', async () => {
|
|
541
|
+
const w = await twoOrgs();
|
|
542
|
+
const g = await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
543
|
+
subject: { type: 'org', orgId: w.partner },
|
|
544
|
+
capabilities: ['read', 'share'],
|
|
545
|
+
});
|
|
546
|
+
await w.fl.softDeleteOrg(w.partner);
|
|
547
|
+
const outsider = (await w.fl.createActor('deleg-target')).id;
|
|
548
|
+
await dbRejects(
|
|
549
|
+
w.db,
|
|
550
|
+
`INSERT INTO file_grant (file_id, org_id, parent_grant_id, subject_type, subject_id, capabilities)
|
|
551
|
+
VALUES ($1,$2,$3,'actor',$4,'{read}'::grant_capability[])`,
|
|
552
|
+
[w.fileId, w.home, g.grantId, outsider],
|
|
553
|
+
/grant_parent_not_live/,
|
|
554
|
+
);
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// =============================================================================
|
|
559
|
+
// I6 -- SUBJECT BREADTH MAY NOT BE AMPLIFIED BY DELEGATION
|
|
560
|
+
// =============================================================================
|
|
561
|
+
//
|
|
562
|
+
// The six cases the RFC enumerates, each asserted TWICE: once through the API
|
|
563
|
+
// (where the engine refuses, with a reason and an audit event) and once through
|
|
564
|
+
// raw SQL (where the kernel refuses, because a rule that lives only in
|
|
565
|
+
// application code binds only the application).
|
|
566
|
+
|
|
567
|
+
describe('I6: delegation may attenuate authority but never amplify subject breadth', () => {
|
|
568
|
+
/**
|
|
569
|
+
* Give `holder` a delegable grant of the named subject type and return it.
|
|
570
|
+
* The issuer is always the file's org owner, whose authority is ROLE-DERIVED
|
|
571
|
+
* and who may therefore mint anything -- that asymmetry IS the invariant.
|
|
572
|
+
*/
|
|
573
|
+
async function grantDerivedIssuer(w: TwoOrgs, kind: 'org' | 'actor' | 'link') {
|
|
574
|
+
if (kind === 'actor') {
|
|
575
|
+
const holder = (await w.fl.createActor(`contractor-${Math.random()}`)).id;
|
|
576
|
+
const g = await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
577
|
+
subject: { type: 'actor', actorId: holder },
|
|
578
|
+
capabilities: ['read', 'share'],
|
|
579
|
+
});
|
|
580
|
+
return { principal: { actorId: holder }, grantId: g.grantId };
|
|
581
|
+
}
|
|
582
|
+
if (kind === 'org') {
|
|
583
|
+
const g = await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
584
|
+
subject: { type: 'org', orgId: w.partner },
|
|
585
|
+
capabilities: ['read', 'share'],
|
|
586
|
+
});
|
|
587
|
+
return { principal: { actorId: w.members.member }, grantId: g.grantId };
|
|
588
|
+
}
|
|
589
|
+
// A `link` grant is read-only by CHECK constraint, so a link holder can
|
|
590
|
+
// never reach `share` at all. It is still exercised at the SQL level below.
|
|
591
|
+
const g = await w.fl.share({ actorId: w.alice }, w.fileId, { subject: { type: 'link' } });
|
|
592
|
+
return { principal: { actorId: null, linkSecret: g.secret! }, grantId: g.grantId };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
it('the delegable set is exactly {actor, link}, and it is stated once', () => {
|
|
596
|
+
assert.deepEqual([...DELEGABLE_SUBJECT_TYPES].sort(), ['actor', 'link']);
|
|
597
|
+
const all: GrantSubjectType[] = ['actor', 'org', 'role', 'link', 'anonymous'];
|
|
598
|
+
const forbidden = all.filter((t) => !DELEGABLE_SUBJECT_TYPES.includes(t));
|
|
599
|
+
assert.deepEqual(forbidden.sort(), ['anonymous', 'org', 'role']);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
// --- case 1: org grant -> delegate to actor = ALLOWED ----------------------
|
|
603
|
+
it('org grant -> delegate to ACTOR: ALLOWED', async () => {
|
|
604
|
+
const w = await twoOrgs();
|
|
605
|
+
const { principal, grantId } = await grantDerivedIssuer(w, 'org');
|
|
606
|
+
const target = (await w.fl.createActor('t1')).id;
|
|
607
|
+
const child = await w.fl.share(principal, w.fileId, {
|
|
608
|
+
subject: { type: 'actor', actorId: target },
|
|
609
|
+
capabilities: ['read'],
|
|
610
|
+
});
|
|
611
|
+
assert.equal(child.parentGrantId, grantId, 'the delegation is not bound to its parent');
|
|
612
|
+
assert.equal(await can(w, target, w.fileId), true);
|
|
613
|
+
|
|
614
|
+
// I3/P4 still holds through a group parent: revoke the parent and the child
|
|
615
|
+
// dies with it, at any depth, with no cascading write.
|
|
616
|
+
await w.fl.revoke({ actorId: w.alice }, grantId);
|
|
617
|
+
assert.equal(await can(w, target, w.fileId), false, 'the child outlived its group parent');
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it('org grant -> delegate to ACTOR is accepted at the SQL level too', async () => {
|
|
621
|
+
const w = await twoOrgs();
|
|
622
|
+
const { grantId } = await grantDerivedIssuer(w, 'org');
|
|
623
|
+
const target = (await w.fl.createActor('t1-sql')).id;
|
|
624
|
+
const { rows } = await w.db.query<{ id: string }>(
|
|
625
|
+
`INSERT INTO file_grant (file_id, org_id, parent_grant_id, subject_type, subject_id, capabilities)
|
|
626
|
+
VALUES ($1,$2,$3,'actor',$4,'{read}'::grant_capability[]) RETURNING id`,
|
|
627
|
+
[w.fileId, w.home, grantId, target],
|
|
628
|
+
);
|
|
629
|
+
assert.ok(rows[0]!.id, 'the kernel refused a legal delegation -- I6 is over-broad');
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
// --- case 2: org grant -> delegate to link = ALLOWED (if attenuation holds) -
|
|
633
|
+
it('org grant -> delegate to LINK: ALLOWED when capability attenuation holds', async () => {
|
|
634
|
+
const w = await twoOrgs();
|
|
635
|
+
const { principal, grantId } = await grantDerivedIssuer(w, 'org');
|
|
636
|
+
const child = await w.fl.share(principal, w.fileId, {
|
|
637
|
+
subject: { type: 'link' },
|
|
638
|
+
capabilities: ['read'],
|
|
639
|
+
});
|
|
640
|
+
assert.equal(child.parentGrantId, grantId);
|
|
641
|
+
const d = await w.fl.redeem(child.secret!);
|
|
642
|
+
assert.equal(d.file.id, w.fileId);
|
|
643
|
+
// ...and it is still attenuated in the OTHER dimension: the child may not
|
|
644
|
+
// carry a capability the group grant did not hold.
|
|
645
|
+
await rejects(
|
|
646
|
+
() =>
|
|
647
|
+
w.fl.share(principal, w.fileId, {
|
|
648
|
+
subject: { type: 'actor', actorId: w.members.viewer },
|
|
649
|
+
capabilities: ['delete'],
|
|
650
|
+
}),
|
|
651
|
+
403,
|
|
652
|
+
'forbidden',
|
|
653
|
+
);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it('org grant -> delegate to LINK is accepted at the SQL level too', async () => {
|
|
657
|
+
const w = await twoOrgs();
|
|
658
|
+
const { grantId } = await grantDerivedIssuer(w, 'org');
|
|
659
|
+
const { rows } = await w.db.query<{ id: string }>(
|
|
660
|
+
`INSERT INTO file_grant (file_id, org_id, parent_grant_id, subject_type, secret_hash, capabilities)
|
|
661
|
+
VALUES ($1,$2,$3,'link','deadbeef','{read}'::grant_capability[]) RETURNING id`,
|
|
662
|
+
[w.fileId, w.home, grantId],
|
|
663
|
+
);
|
|
664
|
+
assert.ok(rows[0]!.id, 'the kernel refused a legal link delegation');
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
// --- case 3: org grant -> delegate to org = DENIED -------------------------
|
|
668
|
+
it('org grant -> delegate to ORG: DENIED', async () => {
|
|
669
|
+
const w = await twoOrgs();
|
|
670
|
+
const { principal } = await grantDerivedIssuer(w, 'org');
|
|
671
|
+
const third = (await w.fl.createOrg('third', 'Third', { ownerActorId: w.alice })).id;
|
|
672
|
+
const err = await rejects(
|
|
673
|
+
() => w.fl.share(principal, w.fileId, { subject: { type: 'org', orgId: third } }),
|
|
674
|
+
403,
|
|
675
|
+
'forbidden',
|
|
676
|
+
);
|
|
677
|
+
assert.equal(err.reason, 'subject_breadth_amplification');
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it('org grant -> delegate to ORG is refused by the KERNEL, engine bypassed', async () => {
|
|
681
|
+
const w = await twoOrgs();
|
|
682
|
+
const { grantId } = await grantDerivedIssuer(w, 'org');
|
|
683
|
+
const third = (await w.fl.createOrg('third-sql', 'Third', { ownerActorId: w.alice })).id;
|
|
684
|
+
await dbRejects(
|
|
685
|
+
w.db,
|
|
686
|
+
`INSERT INTO file_grant (file_id, org_id, parent_grant_id, subject_type, subject_org_id, capabilities)
|
|
687
|
+
VALUES ($1,$2,$3,'org',$4,'{read}'::grant_capability[])`,
|
|
688
|
+
[w.fileId, w.home, grantId, third],
|
|
689
|
+
/grant_subject_amplification/,
|
|
690
|
+
);
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
// --- case 4: actor grant -> delegate to org = DENIED -----------------------
|
|
694
|
+
it('actor grant -> delegate to ORG: DENIED', async () => {
|
|
695
|
+
const w = await twoOrgs();
|
|
696
|
+
const { principal } = await grantDerivedIssuer(w, 'actor');
|
|
697
|
+
const err = await rejects(
|
|
698
|
+
() => w.fl.share(principal, w.fileId, { subject: { type: 'org', orgId: w.partner } }),
|
|
699
|
+
403,
|
|
700
|
+
'forbidden',
|
|
701
|
+
);
|
|
702
|
+
assert.equal(err.reason, 'subject_breadth_amplification');
|
|
703
|
+
// The `role` form is the same refusal: naming a floor does not make a
|
|
704
|
+
// population narrower than "one person".
|
|
705
|
+
const err2 = await rejects(
|
|
706
|
+
() =>
|
|
707
|
+
w.fl.share(principal, w.fileId, {
|
|
708
|
+
subject: { type: 'role', orgId: w.partner, minRole: 'owner' },
|
|
709
|
+
}),
|
|
710
|
+
403,
|
|
711
|
+
);
|
|
712
|
+
assert.equal(err2.reason, 'subject_breadth_amplification');
|
|
713
|
+
// Nothing was written.
|
|
714
|
+
const { rows } = await w.db.query<{ n: number }>(
|
|
715
|
+
`SELECT count(*)::int AS n FROM file_grant WHERE subject_org_id IS NOT NULL`,
|
|
716
|
+
);
|
|
717
|
+
assert.equal(Number(rows[0]!.n), 0);
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it('actor grant -> delegate to ORG is refused by the KERNEL, engine bypassed', async () => {
|
|
721
|
+
const w = await twoOrgs();
|
|
722
|
+
const { grantId } = await grantDerivedIssuer(w, 'actor');
|
|
723
|
+
await dbRejects(
|
|
724
|
+
w.db,
|
|
725
|
+
`INSERT INTO file_grant (file_id, org_id, parent_grant_id, subject_type, subject_org_id, capabilities)
|
|
726
|
+
VALUES ($1,$2,$3,'org',$4,'{read}'::grant_capability[])`,
|
|
727
|
+
[w.fileId, w.home, grantId, w.partner],
|
|
728
|
+
/grant_subject_amplification/,
|
|
729
|
+
);
|
|
730
|
+
await dbRejects(
|
|
731
|
+
w.db,
|
|
732
|
+
`INSERT INTO file_grant (file_id, org_id, parent_grant_id, subject_type, subject_org_id,
|
|
733
|
+
subject_min_role, capabilities)
|
|
734
|
+
VALUES ($1,$2,$3,'role',$4,'viewer','{read}'::grant_capability[])`,
|
|
735
|
+
[w.fileId, w.home, grantId, w.partner],
|
|
736
|
+
/grant_subject_amplification/,
|
|
737
|
+
);
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
// --- case 5: link grant -> delegate to org = DENIED ------------------------
|
|
741
|
+
it('link grant -> delegate to ORG: DENIED (and a link cannot reach share at all)', async () => {
|
|
742
|
+
const w = await twoOrgs();
|
|
743
|
+
const { principal } = await grantDerivedIssuer(w, 'link');
|
|
744
|
+
// A link grant is read-only by CHECK, so the attempt does not even reach
|
|
745
|
+
// I6 -- it is refused for want of `share`, as a 404. Both refusals are
|
|
746
|
+
// load-bearing and this asserts the outer one.
|
|
747
|
+
await rejects(
|
|
748
|
+
() => w.fl.share(principal, w.fileId, { subject: { type: 'org', orgId: w.partner } }),
|
|
749
|
+
404,
|
|
750
|
+
);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it('link grant -> delegate to ORG is refused by the KERNEL, engine bypassed', async () => {
|
|
754
|
+
const w = await twoOrgs();
|
|
755
|
+
const { grantId } = await grantDerivedIssuer(w, 'link');
|
|
756
|
+
// The SQL path is the one that matters here: it bypasses both the
|
|
757
|
+
// read-only CHECK on the PARENT and the engine, and I6 still refuses.
|
|
758
|
+
await dbRejects(
|
|
759
|
+
w.db,
|
|
760
|
+
`INSERT INTO file_grant (file_id, org_id, parent_grant_id, subject_type, subject_org_id, capabilities)
|
|
761
|
+
VALUES ($1,$2,$3,'org',$4,'{read}'::grant_capability[])`,
|
|
762
|
+
[w.fileId, w.home, grantId, w.partner],
|
|
763
|
+
/grant_subject_amplification/,
|
|
764
|
+
);
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
// --- case 6: grant-derived -> anonymous = DENIED ---------------------------
|
|
768
|
+
it('grant-derived -> ANONYMOUS: DENIED', async () => {
|
|
769
|
+
const w = await twoOrgs();
|
|
770
|
+
for (const kind of ['actor', 'org'] as const) {
|
|
771
|
+
const fresh = await twoOrgs();
|
|
772
|
+
const { principal } = await grantDerivedIssuer(fresh, kind);
|
|
773
|
+
const err = await rejects(
|
|
774
|
+
() => fresh.fl.share(principal, fresh.fileId, { subject: { type: 'anonymous' } }),
|
|
775
|
+
403,
|
|
776
|
+
'forbidden',
|
|
777
|
+
);
|
|
778
|
+
assert.equal(err.reason, 'subject_breadth_amplification', `from a ${kind} grant`);
|
|
779
|
+
assert.equal(await can(fresh, null, fresh.fileId), false);
|
|
780
|
+
}
|
|
781
|
+
// The file's own org owner CAN publish it -- role-derived authority may
|
|
782
|
+
// mint any subject. Without this, the test would pass for the wrong reason.
|
|
783
|
+
await w.fl.share({ actorId: w.alice }, w.fileId, { subject: { type: 'anonymous' } });
|
|
784
|
+
assert.equal(await can(w, null, w.fileId), true);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
it('grant-derived -> ANONYMOUS is refused by the KERNEL, engine bypassed', async () => {
|
|
788
|
+
const w = await twoOrgs();
|
|
789
|
+
const { grantId } = await grantDerivedIssuer(w, 'actor');
|
|
790
|
+
await dbRejects(
|
|
791
|
+
w.db,
|
|
792
|
+
`INSERT INTO file_grant (file_id, org_id, parent_grant_id, subject_type, capabilities)
|
|
793
|
+
VALUES ($1,$2,$3,'anonymous','{read}'::grant_capability[])`,
|
|
794
|
+
[w.fileId, w.home, grantId],
|
|
795
|
+
/grant_subject_amplification/,
|
|
796
|
+
);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
// --- the invariant cannot be walked around by a second statement -----------
|
|
800
|
+
it('a delegated grant cannot be WIDENED by a later UPDATE', async () => {
|
|
801
|
+
const w = await twoOrgs();
|
|
802
|
+
const { grantId } = await grantDerivedIssuer(w, 'org');
|
|
803
|
+
const target = (await w.fl.createActor('widen-target')).id;
|
|
804
|
+
const { rows } = await w.db.query<{ id: string }>(
|
|
805
|
+
`INSERT INTO file_grant (file_id, org_id, parent_grant_id, subject_type, subject_id, capabilities)
|
|
806
|
+
VALUES ($1,$2,$3,'actor',$4,'{read}'::grant_capability[]) RETURNING id`,
|
|
807
|
+
[w.fileId, w.home, grantId, target],
|
|
808
|
+
);
|
|
809
|
+
// Attenuation that binds only at INSERT is attenuation a second statement
|
|
810
|
+
// walks around. The trigger fires on the subject columns for this reason.
|
|
811
|
+
await dbRejects(
|
|
812
|
+
w.db,
|
|
813
|
+
`UPDATE file_grant
|
|
814
|
+
SET subject_type = 'org', subject_id = NULL, subject_org_id = $2
|
|
815
|
+
WHERE id = $1`,
|
|
816
|
+
[rows[0]!.id, w.partner],
|
|
817
|
+
/grant_subject_amplification/,
|
|
818
|
+
);
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
it('a ROOT grant is unaffected: role-derived authority may mint every subject type', async () => {
|
|
822
|
+
const w = await twoOrgs();
|
|
823
|
+
// Alice is the owner of the file's org. All five subject types, no parent.
|
|
824
|
+
const target = (await w.fl.createActor('root-target')).id;
|
|
825
|
+
for (const subject of [
|
|
826
|
+
{ type: 'actor', actorId: target },
|
|
827
|
+
{ type: 'org', orgId: w.partner },
|
|
828
|
+
{ type: 'role', orgId: w.partner, minRole: 'admin' },
|
|
829
|
+
{ type: 'link' },
|
|
830
|
+
{ type: 'anonymous' },
|
|
831
|
+
] as const) {
|
|
832
|
+
const g = await w.fl.share({ actorId: w.alice }, w.fileId, { subject });
|
|
833
|
+
assert.equal(g.parentGrantId, null, `${subject.type} was minted with a parent`);
|
|
834
|
+
}
|
|
835
|
+
});
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
// =============================================================================
|
|
839
|
+
// I4 / P5 -- THE AUDIT EVENT NAMES THE MEMBERSHIP THAT CONFERRED ACCESS
|
|
840
|
+
// =============================================================================
|
|
841
|
+
|
|
842
|
+
describe('I4: `via` gains grant:org and grant:role, and the event says which membership', () => {
|
|
843
|
+
async function lastAllow(w: World, fileId: string) {
|
|
844
|
+
const { rows } = await w.db.query<Record<string, unknown>>(
|
|
845
|
+
`SELECT context FROM audit_event
|
|
846
|
+
WHERE file_id = $1 AND decision = 'allow' AND action = 'file.read'
|
|
847
|
+
ORDER BY id DESC LIMIT 1`,
|
|
848
|
+
[fileId],
|
|
849
|
+
);
|
|
850
|
+
const raw = rows[0]!['context'];
|
|
851
|
+
return (typeof raw === 'string' ? JSON.parse(raw) : raw) as Record<string, unknown>;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
it('an org-grant allow records via=grant:org, the subject org, and the role held', async () => {
|
|
855
|
+
const w = await twoOrgs();
|
|
856
|
+
await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
857
|
+
subject: { type: 'org', orgId: w.partner },
|
|
858
|
+
});
|
|
859
|
+
const d = await authorize(w.fl.store, { actorId: w.members.member }, w.fileId, 'read');
|
|
860
|
+
assert.equal(d.allow === true && d.via, 'grant:org');
|
|
861
|
+
|
|
862
|
+
const ctx = await lastAllow(w, w.fileId);
|
|
863
|
+
assert.equal(ctx['via'], 'grant:org');
|
|
864
|
+
assert.equal(ctx['viaOrgId'], w.partner);
|
|
865
|
+
assert.equal(ctx['viaMinRole'], null, 'an org grant has no floor');
|
|
866
|
+
assert.equal(ctx['viaRole'], 'member', 'the conferring membership is not identified');
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it('a role-grant allow records via=grant:role, the floor, and the role held', async () => {
|
|
870
|
+
const w = await twoOrgs();
|
|
871
|
+
await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
872
|
+
subject: { type: 'role', orgId: w.partner, minRole: 'admin' },
|
|
873
|
+
});
|
|
874
|
+
const d = await authorize(w.fl.store, { actorId: w.members.owner }, w.fileId, 'read');
|
|
875
|
+
assert.equal(d.allow === true && d.via, 'grant:role');
|
|
876
|
+
|
|
877
|
+
const ctx = await lastAllow(w, w.fileId);
|
|
878
|
+
assert.equal(ctx['via'], 'grant:role');
|
|
879
|
+
assert.equal(ctx['viaOrgId'], w.partner);
|
|
880
|
+
assert.equal(ctx['viaMinRole'], 'admin');
|
|
881
|
+
assert.equal(ctx['viaRole'], 'owner');
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
it('a compliance auditor can answer "why did this succeed?" from the log alone', async () => {
|
|
885
|
+
const w = await twoOrgs();
|
|
886
|
+
const g = await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
887
|
+
subject: { type: 'role', orgId: w.partner, minRole: 'member' },
|
|
888
|
+
});
|
|
889
|
+
await w.fl.read({ actorId: w.members.admin }, w.fileId);
|
|
890
|
+
const { rows } = await w.db.query<Record<string, unknown>>(
|
|
891
|
+
`SELECT actor_id, grant_id, context FROM audit_event
|
|
892
|
+
WHERE file_id = $1 AND decision = 'allow' AND action = 'file.read'
|
|
893
|
+
ORDER BY id DESC LIMIT 1`,
|
|
894
|
+
[w.fileId],
|
|
895
|
+
);
|
|
896
|
+
const raw = rows[0]!['context'];
|
|
897
|
+
const ctx = (typeof raw === 'string' ? JSON.parse(raw) : raw) as Record<string, unknown>;
|
|
898
|
+
// WHO, through WHICH GRANT, by virtue of WHICH MEMBERSHIP: all on one row.
|
|
899
|
+
assert.equal(rows[0]!['actor_id'], w.members.admin);
|
|
900
|
+
assert.equal(rows[0]!['grant_id'], g.grantId);
|
|
901
|
+
assert.equal(ctx['viaOrgId'], w.partner);
|
|
902
|
+
assert.equal(ctx['viaRole'], 'admin');
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
it('non-group allows are unchanged: no membership fields are added to them', async () => {
|
|
906
|
+
const w = await twoOrgs();
|
|
907
|
+
const target = (await w.fl.createActor('plain')).id;
|
|
908
|
+
await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
909
|
+
subject: { type: 'actor', actorId: target },
|
|
910
|
+
});
|
|
911
|
+
await authorize(w.fl.store, { actorId: target }, w.fileId, 'read');
|
|
912
|
+
const ctx = await lastAllow(w, w.fileId);
|
|
913
|
+
assert.equal(ctx['via'], 'grant:actor');
|
|
914
|
+
assert.equal('viaOrgId' in ctx, false);
|
|
915
|
+
assert.equal('viaRole' in ctx, false);
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it('a named actor grant wins the attribution over a group grant on the same file', async () => {
|
|
919
|
+
const w = await twoOrgs();
|
|
920
|
+
// The same person is reachable both ways. `via` must report the narrower,
|
|
921
|
+
// more specific path, because that is the one a compliance auditor asked about.
|
|
922
|
+
await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
923
|
+
subject: { type: 'org', orgId: w.partner },
|
|
924
|
+
});
|
|
925
|
+
const specific = await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
926
|
+
subject: { type: 'actor', actorId: w.members.admin },
|
|
927
|
+
});
|
|
928
|
+
const d = await authorize(w.fl.store, { actorId: w.members.admin }, w.fileId, 'read');
|
|
929
|
+
assert.equal(d.allow === true && d.via, 'grant:actor');
|
|
930
|
+
assert.equal(d.allow === true && d.grantId, specific.grantId);
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
it('the I6 refusal is audited with a reason, not merely thrown', async () => {
|
|
934
|
+
const w = await twoOrgs();
|
|
935
|
+
const holder = (await w.fl.createActor('audited-contractor')).id;
|
|
936
|
+
await w.fl.share({ actorId: w.alice }, w.fileId, {
|
|
937
|
+
subject: { type: 'actor', actorId: holder },
|
|
938
|
+
capabilities: ['read', 'share'],
|
|
939
|
+
});
|
|
940
|
+
await rejects(
|
|
941
|
+
() =>
|
|
942
|
+
w.fl.share({ actorId: holder }, w.fileId, { subject: { type: 'org', orgId: w.partner } }),
|
|
943
|
+
403,
|
|
944
|
+
);
|
|
945
|
+
const { rows } = await w.db.query<Record<string, unknown>>(
|
|
946
|
+
`SELECT reason, context FROM audit_event
|
|
947
|
+
WHERE action = 'grant.create' AND decision = 'deny' ORDER BY id DESC LIMIT 1`,
|
|
948
|
+
);
|
|
949
|
+
assert.equal(rows[0]!['reason'], 'subject_breadth_amplification');
|
|
950
|
+
const raw = rows[0]!['context'];
|
|
951
|
+
const ctx = (typeof raw === 'string' ? JSON.parse(raw) : raw) as Record<string, unknown>;
|
|
952
|
+
assert.equal(ctx['requestedSubjectType'], 'org');
|
|
953
|
+
assert.equal(ctx['via'], 'grant:actor');
|
|
954
|
+
});
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
// =============================================================================
|
|
958
|
+
// THE SCHEMA'S OWN COHERENCE
|
|
959
|
+
// =============================================================================
|
|
960
|
+
|
|
961
|
+
describe('the schema refuses an incoherent group subject', () => {
|
|
962
|
+
it('an org/role grant may not also name an actor or carry a secret', async () => {
|
|
963
|
+
const w = await twoOrgs();
|
|
964
|
+
for (const [sql, params] of [
|
|
965
|
+
[
|
|
966
|
+
`INSERT INTO file_grant (file_id, org_id, subject_type, subject_org_id, subject_id, capabilities)
|
|
967
|
+
VALUES ($1,$2,'org',$3,$4,'{read}'::grant_capability[])`,
|
|
968
|
+
[w.fileId, w.home, w.partner, w.alice],
|
|
969
|
+
],
|
|
970
|
+
[
|
|
971
|
+
`INSERT INTO file_grant (file_id, org_id, subject_type, subject_org_id, secret_hash, capabilities)
|
|
972
|
+
VALUES ($1,$2,'org',$3,'abc','{read}'::grant_capability[])`,
|
|
973
|
+
[w.fileId, w.home, w.partner],
|
|
974
|
+
],
|
|
975
|
+
// 'org' with a floor: the floor belongs to 'role' and nowhere else.
|
|
976
|
+
[
|
|
977
|
+
`INSERT INTO file_grant (file_id, org_id, subject_type, subject_org_id, subject_min_role, capabilities)
|
|
978
|
+
VALUES ($1,$2,'org',$3,'admin','{read}'::grant_capability[])`,
|
|
979
|
+
[w.fileId, w.home, w.partner],
|
|
980
|
+
],
|
|
981
|
+
// 'role' without a floor.
|
|
982
|
+
[
|
|
983
|
+
`INSERT INTO file_grant (file_id, org_id, subject_type, subject_org_id, capabilities)
|
|
984
|
+
VALUES ($1,$2,'role',$3,'{read}'::grant_capability[])`,
|
|
985
|
+
[w.fileId, w.home, w.partner],
|
|
986
|
+
],
|
|
987
|
+
// 'org' with no subject org at all.
|
|
988
|
+
[
|
|
989
|
+
`INSERT INTO file_grant (file_id, org_id, subject_type, capabilities)
|
|
990
|
+
VALUES ($1,$2,'org','{read}'::grant_capability[])`,
|
|
991
|
+
[w.fileId, w.home],
|
|
992
|
+
],
|
|
993
|
+
// ...and an ACTOR grant may not smuggle a subject org onto itself.
|
|
994
|
+
[
|
|
995
|
+
`INSERT INTO file_grant (file_id, org_id, subject_type, subject_id, subject_org_id, capabilities)
|
|
996
|
+
VALUES ($1,$2,'actor',$3,$4,'{read}'::grant_capability[])`,
|
|
997
|
+
[w.fileId, w.home, w.alice, w.partner],
|
|
998
|
+
],
|
|
999
|
+
] as Array<[string, unknown[]]>) {
|
|
1000
|
+
await dbRejects(w.db, sql, params, /grant_subject_coherent/);
|
|
1001
|
+
}
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
it('a group grant on a file in another tenant is still unrepresentable (P3 unchanged)', async () => {
|
|
1005
|
+
const w = await twoOrgs();
|
|
1006
|
+
const other = await w.fl.upload({ actorId: w.bosses }, w.partner, {
|
|
1007
|
+
name: 'theirs.pdf',
|
|
1008
|
+
contentType: 'application/pdf',
|
|
1009
|
+
body: bytes('THEIRS'),
|
|
1010
|
+
});
|
|
1011
|
+
await dbRejects(
|
|
1012
|
+
w.db,
|
|
1013
|
+
`INSERT INTO file_grant (file_id, org_id, subject_type, subject_org_id, capabilities)
|
|
1014
|
+
VALUES ($1,$2,'org',$3,'{read}'::grant_capability[])`,
|
|
1015
|
+
[other.id, w.home, w.partner],
|
|
1016
|
+
/file_grant_file_id_org_id_fkey|foreign key/i,
|
|
1017
|
+
);
|
|
1018
|
+
});
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
// =============================================================================
|
|
1022
|
+
// THE TIERED API
|
|
1023
|
+
// =============================================================================
|
|
1024
|
+
|
|
1025
|
+
describe('shares.create exposes the new subjects with names an agent can infer', () => {
|
|
1026
|
+
it('withOrg shares with a whole tenant; withOrg + minRole applies the floor', async () => {
|
|
1027
|
+
const { db } = await createTestDb();
|
|
1028
|
+
const fl = new Filelayer(db, new MemoryStorage(), { baseUrl: 'https://t.test' });
|
|
1029
|
+
|
|
1030
|
+
await fl.orgs.create('acme', { owner: 'alice', name: 'Acme' });
|
|
1031
|
+
await fl.orgs.create('partner', { owner: 'boss', name: 'Partner' });
|
|
1032
|
+
await fl.orgs.setRole('partner', 'junior', 'viewer', { as: 'boss' });
|
|
1033
|
+
const f = await fl.files.put(bytes('CV'), { org: 'acme', owner: 'alice' });
|
|
1034
|
+
|
|
1035
|
+
await fl.shares.create(f.id, { as: 'alice', withOrg: 'partner' });
|
|
1036
|
+
assert.ok(await fl.files.get(f.id, { as: 'junior' }));
|
|
1037
|
+
assert.ok(await fl.files.get(f.id, { as: 'boss' }));
|
|
1038
|
+
|
|
1039
|
+
const f2 = await fl.files.put(bytes('BOARD'), { org: 'acme', owner: 'alice' });
|
|
1040
|
+
await fl.shares.create(f2.id, { as: 'alice', withOrg: 'partner', minRole: 'admin' });
|
|
1041
|
+
await rejects(() => fl.files.get(f2.id, { as: 'junior' }), 404);
|
|
1042
|
+
assert.ok(await fl.files.get(f2.id, { as: 'boss' }));
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
it('withOrg does NOT auto-provision a tenant: an unknown org is 404', async () => {
|
|
1046
|
+
const { db } = await createTestDb();
|
|
1047
|
+
const fl = new Filelayer(db, new MemoryStorage(), { baseUrl: 'https://t.test' });
|
|
1048
|
+
await fl.orgs.create('acme', { owner: 'alice' });
|
|
1049
|
+
const f = await fl.files.put(bytes('X'), { org: 'acme', owner: 'alice' });
|
|
1050
|
+
await rejects(
|
|
1051
|
+
() => fl.shares.create(f.id, { as: 'alice', withOrg: 'typo-corp' }),
|
|
1052
|
+
404,
|
|
1053
|
+
'not_found',
|
|
1054
|
+
);
|
|
1055
|
+
const { rows } = await db.query<{ n: number }>(
|
|
1056
|
+
`SELECT count(*)::int AS n FROM org WHERE external_id = 'typo-corp'`,
|
|
1057
|
+
);
|
|
1058
|
+
assert.equal(Number(rows[0]!.n), 0, 'a typo in an org name provisioned a tenant');
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
it('an ambiguous subject is refused rather than silently resolved', async () => {
|
|
1062
|
+
const { db } = await createTestDb();
|
|
1063
|
+
const fl = new Filelayer(db, new MemoryStorage(), { baseUrl: 'https://t.test' });
|
|
1064
|
+
await fl.orgs.create('acme', { owner: 'alice' });
|
|
1065
|
+
const f = await fl.files.put(bytes('X'), { org: 'acme', owner: 'alice' });
|
|
1066
|
+
await rejects(
|
|
1067
|
+
() => fl.shares.create(f.id, { as: 'alice', withUser: 'bob', withOrg: 'acme' }),
|
|
1068
|
+
400,
|
|
1069
|
+
);
|
|
1070
|
+
await rejects(() => fl.shares.create(f.id, { as: 'alice', minRole: 'admin' }), 400);
|
|
1071
|
+
});
|
|
1072
|
+
});
|