@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
package/src/authz.ts
ADDED
|
@@ -0,0 +1,1398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FILELAYER AUTHORIZATION ENGINE
|
|
3
|
+
*
|
|
4
|
+
* Every access question in the system is answered here. There is deliberately
|
|
5
|
+
* no second path. Reads, writes, deletes, shares, signed-URL redemptions,
|
|
6
|
+
* membership changes, file creation and audit access all resolve through this
|
|
7
|
+
* module, against one decision core (`allow` / `deny`).
|
|
8
|
+
*
|
|
9
|
+
* The engine has exactly two resource scopes:
|
|
10
|
+
*
|
|
11
|
+
* authorize() (actor, file, capability) -- file-scoped
|
|
12
|
+
* authorizeOrg() (actor, org, org capability) -- org-scoped
|
|
13
|
+
*
|
|
14
|
+
* The org scope exists because some privileges are not about a file: creating
|
|
15
|
+
* one, reading the audit log, and changing who is in the organization. Those
|
|
16
|
+
* used to be hand-rolled checks in the API layer or entirely absent.
|
|
17
|
+
* Membership is a privilege change like any other, so it goes through
|
|
18
|
+
* the same path, with the same deny reasons and the same audit guarantee.
|
|
19
|
+
*
|
|
20
|
+
* WHY ONE ENGINE MATTERS MORE THAN IT LOOKS:
|
|
21
|
+
* The design goal is to minimise the "number of security-sensitive decisions a
|
|
22
|
+
* developer must make". In a hand-rolled integration built directly on Supabase,
|
|
23
|
+
* Convex or Vercel, that number scales with the number of *places* the
|
|
24
|
+
* developer touches files:
|
|
25
|
+
* every route, every RLS policy, every presign call is an independent chance
|
|
26
|
+
* to leak. Here the developer makes none of them, because there is exactly one
|
|
27
|
+
* place a decision can be made and it is not in the application.
|
|
28
|
+
*
|
|
29
|
+
* INVARIANTS (mirrored from schema.sql, enforced here in code):
|
|
30
|
+
* P1 deny by default - DENY unless a rule fires, at the file
|
|
31
|
+
* boundary as well as the tenant boundary
|
|
32
|
+
* P2 no ambient authority - storage keys/URLs/ids are never inputs
|
|
33
|
+
* P4 URL <= permission - redemption re-validates the grant AND its
|
|
34
|
+
* whole ancestor chain, always
|
|
35
|
+
* P5 audit every decision - every return path emits exactly one event,
|
|
36
|
+
* including decisions with no tenant to charge
|
|
37
|
+
*
|
|
38
|
+
* EVALUATION ORDER IS A SECURITY PROPERTY:
|
|
39
|
+
* 1. Does the file exist? -> no: deny, audit to the system chain
|
|
40
|
+
* 2. Does the caller have STANDING? -> no: deny, and say nothing more
|
|
41
|
+
* 3. Does their standing carry the requested capability?
|
|
42
|
+
* 4. Only then: lifecycle gates (deleted / expired / not ready / retained)
|
|
43
|
+
*
|
|
44
|
+
* Steps 1-3 are indistinguishable to the caller: everything is 404. The
|
|
45
|
+
* lifecycle statuses that are NOT 404 (410 Gone, 409 retention hold) are only
|
|
46
|
+
* ever reachable by someone who has already proven they may perform the
|
|
47
|
+
* operation, so they cannot be used to probe for the existence of a file.
|
|
48
|
+
* Previously these gates ran first and the API layer patched over the resulting
|
|
49
|
+
* oracle; that patch is gone.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
export type Capability = 'read' | 'write' | 'delete' | 'share';
|
|
53
|
+
export type OrgRole = 'viewer' | 'member' | 'admin' | 'owner';
|
|
54
|
+
export type FileVisibility = 'private' | 'org';
|
|
55
|
+
export type FileState = 'pending' | 'ready' | 'deleted';
|
|
56
|
+
|
|
57
|
+
export const ALL_CAPABILITIES: readonly Capability[] = ['read', 'write', 'delete', 'share'];
|
|
58
|
+
const ALL_ROLES: readonly OrgRole[] = ['viewer', 'member', 'admin', 'owner'];
|
|
59
|
+
const ALL_VISIBILITIES: readonly FileVisibility[] = ['private', 'org'];
|
|
60
|
+
const ALL_STATES: readonly FileState[] = ['pending', 'ready', 'deleted'];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Privileges that are not about a particular file.
|
|
64
|
+
*
|
|
65
|
+
* Deliberately a closed, tiny set for the same reason `org_role` is: an
|
|
66
|
+
* unbounded permission vocabulary is an authorization model nobody can audit.
|
|
67
|
+
*/
|
|
68
|
+
export type OrgCapability = 'create_file' | 'manage_members' | 'read_audit';
|
|
69
|
+
|
|
70
|
+
/** Ordered so comparisons are possible. Higher index = strictly more power. */
|
|
71
|
+
const ROLE_RANK: Record<OrgRole, number> = {
|
|
72
|
+
viewer: 0,
|
|
73
|
+
member: 1,
|
|
74
|
+
admin: 2,
|
|
75
|
+
owner: 3,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A grant's subject is a PRINCIPAL SET (RFC-001). See the `grant_subject` note
|
|
80
|
+
* in schema.sql for the full reasoning; the ordering that matters here is
|
|
81
|
+
*
|
|
82
|
+
* actor < role <= org < anonymous (link is orthogonal)
|
|
83
|
+
*
|
|
84
|
+
* and it is what I6 attenuates over.
|
|
85
|
+
*/
|
|
86
|
+
export type GrantSubjectType = 'actor' | 'org' | 'role' | 'link' | 'anonymous';
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The subject types an issuer whose authority is GRANT-DERIVED may mint (I6).
|
|
90
|
+
*
|
|
91
|
+
* `actor` names one person and `link` is a bearer credential for one file;
|
|
92
|
+
* neither widens the population the issuer could already reach. `org`, `role`
|
|
93
|
+
* and `anonymous` all do, so a delegate may not create them at any depth.
|
|
94
|
+
*
|
|
95
|
+
* Exported so the rule is nameable in one place and testable directly, and so
|
|
96
|
+
* that adding a sixth subject type forces a decision about it here rather than
|
|
97
|
+
* silently defaulting to "delegable".
|
|
98
|
+
*/
|
|
99
|
+
export const DELEGABLE_SUBJECT_TYPES: readonly GrantSubjectType[] = ['actor', 'link'];
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* THE ROLE THRESHOLD, DEFINED ONCE.
|
|
103
|
+
*
|
|
104
|
+
* `subject_min_role` is a floor over the existing four-value `org_role` enum
|
|
105
|
+
* and nothing more -- no custom roles, no nesting, no configurable inheritance.
|
|
106
|
+
* A `role` grant matches a principal holding `actual` iff this returns true; an
|
|
107
|
+
* `org` grant is the same question with `min = 'viewer'`.
|
|
108
|
+
*
|
|
109
|
+
* Both the point check (`getGroupGrants`) and the set query
|
|
110
|
+
* (`listAuthorizedFiles`) need this rule in SQL. Neither restates it: both are
|
|
111
|
+
* generated from `membershipCells()` below, which is generated from this
|
|
112
|
+
* function, exactly as the role matrix is generated from `fileCapabilities()`.
|
|
113
|
+
*/
|
|
114
|
+
export function roleMeets(actual: OrgRole, min: OrgRole): boolean {
|
|
115
|
+
return ROLE_RANK[actual] >= ROLE_RANK[min];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** One (floor, held) pair for which the threshold holds. */
|
|
119
|
+
export interface MembershipCell {
|
|
120
|
+
minRole: OrgRole;
|
|
121
|
+
role: OrgRole;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Every (floor, held) pair that satisfies `roleMeets`. 4 x 4 = 16 probes of a
|
|
126
|
+
* pure function; 10 cells survive. This is the whole group-membership rule, in
|
|
127
|
+
* a form SQL can test with a tuple `IN` list.
|
|
128
|
+
*/
|
|
129
|
+
export function membershipCells(): MembershipCell[] {
|
|
130
|
+
const cells: MembershipCell[] = [];
|
|
131
|
+
for (const minRole of ALL_ROLES) {
|
|
132
|
+
for (const role of ALL_ROLES) {
|
|
133
|
+
if (roleMeets(role, minRole)) cells.push({ minRole, role });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return cells;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface Principal {
|
|
140
|
+
/** Null means anonymous: a caller presenting only a link secret. */
|
|
141
|
+
actorId: string | null;
|
|
142
|
+
/** Present only when redeeming a share link. */
|
|
143
|
+
linkSecret?: string;
|
|
144
|
+
/** Present only when the share link is password protected. */
|
|
145
|
+
password?: string;
|
|
146
|
+
ip?: string;
|
|
147
|
+
userAgent?: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface FileRef {
|
|
151
|
+
id: string;
|
|
152
|
+
orgId: string;
|
|
153
|
+
ownerId: string | null;
|
|
154
|
+
state: 'pending' | 'ready' | 'deleted';
|
|
155
|
+
visibility: FileVisibility;
|
|
156
|
+
expiresAt: Date | null;
|
|
157
|
+
retainUntil: Date | null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export type Decision =
|
|
161
|
+
| { allow: true; via: AuthzPath; grantId?: string; remainingDownloads?: number | null }
|
|
162
|
+
| { allow: false; reason: DenyReason };
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Where an allow came from. `grant:org` and `grant:role` are the group paths
|
|
166
|
+
* (RFC-001, I4): the audit event records not just that a grant conferred
|
|
167
|
+
* access, but WHICH MEMBERSHIP did -- the subject org, the floor the grant
|
|
168
|
+
* asked for, and the role the caller actually held in that org. Someone reading
|
|
169
|
+
* the audit log can still answer "why did this succeed?" without joining
|
|
170
|
+
* anything.
|
|
171
|
+
*/
|
|
172
|
+
export type AuthzPath =
|
|
173
|
+
| 'owner'
|
|
174
|
+
| 'role'
|
|
175
|
+
| 'grant:actor'
|
|
176
|
+
| 'grant:org'
|
|
177
|
+
| 'grant:role'
|
|
178
|
+
| 'grant:link'
|
|
179
|
+
| 'grant:anonymous';
|
|
180
|
+
|
|
181
|
+
export type DenyReason =
|
|
182
|
+
| 'file_not_found'
|
|
183
|
+
| 'file_deleted'
|
|
184
|
+
| 'file_expired'
|
|
185
|
+
| 'file_not_ready'
|
|
186
|
+
| 'no_membership'
|
|
187
|
+
| 'insufficient_role'
|
|
188
|
+
| 'no_grant'
|
|
189
|
+
| 'grant_revoked'
|
|
190
|
+
| 'grant_expired'
|
|
191
|
+
| 'grant_exhausted'
|
|
192
|
+
| 'grant_ancestor_dead'
|
|
193
|
+
| 'grant_wrong_capability'
|
|
194
|
+
| 'bad_link_secret'
|
|
195
|
+
| 'bad_password'
|
|
196
|
+
| 'foreign_grant'
|
|
197
|
+
| 'retention_hold'
|
|
198
|
+
| 'attenuation_violation'
|
|
199
|
+
/**
|
|
200
|
+
* I6 (RFC-001). The issuer's authority was grant-derived and they asked to
|
|
201
|
+
* mint a subject wider than `actor` or `link`. Capability attenuation stops
|
|
202
|
+
* you doing more; this stops you reaching more people.
|
|
203
|
+
*/
|
|
204
|
+
| 'subject_breadth_amplification'
|
|
205
|
+
| 'role_escalation'
|
|
206
|
+
| 'superior_target'
|
|
207
|
+
| 'last_owner';
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* What each org role may do to a file in its own org.
|
|
211
|
+
*
|
|
212
|
+
* Deliberately small and total. Every cell is enumerated -- there is no
|
|
213
|
+
* fallthrough, no "and also if", no special case. A reviewer can read this
|
|
214
|
+
* table in ten seconds and know the entire role model, which is the point:
|
|
215
|
+
* an authorization model you cannot hold in your head is one you cannot audit.
|
|
216
|
+
*
|
|
217
|
+
* `visibility` closes the "every viewer in an org could read every file in it"
|
|
218
|
+
* defect. Under the default ('private') a file is not
|
|
219
|
+
* visible to the org at large at all; membership alone buys nothing at the file
|
|
220
|
+
* boundary. Org admins and owners keep full access under both settings, because
|
|
221
|
+
* retention, deletion and legal hold are their responsibility, and a control
|
|
222
|
+
* the accountable party cannot exercise is not a control.
|
|
223
|
+
*/
|
|
224
|
+
export function fileCapabilities(
|
|
225
|
+
role: OrgRole,
|
|
226
|
+
isOwner: boolean,
|
|
227
|
+
visibility: FileVisibility,
|
|
228
|
+
): Set<Capability> {
|
|
229
|
+
const none = new Set<Capability>();
|
|
230
|
+
const readOnly = new Set<Capability>(['read']);
|
|
231
|
+
const full = new Set<Capability>(['read', 'write', 'delete', 'share']);
|
|
232
|
+
|
|
233
|
+
switch (role) {
|
|
234
|
+
case 'viewer':
|
|
235
|
+
// Viewers never do more than read, and under 'private' they read only
|
|
236
|
+
// what is theirs.
|
|
237
|
+
return isOwner || visibility === 'org' ? readOnly : none;
|
|
238
|
+
case 'member':
|
|
239
|
+
// Members fully control their own files, and may read others' in the org
|
|
240
|
+
// only when the file was created org-visible.
|
|
241
|
+
if (isOwner) return full;
|
|
242
|
+
return visibility === 'org' ? readOnly : none;
|
|
243
|
+
case 'admin':
|
|
244
|
+
case 'owner':
|
|
245
|
+
return full;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* What each org role may do to the organization itself.
|
|
251
|
+
*
|
|
252
|
+
* The same shape as the file table above, and enumerated for the same reason.
|
|
253
|
+
* `manage_members` is admin+: membership is the most powerful thing in the
|
|
254
|
+
* system, because it is the thing that confers everything else.
|
|
255
|
+
*/
|
|
256
|
+
export function orgCapabilities(role: OrgRole): Set<OrgCapability> {
|
|
257
|
+
switch (role) {
|
|
258
|
+
case 'viewer':
|
|
259
|
+
return new Set<OrgCapability>();
|
|
260
|
+
case 'member':
|
|
261
|
+
return new Set<OrgCapability>(['create_file']);
|
|
262
|
+
case 'admin':
|
|
263
|
+
case 'owner':
|
|
264
|
+
return new Set<OrgCapability>(['create_file', 'manage_members', 'read_audit']);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export interface AuthzDeps {
|
|
269
|
+
getFile(fileId: string): Promise<FileRef | null>;
|
|
270
|
+
orgExists(orgId: string): Promise<boolean>;
|
|
271
|
+
getMembership(orgId: string, actorId: string): Promise<OrgRole | null>;
|
|
272
|
+
countOwners(orgId: string): Promise<number>;
|
|
273
|
+
/** Live grants only: the store must apply the `live_grant` predicate. */
|
|
274
|
+
getActorGrants(fileId: string, actorId: string): Promise<GrantRow[]>;
|
|
275
|
+
/**
|
|
276
|
+
* Live GROUP grants ('org' / 'role') on this file that this actor matches
|
|
277
|
+
* through a live membership (RFC-001).
|
|
278
|
+
*
|
|
279
|
+
* THE CONTRACT THAT MAKES THE HEADLINE PROPERTY TRUE: this is a JOIN against
|
|
280
|
+
* `membership`, evaluated now. It is never a lookup into a materialized
|
|
281
|
+
* member list, and the engine never caches its result across requests. That
|
|
282
|
+
* is what makes "membership changes must change access on the next request,
|
|
283
|
+
* without recomputation" true BY CONSTRUCTION rather than by a background job
|
|
284
|
+
* that is usually up to date. An implementation of `AuthzDeps` that fans a
|
|
285
|
+
* group grant out into per-member rows satisfies the type and breaks the
|
|
286
|
+
* product.
|
|
287
|
+
*
|
|
288
|
+
* The returned rows carry `matchedRole` -- the role the actor actually holds
|
|
289
|
+
* in the subject org -- so the audit event can record which membership
|
|
290
|
+
* conferred access (I4).
|
|
291
|
+
*/
|
|
292
|
+
getGroupGrants(fileId: string, actorId: string): Promise<GrantRow[]>;
|
|
293
|
+
findLiveGrantBySecret(secretHash: string): Promise<GrantRow | null>;
|
|
294
|
+
/**
|
|
295
|
+
* ANY grant with this secret hash, live or not. Used ONLY to attribute and
|
|
296
|
+
* classify a denial (P5) -- never to authorize. Without it a revoked,
|
|
297
|
+
* expired or capped-out link is indistinguishable from a forged one, and the
|
|
298
|
+
* compliance log cannot answer "why did my link stop working".
|
|
299
|
+
*/
|
|
300
|
+
findGrantBySecret(secretHash: string): Promise<GrantRow | null>;
|
|
301
|
+
/** Is `grantId` the same grant as, or delegated from, `ancestorId`? */
|
|
302
|
+
isDescendantOf(ancestorId: string, grantId: string): Promise<boolean>;
|
|
303
|
+
getAnonymousGrant(fileId: string): Promise<GrantRow | null>;
|
|
304
|
+
/**
|
|
305
|
+
* The set form of the decision. Given a predicate DERIVED from the same
|
|
306
|
+
* `fileCapabilities` / `lifecycleDenial` functions the point check uses, the
|
|
307
|
+
* store returns the files in one org for which that predicate holds.
|
|
308
|
+
*
|
|
309
|
+
* It takes a `ListPredicate`, not a WHERE clause. There is no way for a
|
|
310
|
+
* caller to widen it, and no way to call it without one.
|
|
311
|
+
*/
|
|
312
|
+
listAuthorizedFiles(query: ListQuery): Promise<ListedFile[]>;
|
|
313
|
+
hashSecret(secret: string): Promise<string>;
|
|
314
|
+
verifyPassword(password: string, hash: string): Promise<boolean>;
|
|
315
|
+
audit(event: AuditInput): Promise<void>;
|
|
316
|
+
now(): Date;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export interface GrantRow {
|
|
320
|
+
id: string;
|
|
321
|
+
fileId: string;
|
|
322
|
+
orgId: string;
|
|
323
|
+
parentGrantId: string | null;
|
|
324
|
+
subjectType: GrantSubjectType;
|
|
325
|
+
/** Set for 'org' and 'role' grants: the org whose members are the subject. */
|
|
326
|
+
subjectOrgId: string | null;
|
|
327
|
+
/** Set for 'role' grants only: the floor. Null on 'org' reads as 'viewer'. */
|
|
328
|
+
subjectMinRole: OrgRole | null;
|
|
329
|
+
/**
|
|
330
|
+
* For a group grant resolved for a specific principal: the role that
|
|
331
|
+
* principal actually holds in `subjectOrgId`. Not a column -- it comes out of
|
|
332
|
+
* the membership join that matched -- and it exists so the audit event can
|
|
333
|
+
* name WHICH MEMBERSHIP conferred access (I4) without a second query.
|
|
334
|
+
*/
|
|
335
|
+
matchedRole?: OrgRole;
|
|
336
|
+
capabilities: Capability[];
|
|
337
|
+
passwordHash: string | null;
|
|
338
|
+
expiresAt: Date | null;
|
|
339
|
+
maxDownloads: number | null;
|
|
340
|
+
downloadCount: number;
|
|
341
|
+
revokedAt: Date | null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export interface AuditInput {
|
|
345
|
+
/** Null is the SYSTEM chain: a decision with no tenant to charge it to. */
|
|
346
|
+
orgId: string | null;
|
|
347
|
+
action: string;
|
|
348
|
+
decision: 'allow' | 'deny';
|
|
349
|
+
reason?: string;
|
|
350
|
+
actorId: string | null;
|
|
351
|
+
fileId: string | null;
|
|
352
|
+
grantId?: string | null;
|
|
353
|
+
ip?: string;
|
|
354
|
+
userAgent?: string;
|
|
355
|
+
context?: Record<string, unknown>;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// =============================================================================
|
|
359
|
+
// STANDING -- "is this caller recognised on this file at all?"
|
|
360
|
+
// =============================================================================
|
|
361
|
+
|
|
362
|
+
interface Standing {
|
|
363
|
+
/** True iff the principal holds at least one capability on this file. */
|
|
364
|
+
recognised: boolean;
|
|
365
|
+
role: OrgRole | null;
|
|
366
|
+
capabilities: Set<Capability>;
|
|
367
|
+
/** The grant that supplied the requested capability, if any. */
|
|
368
|
+
grant: GrantRow | null;
|
|
369
|
+
via: AuthzPath | null;
|
|
370
|
+
/**
|
|
371
|
+
* Set when resolution must terminate immediately with this reason. The only
|
|
372
|
+
* case is a link secret presented with a bad or missing password, which is
|
|
373
|
+
* answerable only to someone already holding the secret.
|
|
374
|
+
*/
|
|
375
|
+
halt: { reason: DenyReason; grantId?: string } | null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Classify why a grant we can see is not live, for the audit log only.
|
|
380
|
+
*
|
|
381
|
+
* A grant that is self-consistent but still absent from `live_grant` is one
|
|
382
|
+
* whose ancestor chain is dead: that is P4 working, and the log should say so
|
|
383
|
+
* rather than reporting a forged secret.
|
|
384
|
+
*/
|
|
385
|
+
function deadGrantReason(g: GrantRow, now: Date): DenyReason {
|
|
386
|
+
if (g.revokedAt) return 'grant_revoked';
|
|
387
|
+
if (g.expiresAt && g.expiresAt <= now) return 'grant_expired';
|
|
388
|
+
if (g.maxDownloads !== null && g.downloadCount >= g.maxDownloads) return 'grant_exhausted';
|
|
389
|
+
return 'grant_ancestor_dead';
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* @param complete when true, consult every source of authority even after the
|
|
394
|
+
* requested capability has been found. The fast path stops as soon as it can
|
|
395
|
+
* answer the question asked, which is right for an access check and wrong for
|
|
396
|
+
* delegation: attenuation compares against everything the issuer holds, and a
|
|
397
|
+
* partially-resolved set would refuse to pass on authority the issuer really
|
|
398
|
+
* has. Only the share path pays for it.
|
|
399
|
+
*/
|
|
400
|
+
async function resolveStanding(
|
|
401
|
+
deps: AuthzDeps,
|
|
402
|
+
principal: Principal,
|
|
403
|
+
file: FileRef,
|
|
404
|
+
capability: Capability,
|
|
405
|
+
now: Date,
|
|
406
|
+
complete = false,
|
|
407
|
+
): Promise<Standing> {
|
|
408
|
+
const capabilities = new Set<Capability>();
|
|
409
|
+
const acc: { via: AuthzPath | null; grant: GrantRow | null; role: OrgRole | null } = {
|
|
410
|
+
via: null,
|
|
411
|
+
grant: null,
|
|
412
|
+
role: null,
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
/** Absorb a capability set; report whether it supplied the one we need. */
|
|
416
|
+
const take = (caps: Iterable<Capability>, path: AuthzPath, g: GrantRow | null): boolean => {
|
|
417
|
+
let got = false;
|
|
418
|
+
for (const c of caps) {
|
|
419
|
+
capabilities.add(c);
|
|
420
|
+
if (c === capability) got = true;
|
|
421
|
+
}
|
|
422
|
+
if (got && acc.via === null) {
|
|
423
|
+
acc.via = path;
|
|
424
|
+
acc.grant = g;
|
|
425
|
+
}
|
|
426
|
+
return got;
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const settled = (halt: Standing['halt'] = null): Standing => ({
|
|
430
|
+
recognised: capabilities.size > 0,
|
|
431
|
+
role: acc.role,
|
|
432
|
+
capabilities,
|
|
433
|
+
grant: acc.grant,
|
|
434
|
+
via: acc.via,
|
|
435
|
+
halt,
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// --- org role -------------------------------------------------------------
|
|
439
|
+
if (principal.actorId) {
|
|
440
|
+
acc.role = await deps.getMembership(file.orgId, principal.actorId);
|
|
441
|
+
if (acc.role) {
|
|
442
|
+
const caps = fileCapabilities(acc.role, file.ownerId === principal.actorId, file.visibility);
|
|
443
|
+
if (take(caps, 'role', null) && !complete) return settled();
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// --- explicit actor grants ---------------------------------------------
|
|
447
|
+
// A member of the org without the capability may still hold an explicit
|
|
448
|
+
// grant, so we accumulate rather than deciding here.
|
|
449
|
+
const grants = await deps.getActorGrants(file.id, principal.actorId);
|
|
450
|
+
for (const g of grants) {
|
|
451
|
+
if (take(g.capabilities, 'grant:actor', g) && !complete) return settled();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// --- group grants: 'org' and 'role' (RFC-001) --------------------------
|
|
455
|
+
// Consulted AFTER the actor grants, so a grant naming this person by name
|
|
456
|
+
// wins the `via` attribution over one that reaches them as part of a
|
|
457
|
+
// population. Both are ordinary grant rows; nothing here is a special case
|
|
458
|
+
// in liveness, revocation, delegation or capability handling.
|
|
459
|
+
//
|
|
460
|
+
// ONE EXTRA QUERY, resolved by JOIN. No fan-out, no materialized member
|
|
461
|
+
// list, nothing cached: add or remove a member and the very next call to
|
|
462
|
+
// this function sees it, with no grant row touched.
|
|
463
|
+
const groupGrants = await deps.getGroupGrants(file.id, principal.actorId);
|
|
464
|
+
for (const g of groupGrants) {
|
|
465
|
+
const path: AuthzPath = g.subjectType === 'role' ? 'grant:role' : 'grant:org';
|
|
466
|
+
if (take(g.capabilities, path, g) && !complete) return settled();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (acc.via !== null && !complete) return settled();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// --- link grants ----------------------------------------------------------
|
|
473
|
+
if (principal.linkSecret) {
|
|
474
|
+
const hash = await deps.hashSecret(principal.linkSecret);
|
|
475
|
+
const live = await deps.findLiveGrantBySecret(hash);
|
|
476
|
+
|
|
477
|
+
// The grant must belong to the file being requested. Without this check a
|
|
478
|
+
// valid link for file A would authorize file B -- the classic confused
|
|
479
|
+
// deputy. It is one line and it is the whole ballgame.
|
|
480
|
+
if (live && live.fileId === file.id) {
|
|
481
|
+
if (live.passwordHash) {
|
|
482
|
+
const ok =
|
|
483
|
+
principal.password !== undefined &&
|
|
484
|
+
(await deps.verifyPassword(principal.password, live.passwordHash));
|
|
485
|
+
if (!ok) return settled({ reason: 'bad_password', grantId: live.id });
|
|
486
|
+
}
|
|
487
|
+
if (take(live.capabilities, 'grant:link', live) && !complete) return settled();
|
|
488
|
+
} else if (!live) {
|
|
489
|
+
// Attribution only, never authorization: if the secret is real but the
|
|
490
|
+
// grant (or an ancestor of it) is dead, the log records which grant and
|
|
491
|
+
// why. This is what makes an exhausted or revoked link visible in the
|
|
492
|
+
// compliance record instead of looking like a forged one.
|
|
493
|
+
const any = await deps.findGrantBySecret(hash);
|
|
494
|
+
if (any && any.fileId === file.id) {
|
|
495
|
+
return settled({ reason: deadGrantReason(any, now), grantId: any.id });
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// --- anonymous grants -----------------------------------------------------
|
|
501
|
+
// Note this is NOT a "public" flag on the file. It is an explicitly created,
|
|
502
|
+
// individually revocable, individually auditable grant row (P1). Anonymous
|
|
503
|
+
// grants are read-only by CHECK constraint, so there is nothing to look up
|
|
504
|
+
// when a stronger capability was asked for -- unless we are resolving the
|
|
505
|
+
// full set for delegation.
|
|
506
|
+
if (capability === 'read' || complete) {
|
|
507
|
+
const anon = await deps.getAnonymousGrant(file.id);
|
|
508
|
+
if (anon) take(anon.capabilities, 'grant:anonymous', anon);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return settled();
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** The reason to report when a principal has no standing on a file at all. */
|
|
515
|
+
function noStandingReason(principal: Principal, role: OrgRole | null): DenyReason {
|
|
516
|
+
if (principal.actorId) return role ? 'insufficient_role' : 'no_membership';
|
|
517
|
+
if (principal.linkSecret) return 'bad_link_secret';
|
|
518
|
+
return 'no_grant';
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* File lifecycle gates, in order of severity. Null means the file is usable.
|
|
523
|
+
*
|
|
524
|
+
* Retention holds block deletion even for org owners. This is the point of
|
|
525
|
+
* retention: it must bind the people who would otherwise be able to override
|
|
526
|
+
* it, or it is not a compliance control.
|
|
527
|
+
*/
|
|
528
|
+
function lifecycleDenial(file: FileRef, capability: Capability, now: Date): DenyReason | null {
|
|
529
|
+
if (file.state === 'deleted') return 'file_deleted';
|
|
530
|
+
if (file.expiresAt && file.expiresAt <= now) return 'file_expired';
|
|
531
|
+
if (file.state === 'pending' && capability === 'read') return 'file_not_ready';
|
|
532
|
+
if (capability === 'delete' && file.retainUntil && file.retainUntil > now) {
|
|
533
|
+
return 'retention_hold';
|
|
534
|
+
}
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// =============================================================================
|
|
539
|
+
// THE FILE DECISION
|
|
540
|
+
// =============================================================================
|
|
541
|
+
|
|
542
|
+
interface FileResolution {
|
|
543
|
+
decision: Decision;
|
|
544
|
+
file: FileRef | null;
|
|
545
|
+
standing: Standing | null;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* The whole file-scoped decision, exactly once, emitting exactly one event.
|
|
550
|
+
* `authorize` and `authorizeShare` both run through here so that asking a
|
|
551
|
+
* second question about the same request cannot cost a second audit event or a
|
|
552
|
+
* second round of queries.
|
|
553
|
+
*/
|
|
554
|
+
async function decideFile(
|
|
555
|
+
deps: AuthzDeps,
|
|
556
|
+
principal: Principal,
|
|
557
|
+
fileId: string,
|
|
558
|
+
capability: Capability,
|
|
559
|
+
complete = false,
|
|
560
|
+
): Promise<FileResolution> {
|
|
561
|
+
const now = deps.now();
|
|
562
|
+
const file = await deps.getFile(fileId);
|
|
563
|
+
|
|
564
|
+
// --- 1. Existence ---------------------------------------------------------
|
|
565
|
+
// Audited against the SYSTEM chain: there is no tenant to charge an
|
|
566
|
+
// enumeration probe to, and inventing one would itself leak whether the file
|
|
567
|
+
// exists. Dropping the event -- which is what we used to do -- made a file-id
|
|
568
|
+
// sweep completely invisible.
|
|
569
|
+
if (!file) {
|
|
570
|
+
return {
|
|
571
|
+
decision: await deny(deps, null, principal, fileId, capability, 'file_not_found'),
|
|
572
|
+
file: null,
|
|
573
|
+
standing: null,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// --- 2. Standing ----------------------------------------------------------
|
|
578
|
+
const standing = await resolveStanding(deps, principal, file, capability, now, complete);
|
|
579
|
+
const out = (decision: Decision): FileResolution => ({ decision, file, standing });
|
|
580
|
+
|
|
581
|
+
if (standing.halt) {
|
|
582
|
+
return out(
|
|
583
|
+
await deny(
|
|
584
|
+
deps,
|
|
585
|
+
file.orgId,
|
|
586
|
+
principal,
|
|
587
|
+
fileId,
|
|
588
|
+
capability,
|
|
589
|
+
standing.halt.reason,
|
|
590
|
+
standing.halt.grantId,
|
|
591
|
+
),
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
if (!standing.recognised) {
|
|
595
|
+
return out(
|
|
596
|
+
await deny(
|
|
597
|
+
deps,
|
|
598
|
+
file.orgId,
|
|
599
|
+
principal,
|
|
600
|
+
fileId,
|
|
601
|
+
capability,
|
|
602
|
+
noStandingReason(principal, standing.role),
|
|
603
|
+
),
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// --- 3. Capability --------------------------------------------------------
|
|
608
|
+
if (!standing.capabilities.has(capability)) {
|
|
609
|
+
return out(
|
|
610
|
+
await deny(
|
|
611
|
+
deps,
|
|
612
|
+
file.orgId,
|
|
613
|
+
principal,
|
|
614
|
+
fileId,
|
|
615
|
+
capability,
|
|
616
|
+
standing.role ? 'insufficient_role' : 'grant_wrong_capability',
|
|
617
|
+
),
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// --- 4. Lifecycle ---------------------------------------------------------
|
|
622
|
+
// Reached only by a caller who both has standing and holds the capability,
|
|
623
|
+
// so a 410 or a 409 here tells an attacker nothing they did not already have
|
|
624
|
+
// the authority to learn.
|
|
625
|
+
const lifecycle = lifecycleDenial(file, capability, now);
|
|
626
|
+
if (lifecycle) {
|
|
627
|
+
return out(
|
|
628
|
+
await deny(deps, file.orgId, principal, fileId, capability, lifecycle, standing.grant?.id),
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return out(
|
|
633
|
+
await allow(deps, file.orgId, principal, fileId, capability, standing.via!, standing.grant),
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** THE authorization decision. */
|
|
638
|
+
export async function authorize(
|
|
639
|
+
deps: AuthzDeps,
|
|
640
|
+
principal: Principal,
|
|
641
|
+
fileId: string,
|
|
642
|
+
capability: Capability,
|
|
643
|
+
): Promise<Decision> {
|
|
644
|
+
return (await decideFile(deps, principal, fileId, capability)).decision;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// =============================================================================
|
|
648
|
+
// THE SET DECISION -- "which files may this principal see?"
|
|
649
|
+
// =============================================================================
|
|
650
|
+
//
|
|
651
|
+
// Listing is the highest-frequency operation in a document workspace and
|
|
652
|
+
// historically the highest-yield IDOR surface. Until the authorized listing
|
|
653
|
+
// primitive below existed, Filelayer had no
|
|
654
|
+
// answer for it at all: the only bulk-ish path was `getFileRecord()`, which
|
|
655
|
+
// took no principal (that is now private -- see the note on it in filelayer.ts
|
|
656
|
+
// -- and `stat()` is the authorized replacement), and a developer who needed a
|
|
657
|
+
// listing screen had to hand-roll SQL over `file` and reimplement org scoping,
|
|
658
|
+
// visibility, ownership, the role matrix and the grant union themselves --
|
|
659
|
+
// which is precisely the work the product claims to have removed.
|
|
660
|
+
//
|
|
661
|
+
// THE PROBLEM THIS CREATES, STATED HONESTLY.
|
|
662
|
+
// `authorize()` is a POINT check: it resolves one (principal, file) pair by
|
|
663
|
+
// running queries and TypeScript. A listing screen needs a SET answer over
|
|
664
|
+
// thousands of rows. Calling the point check per row is O(n) round trips and
|
|
665
|
+
// does not survive a real corpus. So the predicate has to be expressible in
|
|
666
|
+
// SQL -- and the moment the same rule exists in two languages, they can drift,
|
|
667
|
+
// and a drift in the widening direction is a cross-tenant leak that no test
|
|
668
|
+
// written against `authorize()` would catch. This is the hand-rolled-check
|
|
669
|
+
// problem in a new place: a second authorization path.
|
|
670
|
+
//
|
|
671
|
+
// HOW WE AVOID WRITING IT TWICE.
|
|
672
|
+
// The role matrix and the lifecycle gates are TOTAL FUNCTIONS OVER A FINITE
|
|
673
|
+
// DOMAIN: 4 roles x 2 ownerships x 2 visibilities = 16 cells, and 3 states x
|
|
674
|
+
// expired x retained = 12 cells. So we do not re-express them in SQL. We
|
|
675
|
+
// ENUMERATE the domain and call `fileCapabilities()` and `lifecycleDenial()` --
|
|
676
|
+
// the exact functions `authorize()` calls -- to derive the set of cells that
|
|
677
|
+
// carry the capability. The SQL is then a membership test against a generated
|
|
678
|
+
// tuple list. There is still exactly one definition of the role model and one
|
|
679
|
+
// definition of the lifecycle gates, and it is the one in this file.
|
|
680
|
+
//
|
|
681
|
+
// The group-grant threshold (`subject_min_role`) is handled the same way: the
|
|
682
|
+
// 16 (floor, held role) pairs are enumerated by `membershipCells()` from
|
|
683
|
+
// `roleMeets()`, and the SQL tests a tuple against that list rather than
|
|
684
|
+
// writing `m.role >= g.subject_min_role`, which would be a second, independent
|
|
685
|
+
// statement of the role ordering living in the enum's declaration order.
|
|
686
|
+
//
|
|
687
|
+
// WHAT IS STILL EXPRESSED TWICE: the SHAPE of the union (role OR actor-grant OR
|
|
688
|
+
// group-grant OR anonymous-grant) and the join structure. That is why the differential test in
|
|
689
|
+
// `test/listing.test.ts` exists and why it is the most important test in the
|
|
690
|
+
// suite: over a randomized corpus of orgs, roles, visibilities, ownerships,
|
|
691
|
+
// grants, delegations, revocations and expiries, it asserts
|
|
692
|
+
//
|
|
693
|
+
// listFiles(p, org, cap) == { f in org : authorize(p, f, cap).allow }
|
|
694
|
+
//
|
|
695
|
+
// element for element. If those ever diverge, the set query is leaking.
|
|
696
|
+
//
|
|
697
|
+
// SCOPE BOUNDARY (deliberate, and enforced rather than documented): a principal
|
|
698
|
+
// carrying a `linkSecret` cannot list. A link is a bearer credential for ONE
|
|
699
|
+
// file; "list everything this link can see" is not a meaningful question, and
|
|
700
|
+
// answering it would require resolving a password challenge across a result
|
|
701
|
+
// set. Attempting it is refused, not silently ignored.
|
|
702
|
+
|
|
703
|
+
export interface RoleCell {
|
|
704
|
+
role: OrgRole;
|
|
705
|
+
isOwner: boolean;
|
|
706
|
+
visibility: FileVisibility;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
export interface LifecycleCell {
|
|
710
|
+
state: FileState;
|
|
711
|
+
expired: boolean;
|
|
712
|
+
retained: boolean;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
export interface ListPredicate {
|
|
716
|
+
capability: Capability;
|
|
717
|
+
/** Every (role, ownership, visibility) cell whose role-derived caps hold it. */
|
|
718
|
+
roleCells: RoleCell[];
|
|
719
|
+
/** Every (state, expired, retained) cell that survives the lifecycle gate. */
|
|
720
|
+
lifecycleCells: LifecycleCell[];
|
|
721
|
+
/**
|
|
722
|
+
* Every (grant floor, held role) pair satisfying the group-grant threshold.
|
|
723
|
+
* Derived from `roleMeets()`, so the set query tests the same rule the point
|
|
724
|
+
* check applies rather than restating `>=` in SQL.
|
|
725
|
+
*/
|
|
726
|
+
membershipCells: MembershipCell[];
|
|
727
|
+
/** Whether an anonymous grant can supply this capability at all. */
|
|
728
|
+
anonymousEligible: boolean;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
export interface ListQuery {
|
|
732
|
+
orgId: string;
|
|
733
|
+
actorId: string | null;
|
|
734
|
+
predicate: ListPredicate;
|
|
735
|
+
now: Date;
|
|
736
|
+
limit: number;
|
|
737
|
+
cursor: { createdAt: Date; id: string } | null;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** The subset of file columns listing returns. Same shape the store reads. */
|
|
741
|
+
export interface ListedFile extends FileRef {
|
|
742
|
+
name: string;
|
|
743
|
+
contentType: string;
|
|
744
|
+
sizeBytes: number | null;
|
|
745
|
+
/**
|
|
746
|
+
* An object's location is (provider, key), not key alone -- that pair is what
|
|
747
|
+
* `file_storage_key_idx` makes unique. Code that carried only the key was the
|
|
748
|
+
* shape of the bug that let `storage_provider` be hardcoded to 'memory' and go
|
|
749
|
+
* unnoticed: nothing downstream ever read the column, so nothing ever
|
|
750
|
+
* disagreed with it.
|
|
751
|
+
*/
|
|
752
|
+
storageProvider: string;
|
|
753
|
+
storageKey: string;
|
|
754
|
+
createdAt: Date;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Derive the set predicate from the point-check functions.
|
|
759
|
+
*
|
|
760
|
+
* Pure, total, and cheap enough to call per request (28 function calls). It is
|
|
761
|
+
* called per request rather than memoised so that it cannot go stale against a
|
|
762
|
+
* hot-reloaded or monkey-patched role table.
|
|
763
|
+
*/
|
|
764
|
+
export function listPredicate(capability: Capability): ListPredicate {
|
|
765
|
+
const roleCells: RoleCell[] = [];
|
|
766
|
+
for (const role of ALL_ROLES) {
|
|
767
|
+
for (const isOwner of [false, true]) {
|
|
768
|
+
for (const visibility of ALL_VISIBILITIES) {
|
|
769
|
+
if (fileCapabilities(role, isOwner, visibility).has(capability)) {
|
|
770
|
+
roleCells.push({ role, isOwner, visibility });
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// A fixed reference clock: we are probing a pure function, not reading time.
|
|
777
|
+
const now = new Date(1_000_000);
|
|
778
|
+
const past = new Date(now.getTime() - 1000);
|
|
779
|
+
const future = new Date(now.getTime() + 1000);
|
|
780
|
+
const lifecycleCells: LifecycleCell[] = [];
|
|
781
|
+
for (const state of ALL_STATES) {
|
|
782
|
+
for (const expired of [false, true]) {
|
|
783
|
+
for (const retained of [false, true]) {
|
|
784
|
+
const probe: FileRef = {
|
|
785
|
+
id: '',
|
|
786
|
+
orgId: '',
|
|
787
|
+
ownerId: null,
|
|
788
|
+
state,
|
|
789
|
+
visibility: 'private',
|
|
790
|
+
expiresAt: expired ? past : null,
|
|
791
|
+
retainUntil: retained ? future : null,
|
|
792
|
+
};
|
|
793
|
+
if (lifecycleDenial(probe, capability, now) === null) {
|
|
794
|
+
lifecycleCells.push({ state, expired, retained });
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Mirrors `resolveStanding`: the anonymous grant is consulted only for
|
|
801
|
+
// `read` on the access path. (Anonymous grants are read-only by CHECK, so
|
|
802
|
+
// this is belt and braces -- but the point check has the branch, so the set
|
|
803
|
+
// query must have it too or the two are not the same predicate.)
|
|
804
|
+
//
|
|
805
|
+
// Group grants have NO capability branch: unlike anonymous, they may carry
|
|
806
|
+
// any capability, so they are consulted for every capability -- which is
|
|
807
|
+
// exactly what `resolveStanding` does. The membership cells are the whole of
|
|
808
|
+
// the extra rule, and they are derived, not written.
|
|
809
|
+
return {
|
|
810
|
+
capability,
|
|
811
|
+
roleCells,
|
|
812
|
+
lifecycleCells,
|
|
813
|
+
membershipCells: membershipCells(),
|
|
814
|
+
anonymousEligible: capability === 'read',
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
export interface ListResult {
|
|
819
|
+
files: ListedFile[];
|
|
820
|
+
/** True when another page exists. Derived from a +1 over-fetch, not a count. */
|
|
821
|
+
hasMore: boolean;
|
|
822
|
+
/** The role the caller holds in the org, for the audit event. */
|
|
823
|
+
role: OrgRole | null;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* THE set-scoped authorization decision.
|
|
828
|
+
*
|
|
829
|
+
* Note what this does NOT do: it does not gate on org membership before
|
|
830
|
+
* running the query. That would be a SECOND, different rule -- and it would be
|
|
831
|
+
* WRONG, because a grant may be issued to an actor who is not a member of the
|
|
832
|
+
* owning org at all. Such an actor's `authorize(read)` returns allow, so their
|
|
833
|
+
* `listFiles` must return that file, or the two disagree and the set query is
|
|
834
|
+
* not the access model.
|
|
835
|
+
*
|
|
836
|
+
* The empty set is therefore the correct answer for a caller with no standing,
|
|
837
|
+
* and it is also the answer for an org that does not exist. That symmetry is
|
|
838
|
+
* deliberate: a 404 for "no such org" against a 200 for "org you cannot see"
|
|
839
|
+
* would rebuild the existence oracle that the evaluation order closes.
|
|
840
|
+
*
|
|
841
|
+
* AUDIT: ONE event per call, not one per file. Reasoning in full, because it is
|
|
842
|
+
* a judgement call, and the reasons are recorded here:
|
|
843
|
+
*
|
|
844
|
+
* - The principal made ONE decision request and the engine evaluated ONE
|
|
845
|
+
* predicate. N events would describe an operation that did not happen: the
|
|
846
|
+
* caller did not access N files, they enumerated their own authorized set.
|
|
847
|
+
* - N events would put N hash-chain writes on the critical path of a read.
|
|
848
|
+
* The chain is serialized per org (see the chain-append note in store.ts),
|
|
849
|
+
* so a 200-row page would
|
|
850
|
+
* serialize 200 writes behind one lock. That is not a cost trade, it is an
|
|
851
|
+
* availability defect.
|
|
852
|
+
* - It would make unauthenticated chain flooding trivially worse: one
|
|
853
|
+
* request would append a page's worth of events.
|
|
854
|
+
* - A per-file loop would also emit a DENY for every file the caller may not
|
|
855
|
+
* see, which in a tenant with 100k files is 100k rows per listing screen.
|
|
856
|
+
*
|
|
857
|
+
* What is NOT given up: the event records the predicate (capability), the
|
|
858
|
+
* caller's role, the result cardinality and the returned ids, so "what did this
|
|
859
|
+
* principal learn the existence of, and when" is answerable from the log. And
|
|
860
|
+
* an empty result from a caller with no standing is recorded as a DENY with the
|
|
861
|
+
* same reason vocabulary the point check uses, so enumeration of a tenant by a
|
|
862
|
+
* non-member still shows up in a `decision = 'deny'` query.
|
|
863
|
+
*/
|
|
864
|
+
export async function authorizeList(
|
|
865
|
+
deps: AuthzDeps,
|
|
866
|
+
principal: Principal,
|
|
867
|
+
orgId: string,
|
|
868
|
+
opts: { capability: Capability; limit: number; cursor: { createdAt: Date; id: string } | null },
|
|
869
|
+
): Promise<ListResult> {
|
|
870
|
+
const now = deps.now();
|
|
871
|
+
const exists = await deps.orgExists(orgId);
|
|
872
|
+
const role =
|
|
873
|
+
exists && principal.actorId ? await deps.getMembership(orgId, principal.actorId) : null;
|
|
874
|
+
|
|
875
|
+
// One row over the page size, so "is there another page" costs nothing. A
|
|
876
|
+
// count(*) would have to evaluate the predicate over the whole tenant, which
|
|
877
|
+
// is both slow and a way to measure a tenant you cannot read.
|
|
878
|
+
//
|
|
879
|
+
// NOTE: the query runs whether or not `orgExists` said yes, and `exists` is
|
|
880
|
+
// used ONLY to decide which audit chain the event belongs to. Gating the
|
|
881
|
+
// query on it would be a precondition the point check does not have.
|
|
882
|
+
//
|
|
883
|
+
// This is where a stale-scope defect was found. `getMembership` joined `org` on
|
|
884
|
+
// `deleted_at IS NULL` and `getActorGrants` did not, so a soft-deleted org
|
|
885
|
+
// stopped conferring membership while leaving outstanding grants alive:
|
|
886
|
+
// `authorize()` still allowed a grant-holder to read those files, and a
|
|
887
|
+
// version of this function that skipped the query returned LESS than
|
|
888
|
+
// `authorize()` permitted. Fail-closed, and still a divergence, and the
|
|
889
|
+
// differential test caught it.
|
|
890
|
+
//
|
|
891
|
+
// That is now closed in the schema (`grant_scope_is_live`), so BOTH sides
|
|
892
|
+
// return nothing for a deleted org -- and they return nothing for the same
|
|
893
|
+
// reason, evaluated in the same predicate, rather than by two functions that
|
|
894
|
+
// happen to agree. The structure here is unchanged on purpose: `exists`
|
|
895
|
+
// still selects a chain and never gates a query.
|
|
896
|
+
const fetched = await deps.listAuthorizedFiles({
|
|
897
|
+
orgId,
|
|
898
|
+
actorId: principal.actorId,
|
|
899
|
+
predicate: listPredicate(opts.capability),
|
|
900
|
+
now,
|
|
901
|
+
limit: opts.limit + 1,
|
|
902
|
+
cursor: opts.cursor,
|
|
903
|
+
});
|
|
904
|
+
const hasMore = fetched.length > opts.limit;
|
|
905
|
+
const files = hasMore ? fetched.slice(0, opts.limit) : fetched;
|
|
906
|
+
|
|
907
|
+
const empty = files.length === 0;
|
|
908
|
+
await deps.audit({
|
|
909
|
+
// An org we cannot confirm goes to the system chain, exactly as
|
|
910
|
+
// `authorizeOrg` does, and for the same reason.
|
|
911
|
+
orgId: exists ? orgId : null,
|
|
912
|
+
action: 'file.list',
|
|
913
|
+
decision: empty ? 'deny' : 'allow',
|
|
914
|
+
...(empty ? { reason: noStandingReason(principal, role) } : {}),
|
|
915
|
+
actorId: principal.actorId,
|
|
916
|
+
fileId: null,
|
|
917
|
+
...(principal.ip !== undefined ? { ip: principal.ip } : {}),
|
|
918
|
+
...(principal.userAgent !== undefined ? { userAgent: principal.userAgent } : {}),
|
|
919
|
+
context: {
|
|
920
|
+
...(exists ? {} : { chain: 'system', orgId }),
|
|
921
|
+
capability: opts.capability,
|
|
922
|
+
role,
|
|
923
|
+
count: files.length,
|
|
924
|
+
fileIds: files.map((f) => f.id),
|
|
925
|
+
paged: opts.cursor !== null,
|
|
926
|
+
hasMore,
|
|
927
|
+
},
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
return { files, hasMore, role };
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// =============================================================================
|
|
934
|
+
// DELEGATION -- attenuation is an authorization question
|
|
935
|
+
// =============================================================================
|
|
936
|
+
|
|
937
|
+
export type ShareDecision =
|
|
938
|
+
| {
|
|
939
|
+
allow: true;
|
|
940
|
+
via: AuthzPath;
|
|
941
|
+
/**
|
|
942
|
+
* The grant the issuer's authority came from, or null when it came from
|
|
943
|
+
* an org role. This becomes the child's `parent_grant_id`, and it is what
|
|
944
|
+
* makes revocation transitive (P4).
|
|
945
|
+
*/
|
|
946
|
+
parentGrantId: string | null;
|
|
947
|
+
/** Everything the issuer holds; the child may not exceed this. */
|
|
948
|
+
held: Capability[];
|
|
949
|
+
}
|
|
950
|
+
| { allow: false; reason: DenyReason };
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* May this principal mint a grant on this file carrying these capabilities, to
|
|
954
|
+
* this kind of subject?
|
|
955
|
+
*
|
|
956
|
+
* Three questions, and all three belong here rather than in the API layer:
|
|
957
|
+
*
|
|
958
|
+
* 1. May they share at all?
|
|
959
|
+
* 2. Is what they are handing out a SUBSET of what they hold? (capability)
|
|
960
|
+
* 3. Is who they are handing it to no WIDER than they may reach? (I6, breadth)
|
|
961
|
+
*
|
|
962
|
+
* (2) used to live in `filelayer.share()`, which meant any second entry point
|
|
963
|
+
* built on `authorize()` silently reintroduced the escalation: a holder
|
|
964
|
+
* of `{share}` minting themselves `{delete}`. It is an authorization question,
|
|
965
|
+
* so the authorization engine answers it. The schema enforces the same rule
|
|
966
|
+
* again on the row itself, because a rule that exists only in application code
|
|
967
|
+
* binds only the application.
|
|
968
|
+
*
|
|
969
|
+
* (3) is the same argument in the other dimension, and it is new (RFC-001, I6).
|
|
970
|
+
* Attenuation over capabilities says what a delegate may DO; without a rule
|
|
971
|
+
* over subject breadth, a contractor holding one `{read, share}` grant could
|
|
972
|
+
* re-grant to an entire organization -- or to `anonymous` -- and every
|
|
973
|
+
* capability check would pass, because the child's set is a subset. Authority
|
|
974
|
+
* derived from an ORG ROLE (`via` of 'role' or 'owner') may name any subject;
|
|
975
|
+
* authority derived from a GRANT may name only an `actor` or mint a `link`.
|
|
976
|
+
*
|
|
977
|
+
* The engine refuses first, so the refusal is a decision with a reason and an
|
|
978
|
+
* audit event. The trigger in schema.sql refuses the same row again, so the
|
|
979
|
+
* rule holds for a caller issuing raw SQL. Same structure, same reasoning, as
|
|
980
|
+
* capability attenuation.
|
|
981
|
+
*
|
|
982
|
+
* -----------------------------------------------------------------------------
|
|
983
|
+
* KNOWN GAP, RECORDED RATHER THAN HIDDEN: `held` IS A UNION, `parentGrantId` IS
|
|
984
|
+
* ONE ROW.
|
|
985
|
+
* -----------------------------------------------------------------------------
|
|
986
|
+
* `held` is the union of every capability the issuer holds from every source,
|
|
987
|
+
* while `parentGrantId` is the SINGLE grant that supplied `share`. When a
|
|
988
|
+
* principal holds several grants on one file, the engine can therefore approve
|
|
989
|
+
* a capability set that no single ancestor covers -- and the attenuation
|
|
990
|
+
* trigger, which compares the child against its ONE parent, then refuses the
|
|
991
|
+
* INSERT. The outcome is a 403 rather than a disclosure, so the failure is
|
|
992
|
+
* fail-CLOSED and P4 is intact; what is wrong is that a legitimate delegation
|
|
993
|
+
* can be refused, and which one depends on the order the grants come back in.
|
|
994
|
+
*
|
|
995
|
+
* That order is now defined (`getActorGrants` sorts oldest-first) so the
|
|
996
|
+
* behaviour is at least deterministic and reproducible. Closing the gap
|
|
997
|
+
* properly means either picking the parent that covers the requested set, or
|
|
998
|
+
* minting one child per contributing ancestor, and both are changes to the
|
|
999
|
+
* delegation model rather than to this rule. Out of scope for RFC-001; flagged
|
|
1000
|
+
* here so it is a decision rather than an accident.
|
|
1001
|
+
*/
|
|
1002
|
+
export async function authorizeShare(
|
|
1003
|
+
deps: AuthzDeps,
|
|
1004
|
+
principal: Principal,
|
|
1005
|
+
fileId: string,
|
|
1006
|
+
requested: readonly Capability[],
|
|
1007
|
+
opts: { subjectType?: GrantSubjectType } = {},
|
|
1008
|
+
): Promise<ShareDecision> {
|
|
1009
|
+
const resolved = await decideFile(deps, principal, fileId, 'share', true);
|
|
1010
|
+
if (!resolved.decision.allow) return { allow: false, reason: resolved.decision.reason };
|
|
1011
|
+
|
|
1012
|
+
const held = resolved.standing!.capabilities;
|
|
1013
|
+
for (const cap of requested) {
|
|
1014
|
+
if (!held.has(cap)) {
|
|
1015
|
+
await deps.audit({
|
|
1016
|
+
orgId: resolved.file!.orgId,
|
|
1017
|
+
action: 'grant.create',
|
|
1018
|
+
decision: 'deny',
|
|
1019
|
+
reason: 'attenuation_violation',
|
|
1020
|
+
actorId: principal.actorId,
|
|
1021
|
+
fileId,
|
|
1022
|
+
...(principal.ip !== undefined ? { ip: principal.ip } : {}),
|
|
1023
|
+
context: { requested: [...requested], held: [...held] },
|
|
1024
|
+
});
|
|
1025
|
+
return { allow: false, reason: 'attenuation_violation' };
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// I6. `grantId` is set exactly when the authority that carried `share` came
|
|
1030
|
+
// from a grant rather than from an org role -- it is the same value that
|
|
1031
|
+
// becomes `parent_grant_id` below, which is what makes the engine's rule and
|
|
1032
|
+
// the trigger's rule the same rule rather than two rules that agree.
|
|
1033
|
+
const parentGrantId = resolved.decision.grantId ?? null;
|
|
1034
|
+
const subjectType = opts.subjectType;
|
|
1035
|
+
if (
|
|
1036
|
+
parentGrantId !== null &&
|
|
1037
|
+
subjectType !== undefined &&
|
|
1038
|
+
!DELEGABLE_SUBJECT_TYPES.includes(subjectType)
|
|
1039
|
+
) {
|
|
1040
|
+
await deps.audit({
|
|
1041
|
+
orgId: resolved.file!.orgId,
|
|
1042
|
+
action: 'grant.create',
|
|
1043
|
+
decision: 'deny',
|
|
1044
|
+
reason: 'subject_breadth_amplification',
|
|
1045
|
+
actorId: principal.actorId,
|
|
1046
|
+
fileId,
|
|
1047
|
+
grantId: parentGrantId,
|
|
1048
|
+
...(principal.ip !== undefined ? { ip: principal.ip } : {}),
|
|
1049
|
+
context: {
|
|
1050
|
+
requestedSubjectType: subjectType,
|
|
1051
|
+
via: resolved.decision.via,
|
|
1052
|
+
delegableSubjectTypes: [...DELEGABLE_SUBJECT_TYPES],
|
|
1053
|
+
},
|
|
1054
|
+
});
|
|
1055
|
+
return { allow: false, reason: 'subject_breadth_amplification' };
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
return {
|
|
1059
|
+
allow: true,
|
|
1060
|
+
via: resolved.decision.via,
|
|
1061
|
+
// Authority derived from an org role has no parent grant; authority
|
|
1062
|
+
// derived from a grant does, and the child is bound to it forever.
|
|
1063
|
+
parentGrantId: resolved.decision.grantId ?? null,
|
|
1064
|
+
held: [...held],
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* May this principal revoke this grant?
|
|
1070
|
+
*
|
|
1071
|
+
* Found during the review of the delegation work and fixed with it: holding
|
|
1072
|
+
* `share` on a file used to mean holding revoke over EVERY grant on that file.
|
|
1073
|
+
* A contractor given a delegated `{read, share}` could therefore revoke the
|
|
1074
|
+
* owner's unrelated share links -- not a disclosure, but a straightforward
|
|
1075
|
+
* denial of service against the file's other recipients, and a strange thing
|
|
1076
|
+
* for "you may pass this on" to imply.
|
|
1077
|
+
*
|
|
1078
|
+
* The rule mirrors the delegation model rather than adding a new one:
|
|
1079
|
+
*
|
|
1080
|
+
* - authority from an org ROLE (admin, owner, or the file's own owner)
|
|
1081
|
+
* carries revoke over every grant on the file, as before;
|
|
1082
|
+
* - authority from a GRANT carries revoke only over that grant's own subtree,
|
|
1083
|
+
* which is exactly the authority it was given.
|
|
1084
|
+
*/
|
|
1085
|
+
export async function authorizeRevoke(
|
|
1086
|
+
deps: AuthzDeps,
|
|
1087
|
+
principal: Principal,
|
|
1088
|
+
grant: { id: string; fileId: string; orgId: string },
|
|
1089
|
+
): Promise<Decision> {
|
|
1090
|
+
const resolved = await decideFile(deps, principal, grant.fileId, 'share');
|
|
1091
|
+
if (!resolved.decision.allow) return resolved.decision;
|
|
1092
|
+
|
|
1093
|
+
const via = resolved.decision.grantId ?? null;
|
|
1094
|
+
if (via === null || grant.id === via || (await deps.isDescendantOf(via, grant.id))) {
|
|
1095
|
+
return resolved.decision;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
await deps.audit({
|
|
1099
|
+
orgId: grant.orgId,
|
|
1100
|
+
action: 'grant.revoke',
|
|
1101
|
+
decision: 'deny',
|
|
1102
|
+
reason: 'foreign_grant',
|
|
1103
|
+
actorId: principal.actorId,
|
|
1104
|
+
fileId: grant.fileId,
|
|
1105
|
+
grantId: grant.id,
|
|
1106
|
+
...(principal.ip !== undefined ? { ip: principal.ip } : {}),
|
|
1107
|
+
context: { viaGrantId: via },
|
|
1108
|
+
});
|
|
1109
|
+
return { allow: false, reason: 'foreign_grant' };
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// =============================================================================
|
|
1113
|
+
// THE ORG DECISION
|
|
1114
|
+
// =============================================================================
|
|
1115
|
+
|
|
1116
|
+
export async function authorizeOrg(
|
|
1117
|
+
deps: AuthzDeps,
|
|
1118
|
+
principal: Principal,
|
|
1119
|
+
orgId: string,
|
|
1120
|
+
capability: OrgCapability,
|
|
1121
|
+
opts: { action?: string; emitAllow?: boolean } = {},
|
|
1122
|
+
): Promise<Decision> {
|
|
1123
|
+
const action = opts.action ?? `org.${capability}`;
|
|
1124
|
+
const emitAllow = opts.emitAllow ?? true;
|
|
1125
|
+
|
|
1126
|
+
// An org we cannot see is audited to the system chain, for the same reason a
|
|
1127
|
+
// file we cannot see is: attributing the event would confirm the org id, and
|
|
1128
|
+
// the audit row's own foreign key would fail anyway.
|
|
1129
|
+
const exists = await deps.orgExists(orgId);
|
|
1130
|
+
if (!exists || !principal.actorId) {
|
|
1131
|
+
return denyOrg(deps, exists ? orgId : null, principal, orgId, action, 'no_membership');
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
const role = await deps.getMembership(orgId, principal.actorId);
|
|
1135
|
+
if (!role) return denyOrg(deps, orgId, principal, orgId, action, 'no_membership');
|
|
1136
|
+
if (!orgCapabilities(role).has(capability)) {
|
|
1137
|
+
return denyOrg(deps, orgId, principal, orgId, action, 'insufficient_role');
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
if (emitAllow) {
|
|
1141
|
+
await deps.audit({
|
|
1142
|
+
orgId,
|
|
1143
|
+
action,
|
|
1144
|
+
decision: 'allow',
|
|
1145
|
+
actorId: principal.actorId,
|
|
1146
|
+
fileId: null,
|
|
1147
|
+
...(principal.ip !== undefined ? { ip: principal.ip } : {}),
|
|
1148
|
+
...(principal.userAgent !== undefined ? { userAgent: principal.userAgent } : {}),
|
|
1149
|
+
context: { via: 'role', role, capability },
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
return { allow: true, via: 'role' };
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* May this principal set `targetActorId` to `newRole` (null = remove them)?
|
|
1157
|
+
*
|
|
1158
|
+
* Membership is the privilege that confers every other privilege, so the rules
|
|
1159
|
+
* are stated here rather than left to the application:
|
|
1160
|
+
*
|
|
1161
|
+
* - you must hold `manage_members` (admin or owner);
|
|
1162
|
+
* - you may not grant a role above your own -- otherwise `admin` is just
|
|
1163
|
+
* `owner` with an extra step;
|
|
1164
|
+
* - you may not modify anyone who currently outranks you;
|
|
1165
|
+
* - you may not remove or demote the last owner, because an org nobody
|
|
1166
|
+
* administers cannot honour a retention hold or a deletion request.
|
|
1167
|
+
*
|
|
1168
|
+
* Exactly one audit event is emitted per attempt, allow or deny. A membership
|
|
1169
|
+
* change that leaves no trace is indistinguishable from a breach after the
|
|
1170
|
+
* fact.
|
|
1171
|
+
*/
|
|
1172
|
+
export async function authorizeMembershipChange(
|
|
1173
|
+
deps: AuthzDeps,
|
|
1174
|
+
principal: Principal,
|
|
1175
|
+
orgId: string,
|
|
1176
|
+
targetActorId: string,
|
|
1177
|
+
newRole: OrgRole | null,
|
|
1178
|
+
): Promise<Decision> {
|
|
1179
|
+
const orgReal = await deps.orgExists(orgId);
|
|
1180
|
+
const currentRole = orgReal ? await deps.getMembership(orgId, targetActorId) : null;
|
|
1181
|
+
const action =
|
|
1182
|
+
newRole === null ? 'member.remove' : currentRole === null ? 'member.add' : 'member.role_change';
|
|
1183
|
+
|
|
1184
|
+
const base = await authorizeOrg(deps, principal, orgId, 'manage_members', {
|
|
1185
|
+
action,
|
|
1186
|
+
emitAllow: false,
|
|
1187
|
+
});
|
|
1188
|
+
if (!base.allow) return base;
|
|
1189
|
+
|
|
1190
|
+
const actorRole = (await deps.getMembership(orgId, principal.actorId!))!;
|
|
1191
|
+
const context = { targetActorId, fromRole: currentRole, toRole: newRole, byRole: actorRole };
|
|
1192
|
+
|
|
1193
|
+
const settle = async (reason: DenyReason | null): Promise<Decision> => {
|
|
1194
|
+
await deps.audit({
|
|
1195
|
+
orgId,
|
|
1196
|
+
action,
|
|
1197
|
+
decision: reason ? 'deny' : 'allow',
|
|
1198
|
+
...(reason ? { reason } : {}),
|
|
1199
|
+
actorId: principal.actorId,
|
|
1200
|
+
fileId: null,
|
|
1201
|
+
...(principal.ip !== undefined ? { ip: principal.ip } : {}),
|
|
1202
|
+
...(principal.userAgent !== undefined ? { userAgent: principal.userAgent } : {}),
|
|
1203
|
+
context,
|
|
1204
|
+
});
|
|
1205
|
+
return reason ? { allow: false, reason } : { allow: true, via: 'role' };
|
|
1206
|
+
};
|
|
1207
|
+
|
|
1208
|
+
if (currentRole !== null && ROLE_RANK[currentRole] > ROLE_RANK[actorRole]) {
|
|
1209
|
+
return settle('superior_target');
|
|
1210
|
+
}
|
|
1211
|
+
if (newRole !== null && ROLE_RANK[newRole] > ROLE_RANK[actorRole]) {
|
|
1212
|
+
return settle('role_escalation');
|
|
1213
|
+
}
|
|
1214
|
+
if (currentRole === 'owner' && newRole !== 'owner' && (await deps.countOwners(orgId)) <= 1) {
|
|
1215
|
+
return settle('last_owner');
|
|
1216
|
+
}
|
|
1217
|
+
return settle(null);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// =============================================================================
|
|
1221
|
+
// DECISION CORE -- the only places an access event is written
|
|
1222
|
+
// =============================================================================
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* I4. For a group grant, "why did this succeed?" is not answered by `via`
|
|
1226
|
+
* alone -- the caller is one of a population, and a compliance auditor needs to know
|
|
1227
|
+
* which membership put them in it. The event already carries `actor_id` and
|
|
1228
|
+
* `grant_id`, so the missing half is the subject org, the floor the grant
|
|
1229
|
+
* asked for, and the role the caller actually held. All three come out of the
|
|
1230
|
+
* membership join that already matched; none of them costs a second query.
|
|
1231
|
+
*/
|
|
1232
|
+
function membershipContext(grant: GrantRow | null): Record<string, unknown> {
|
|
1233
|
+
if (!grant || grant.subjectOrgId === null) return {};
|
|
1234
|
+
return {
|
|
1235
|
+
viaOrgId: grant.subjectOrgId,
|
|
1236
|
+
viaMinRole: grant.subjectMinRole,
|
|
1237
|
+
...(grant.matchedRole !== undefined ? { viaRole: grant.matchedRole } : {}),
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
async function allow(
|
|
1242
|
+
deps: AuthzDeps,
|
|
1243
|
+
orgId: string,
|
|
1244
|
+
p: Principal,
|
|
1245
|
+
fileId: string,
|
|
1246
|
+
capability: Capability,
|
|
1247
|
+
via: AuthzPath,
|
|
1248
|
+
grant: GrantRow | null,
|
|
1249
|
+
): Promise<Decision> {
|
|
1250
|
+
await deps.audit({
|
|
1251
|
+
orgId,
|
|
1252
|
+
action: `file.${capability}`,
|
|
1253
|
+
decision: 'allow',
|
|
1254
|
+
actorId: p.actorId,
|
|
1255
|
+
fileId,
|
|
1256
|
+
grantId: grant?.id ?? null,
|
|
1257
|
+
...(p.ip !== undefined ? { ip: p.ip } : {}),
|
|
1258
|
+
...(p.userAgent !== undefined ? { userAgent: p.userAgent } : {}),
|
|
1259
|
+
context: { via, ...membershipContext(grant) },
|
|
1260
|
+
});
|
|
1261
|
+
return {
|
|
1262
|
+
allow: true,
|
|
1263
|
+
via,
|
|
1264
|
+
...(grant ? { grantId: grant.id } : {}),
|
|
1265
|
+
remainingDownloads:
|
|
1266
|
+
grant == null || grant.maxDownloads == null ? null : grant.maxDownloads - grant.downloadCount,
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
async function deny(
|
|
1271
|
+
deps: AuthzDeps,
|
|
1272
|
+
orgId: string | null,
|
|
1273
|
+
p: Principal,
|
|
1274
|
+
fileId: string,
|
|
1275
|
+
capability: Capability,
|
|
1276
|
+
reason: DenyReason,
|
|
1277
|
+
grantId?: string,
|
|
1278
|
+
): Promise<Decision> {
|
|
1279
|
+
await deps.audit({
|
|
1280
|
+
orgId,
|
|
1281
|
+
action: `file.${capability}`,
|
|
1282
|
+
decision: 'deny',
|
|
1283
|
+
reason,
|
|
1284
|
+
actorId: p.actorId,
|
|
1285
|
+
fileId,
|
|
1286
|
+
grantId: grantId ?? null,
|
|
1287
|
+
...(p.ip !== undefined ? { ip: p.ip } : {}),
|
|
1288
|
+
...(p.userAgent !== undefined ? { userAgent: p.userAgent } : {}),
|
|
1289
|
+
...(orgId === null ? { context: { chain: 'system' } } : {}),
|
|
1290
|
+
});
|
|
1291
|
+
return { allow: false, reason };
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
async function denyOrg(
|
|
1295
|
+
deps: AuthzDeps,
|
|
1296
|
+
auditOrgId: string | null,
|
|
1297
|
+
p: Principal,
|
|
1298
|
+
orgId: string,
|
|
1299
|
+
action: string,
|
|
1300
|
+
reason: DenyReason,
|
|
1301
|
+
): Promise<Decision> {
|
|
1302
|
+
await deps.audit({
|
|
1303
|
+
orgId: auditOrgId,
|
|
1304
|
+
action,
|
|
1305
|
+
decision: 'deny',
|
|
1306
|
+
reason,
|
|
1307
|
+
actorId: p.actorId,
|
|
1308
|
+
fileId: null,
|
|
1309
|
+
...(p.ip !== undefined ? { ip: p.ip } : {}),
|
|
1310
|
+
...(p.userAgent !== undefined ? { userAgent: p.userAgent } : {}),
|
|
1311
|
+
context: auditOrgId === null ? { chain: 'system', orgId } : { orgId },
|
|
1312
|
+
});
|
|
1313
|
+
return { allow: false, reason };
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
/**
|
|
1317
|
+
* Emit an audit event for a decision that never reached the engine because the
|
|
1318
|
+
* credential presented could not be resolved to anything at all -- a link
|
|
1319
|
+
* secret matching no grant. There is no file and no tenant, so it goes to the
|
|
1320
|
+
* system chain. A truncated hash of the presented secret is recorded so a
|
|
1321
|
+
* brute-force sweep is correlatable; it is a 48-bit prefix of a SHA-256, not
|
|
1322
|
+
* the credential, and it is useless without the original.
|
|
1323
|
+
*/
|
|
1324
|
+
export async function auditUnresolvedSecret(
|
|
1325
|
+
deps: AuthzDeps,
|
|
1326
|
+
principal: Principal,
|
|
1327
|
+
secretHash: string,
|
|
1328
|
+
): Promise<void> {
|
|
1329
|
+
await deps.audit({
|
|
1330
|
+
orgId: null,
|
|
1331
|
+
action: 'file.read',
|
|
1332
|
+
decision: 'deny',
|
|
1333
|
+
reason: 'bad_link_secret',
|
|
1334
|
+
actorId: principal.actorId,
|
|
1335
|
+
fileId: null,
|
|
1336
|
+
...(principal.ip !== undefined ? { ip: principal.ip } : {}),
|
|
1337
|
+
...(principal.userAgent !== undefined ? { userAgent: principal.userAgent } : {}),
|
|
1338
|
+
context: { chain: 'system', secretHashPrefix: secretHash.slice(0, 12) },
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/**
|
|
1343
|
+
* The refusals the schema raises on its own account (see the attenuation
|
|
1344
|
+
* trigger in schema.sql).
|
|
1345
|
+
*
|
|
1346
|
+
* The engine refuses these cases first, so a trigger firing means the engine
|
|
1347
|
+
* and the schema disagree -- which is a bug worth an alert, not a stack trace
|
|
1348
|
+
* in a caller's face. The vocabulary lives here, next to the rules it mirrors,
|
|
1349
|
+
* so that the two cannot drift apart in separate files.
|
|
1350
|
+
*/
|
|
1351
|
+
const SCHEMA_REFUSAL =
|
|
1352
|
+
/^(grant_capability_amplification|grant_subject_amplification|grant_parent_not_live|grant_parent_exhausted|grant_parent_missing|grant_delegation_too_deep|grant_lineage_immutable)/;
|
|
1353
|
+
|
|
1354
|
+
export function schemaRefusal(err: unknown): string | null {
|
|
1355
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1356
|
+
return SCHEMA_REFUSAL.test(message) ? message.split(':')[0]! : null;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/**
|
|
1360
|
+
* Collapse internal deny reasons into what the caller is told.
|
|
1361
|
+
*
|
|
1362
|
+
* Internally we record precisely why access was denied, because that is what
|
|
1363
|
+
* makes the audit log useful for incident response. Externally we return 404
|
|
1364
|
+
* for everything that would otherwise confirm a file's existence -- otherwise
|
|
1365
|
+
* the error message becomes an enumeration oracle across tenants.
|
|
1366
|
+
*
|
|
1367
|
+
* Grant-level lifecycle reasons (revoked / expired / exhausted / ancestor dead)
|
|
1368
|
+
* collapse to 404 as well. A 410 "your link is used up" would be friendlier and
|
|
1369
|
+
* is reachable only by someone holding a 256-bit secret, so the leak is
|
|
1370
|
+
* theoretical -- but it costs nothing to make a dead link indistinguishable
|
|
1371
|
+
* from a forged one, and the audit log carries the true reason for the operator
|
|
1372
|
+
* who actually needs it.
|
|
1373
|
+
*
|
|
1374
|
+
* The asymmetry between what we log and what we return is intentional and is
|
|
1375
|
+
* the kind of decision a hand-rolled integration requires developers to make
|
|
1376
|
+
* themselves, in every route, correctly, every time.
|
|
1377
|
+
*/
|
|
1378
|
+
export function toPublicError(reason: DenyReason): { status: number; code: string } {
|
|
1379
|
+
switch (reason) {
|
|
1380
|
+
case 'bad_password':
|
|
1381
|
+
return { status: 401, code: 'password_required' };
|
|
1382
|
+
case 'file_expired':
|
|
1383
|
+
return { status: 410, code: 'gone' };
|
|
1384
|
+
case 'retention_hold':
|
|
1385
|
+
return { status: 409, code: 'retention_hold' };
|
|
1386
|
+
// Attenuation and membership-management refusals are answered to a caller
|
|
1387
|
+
// who has already proven standing and already knows the resource exists:
|
|
1388
|
+
// 403 leaks nothing and is far more useful than a 404.
|
|
1389
|
+
case 'attenuation_violation':
|
|
1390
|
+
case 'subject_breadth_amplification':
|
|
1391
|
+
case 'role_escalation':
|
|
1392
|
+
case 'superior_target':
|
|
1393
|
+
case 'last_owner':
|
|
1394
|
+
return { status: 403, code: 'forbidden' };
|
|
1395
|
+
default:
|
|
1396
|
+
return { status: 404, code: 'not_found' };
|
|
1397
|
+
}
|
|
1398
|
+
}
|