@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +338 -0
  2. package/LICENSE +202 -0
  3. package/MIGRATIONS.md +328 -0
  4. package/NOTICE +37 -0
  5. package/README.md +343 -0
  6. package/SEMANTICS.md +729 -0
  7. package/dist/authz.d.ts +524 -0
  8. package/dist/authz.d.ts.map +1 -0
  9. package/dist/authz.js +889 -0
  10. package/dist/authz.js.map +1 -0
  11. package/dist/db.d.ts +145 -0
  12. package/dist/db.d.ts.map +1 -0
  13. package/dist/db.js +217 -0
  14. package/dist/db.js.map +1 -0
  15. package/dist/delivery.d.ts +293 -0
  16. package/dist/delivery.d.ts.map +1 -0
  17. package/dist/delivery.js +519 -0
  18. package/dist/delivery.js.map +1 -0
  19. package/dist/errors.d.ts +16 -0
  20. package/dist/errors.d.ts.map +1 -0
  21. package/dist/errors.js +21 -0
  22. package/dist/errors.js.map +1 -0
  23. package/dist/filelayer.d.ts +542 -0
  24. package/dist/filelayer.d.ts.map +1 -0
  25. package/dist/filelayer.js +1360 -0
  26. package/dist/filelayer.js.map +1 -0
  27. package/dist/index.d.ts +8 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +8 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/simple.d.ts +297 -0
  32. package/dist/simple.d.ts.map +1 -0
  33. package/dist/simple.js +492 -0
  34. package/dist/simple.js.map +1 -0
  35. package/dist/storage.d.ts +269 -0
  36. package/dist/storage.d.ts.map +1 -0
  37. package/dist/storage.js +700 -0
  38. package/dist/storage.js.map +1 -0
  39. package/dist/store.d.ts +432 -0
  40. package/dist/store.d.ts.map +1 -0
  41. package/dist/store.js +862 -0
  42. package/dist/store.js.map +1 -0
  43. package/package.json +77 -0
  44. package/schema.sql +1190 -0
  45. package/src/authz.ts +1398 -0
  46. package/src/db.ts +271 -0
  47. package/src/delivery.ts +737 -0
  48. package/src/errors.ts +24 -0
  49. package/src/filelayer.ts +1836 -0
  50. package/src/index.ts +7 -0
  51. package/src/simple.ts +666 -0
  52. package/src/storage.ts +917 -0
  53. package/src/store.ts +1072 -0
  54. package/test/delivery.test.ts +0 -0
  55. package/test/group-subjects.test.ts +1072 -0
  56. package/test/helpers.ts +65 -0
  57. package/test/listing.test.ts +689 -0
  58. package/test/local-s3.d.mts +33 -0
  59. package/test/local-s3.mjs +400 -0
  60. package/test/persistence.test.ts +953 -0
  61. package/test/regression.test.ts +619 -0
  62. package/test/s3-live.test.ts +322 -0
  63. package/test/security.test.ts +1652 -0
  64. package/test/semantics.test.ts +888 -0
  65. package/test/storage.test.ts +437 -0
  66. package/test/tiers.test.ts +432 -0
  67. package/test/vault-example.test.ts +302 -0
  68. package/tsconfig.build.json +29 -0
  69. package/tsconfig.json +19 -0
@@ -0,0 +1,524 @@
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
+ export type Capability = 'read' | 'write' | 'delete' | 'share';
52
+ export type OrgRole = 'viewer' | 'member' | 'admin' | 'owner';
53
+ export type FileVisibility = 'private' | 'org';
54
+ export type FileState = 'pending' | 'ready' | 'deleted';
55
+ export declare const ALL_CAPABILITIES: readonly Capability[];
56
+ /**
57
+ * Privileges that are not about a particular file.
58
+ *
59
+ * Deliberately a closed, tiny set for the same reason `org_role` is: an
60
+ * unbounded permission vocabulary is an authorization model nobody can audit.
61
+ */
62
+ export type OrgCapability = 'create_file' | 'manage_members' | 'read_audit';
63
+ /**
64
+ * A grant's subject is a PRINCIPAL SET (RFC-001). See the `grant_subject` note
65
+ * in schema.sql for the full reasoning; the ordering that matters here is
66
+ *
67
+ * actor < role <= org < anonymous (link is orthogonal)
68
+ *
69
+ * and it is what I6 attenuates over.
70
+ */
71
+ export type GrantSubjectType = 'actor' | 'org' | 'role' | 'link' | 'anonymous';
72
+ /**
73
+ * The subject types an issuer whose authority is GRANT-DERIVED may mint (I6).
74
+ *
75
+ * `actor` names one person and `link` is a bearer credential for one file;
76
+ * neither widens the population the issuer could already reach. `org`, `role`
77
+ * and `anonymous` all do, so a delegate may not create them at any depth.
78
+ *
79
+ * Exported so the rule is nameable in one place and testable directly, and so
80
+ * that adding a sixth subject type forces a decision about it here rather than
81
+ * silently defaulting to "delegable".
82
+ */
83
+ export declare const DELEGABLE_SUBJECT_TYPES: readonly GrantSubjectType[];
84
+ /**
85
+ * THE ROLE THRESHOLD, DEFINED ONCE.
86
+ *
87
+ * `subject_min_role` is a floor over the existing four-value `org_role` enum
88
+ * and nothing more -- no custom roles, no nesting, no configurable inheritance.
89
+ * A `role` grant matches a principal holding `actual` iff this returns true; an
90
+ * `org` grant is the same question with `min = 'viewer'`.
91
+ *
92
+ * Both the point check (`getGroupGrants`) and the set query
93
+ * (`listAuthorizedFiles`) need this rule in SQL. Neither restates it: both are
94
+ * generated from `membershipCells()` below, which is generated from this
95
+ * function, exactly as the role matrix is generated from `fileCapabilities()`.
96
+ */
97
+ export declare function roleMeets(actual: OrgRole, min: OrgRole): boolean;
98
+ /** One (floor, held) pair for which the threshold holds. */
99
+ export interface MembershipCell {
100
+ minRole: OrgRole;
101
+ role: OrgRole;
102
+ }
103
+ /**
104
+ * Every (floor, held) pair that satisfies `roleMeets`. 4 x 4 = 16 probes of a
105
+ * pure function; 10 cells survive. This is the whole group-membership rule, in
106
+ * a form SQL can test with a tuple `IN` list.
107
+ */
108
+ export declare function membershipCells(): MembershipCell[];
109
+ export interface Principal {
110
+ /** Null means anonymous: a caller presenting only a link secret. */
111
+ actorId: string | null;
112
+ /** Present only when redeeming a share link. */
113
+ linkSecret?: string;
114
+ /** Present only when the share link is password protected. */
115
+ password?: string;
116
+ ip?: string;
117
+ userAgent?: string;
118
+ }
119
+ export interface FileRef {
120
+ id: string;
121
+ orgId: string;
122
+ ownerId: string | null;
123
+ state: 'pending' | 'ready' | 'deleted';
124
+ visibility: FileVisibility;
125
+ expiresAt: Date | null;
126
+ retainUntil: Date | null;
127
+ }
128
+ export type Decision = {
129
+ allow: true;
130
+ via: AuthzPath;
131
+ grantId?: string;
132
+ remainingDownloads?: number | null;
133
+ } | {
134
+ allow: false;
135
+ reason: DenyReason;
136
+ };
137
+ /**
138
+ * Where an allow came from. `grant:org` and `grant:role` are the group paths
139
+ * (RFC-001, I4): the audit event records not just that a grant conferred
140
+ * access, but WHICH MEMBERSHIP did -- the subject org, the floor the grant
141
+ * asked for, and the role the caller actually held in that org. Someone reading
142
+ * the audit log can still answer "why did this succeed?" without joining
143
+ * anything.
144
+ */
145
+ export type AuthzPath = 'owner' | 'role' | 'grant:actor' | 'grant:org' | 'grant:role' | 'grant:link' | 'grant:anonymous';
146
+ export type DenyReason = 'file_not_found' | 'file_deleted' | 'file_expired' | 'file_not_ready' | 'no_membership' | 'insufficient_role' | 'no_grant' | 'grant_revoked' | 'grant_expired' | 'grant_exhausted' | 'grant_ancestor_dead' | 'grant_wrong_capability' | 'bad_link_secret' | 'bad_password' | 'foreign_grant' | 'retention_hold' | 'attenuation_violation'
147
+ /**
148
+ * I6 (RFC-001). The issuer's authority was grant-derived and they asked to
149
+ * mint a subject wider than `actor` or `link`. Capability attenuation stops
150
+ * you doing more; this stops you reaching more people.
151
+ */
152
+ | 'subject_breadth_amplification' | 'role_escalation' | 'superior_target' | 'last_owner';
153
+ /**
154
+ * What each org role may do to a file in its own org.
155
+ *
156
+ * Deliberately small and total. Every cell is enumerated -- there is no
157
+ * fallthrough, no "and also if", no special case. A reviewer can read this
158
+ * table in ten seconds and know the entire role model, which is the point:
159
+ * an authorization model you cannot hold in your head is one you cannot audit.
160
+ *
161
+ * `visibility` closes the "every viewer in an org could read every file in it"
162
+ * defect. Under the default ('private') a file is not
163
+ * visible to the org at large at all; membership alone buys nothing at the file
164
+ * boundary. Org admins and owners keep full access under both settings, because
165
+ * retention, deletion and legal hold are their responsibility, and a control
166
+ * the accountable party cannot exercise is not a control.
167
+ */
168
+ export declare function fileCapabilities(role: OrgRole, isOwner: boolean, visibility: FileVisibility): Set<Capability>;
169
+ /**
170
+ * What each org role may do to the organization itself.
171
+ *
172
+ * The same shape as the file table above, and enumerated for the same reason.
173
+ * `manage_members` is admin+: membership is the most powerful thing in the
174
+ * system, because it is the thing that confers everything else.
175
+ */
176
+ export declare function orgCapabilities(role: OrgRole): Set<OrgCapability>;
177
+ export interface AuthzDeps {
178
+ getFile(fileId: string): Promise<FileRef | null>;
179
+ orgExists(orgId: string): Promise<boolean>;
180
+ getMembership(orgId: string, actorId: string): Promise<OrgRole | null>;
181
+ countOwners(orgId: string): Promise<number>;
182
+ /** Live grants only: the store must apply the `live_grant` predicate. */
183
+ getActorGrants(fileId: string, actorId: string): Promise<GrantRow[]>;
184
+ /**
185
+ * Live GROUP grants ('org' / 'role') on this file that this actor matches
186
+ * through a live membership (RFC-001).
187
+ *
188
+ * THE CONTRACT THAT MAKES THE HEADLINE PROPERTY TRUE: this is a JOIN against
189
+ * `membership`, evaluated now. It is never a lookup into a materialized
190
+ * member list, and the engine never caches its result across requests. That
191
+ * is what makes "membership changes must change access on the next request,
192
+ * without recomputation" true BY CONSTRUCTION rather than by a background job
193
+ * that is usually up to date. An implementation of `AuthzDeps` that fans a
194
+ * group grant out into per-member rows satisfies the type and breaks the
195
+ * product.
196
+ *
197
+ * The returned rows carry `matchedRole` -- the role the actor actually holds
198
+ * in the subject org -- so the audit event can record which membership
199
+ * conferred access (I4).
200
+ */
201
+ getGroupGrants(fileId: string, actorId: string): Promise<GrantRow[]>;
202
+ findLiveGrantBySecret(secretHash: string): Promise<GrantRow | null>;
203
+ /**
204
+ * ANY grant with this secret hash, live or not. Used ONLY to attribute and
205
+ * classify a denial (P5) -- never to authorize. Without it a revoked,
206
+ * expired or capped-out link is indistinguishable from a forged one, and the
207
+ * compliance log cannot answer "why did my link stop working".
208
+ */
209
+ findGrantBySecret(secretHash: string): Promise<GrantRow | null>;
210
+ /** Is `grantId` the same grant as, or delegated from, `ancestorId`? */
211
+ isDescendantOf(ancestorId: string, grantId: string): Promise<boolean>;
212
+ getAnonymousGrant(fileId: string): Promise<GrantRow | null>;
213
+ /**
214
+ * The set form of the decision. Given a predicate DERIVED from the same
215
+ * `fileCapabilities` / `lifecycleDenial` functions the point check uses, the
216
+ * store returns the files in one org for which that predicate holds.
217
+ *
218
+ * It takes a `ListPredicate`, not a WHERE clause. There is no way for a
219
+ * caller to widen it, and no way to call it without one.
220
+ */
221
+ listAuthorizedFiles(query: ListQuery): Promise<ListedFile[]>;
222
+ hashSecret(secret: string): Promise<string>;
223
+ verifyPassword(password: string, hash: string): Promise<boolean>;
224
+ audit(event: AuditInput): Promise<void>;
225
+ now(): Date;
226
+ }
227
+ export interface GrantRow {
228
+ id: string;
229
+ fileId: string;
230
+ orgId: string;
231
+ parentGrantId: string | null;
232
+ subjectType: GrantSubjectType;
233
+ /** Set for 'org' and 'role' grants: the org whose members are the subject. */
234
+ subjectOrgId: string | null;
235
+ /** Set for 'role' grants only: the floor. Null on 'org' reads as 'viewer'. */
236
+ subjectMinRole: OrgRole | null;
237
+ /**
238
+ * For a group grant resolved for a specific principal: the role that
239
+ * principal actually holds in `subjectOrgId`. Not a column -- it comes out of
240
+ * the membership join that matched -- and it exists so the audit event can
241
+ * name WHICH MEMBERSHIP conferred access (I4) without a second query.
242
+ */
243
+ matchedRole?: OrgRole;
244
+ capabilities: Capability[];
245
+ passwordHash: string | null;
246
+ expiresAt: Date | null;
247
+ maxDownloads: number | null;
248
+ downloadCount: number;
249
+ revokedAt: Date | null;
250
+ }
251
+ export interface AuditInput {
252
+ /** Null is the SYSTEM chain: a decision with no tenant to charge it to. */
253
+ orgId: string | null;
254
+ action: string;
255
+ decision: 'allow' | 'deny';
256
+ reason?: string;
257
+ actorId: string | null;
258
+ fileId: string | null;
259
+ grantId?: string | null;
260
+ ip?: string;
261
+ userAgent?: string;
262
+ context?: Record<string, unknown>;
263
+ }
264
+ /** THE authorization decision. */
265
+ export declare function authorize(deps: AuthzDeps, principal: Principal, fileId: string, capability: Capability): Promise<Decision>;
266
+ export interface RoleCell {
267
+ role: OrgRole;
268
+ isOwner: boolean;
269
+ visibility: FileVisibility;
270
+ }
271
+ export interface LifecycleCell {
272
+ state: FileState;
273
+ expired: boolean;
274
+ retained: boolean;
275
+ }
276
+ export interface ListPredicate {
277
+ capability: Capability;
278
+ /** Every (role, ownership, visibility) cell whose role-derived caps hold it. */
279
+ roleCells: RoleCell[];
280
+ /** Every (state, expired, retained) cell that survives the lifecycle gate. */
281
+ lifecycleCells: LifecycleCell[];
282
+ /**
283
+ * Every (grant floor, held role) pair satisfying the group-grant threshold.
284
+ * Derived from `roleMeets()`, so the set query tests the same rule the point
285
+ * check applies rather than restating `>=` in SQL.
286
+ */
287
+ membershipCells: MembershipCell[];
288
+ /** Whether an anonymous grant can supply this capability at all. */
289
+ anonymousEligible: boolean;
290
+ }
291
+ export interface ListQuery {
292
+ orgId: string;
293
+ actorId: string | null;
294
+ predicate: ListPredicate;
295
+ now: Date;
296
+ limit: number;
297
+ cursor: {
298
+ createdAt: Date;
299
+ id: string;
300
+ } | null;
301
+ }
302
+ /** The subset of file columns listing returns. Same shape the store reads. */
303
+ export interface ListedFile extends FileRef {
304
+ name: string;
305
+ contentType: string;
306
+ sizeBytes: number | null;
307
+ /**
308
+ * An object's location is (provider, key), not key alone -- that pair is what
309
+ * `file_storage_key_idx` makes unique. Code that carried only the key was the
310
+ * shape of the bug that let `storage_provider` be hardcoded to 'memory' and go
311
+ * unnoticed: nothing downstream ever read the column, so nothing ever
312
+ * disagreed with it.
313
+ */
314
+ storageProvider: string;
315
+ storageKey: string;
316
+ createdAt: Date;
317
+ }
318
+ /**
319
+ * Derive the set predicate from the point-check functions.
320
+ *
321
+ * Pure, total, and cheap enough to call per request (28 function calls). It is
322
+ * called per request rather than memoised so that it cannot go stale against a
323
+ * hot-reloaded or monkey-patched role table.
324
+ */
325
+ export declare function listPredicate(capability: Capability): ListPredicate;
326
+ export interface ListResult {
327
+ files: ListedFile[];
328
+ /** True when another page exists. Derived from a +1 over-fetch, not a count. */
329
+ hasMore: boolean;
330
+ /** The role the caller holds in the org, for the audit event. */
331
+ role: OrgRole | null;
332
+ }
333
+ /**
334
+ * THE set-scoped authorization decision.
335
+ *
336
+ * Note what this does NOT do: it does not gate on org membership before
337
+ * running the query. That would be a SECOND, different rule -- and it would be
338
+ * WRONG, because a grant may be issued to an actor who is not a member of the
339
+ * owning org at all. Such an actor's `authorize(read)` returns allow, so their
340
+ * `listFiles` must return that file, or the two disagree and the set query is
341
+ * not the access model.
342
+ *
343
+ * The empty set is therefore the correct answer for a caller with no standing,
344
+ * and it is also the answer for an org that does not exist. That symmetry is
345
+ * deliberate: a 404 for "no such org" against a 200 for "org you cannot see"
346
+ * would rebuild the existence oracle that the evaluation order closes.
347
+ *
348
+ * AUDIT: ONE event per call, not one per file. Reasoning in full, because it is
349
+ * a judgement call, and the reasons are recorded here:
350
+ *
351
+ * - The principal made ONE decision request and the engine evaluated ONE
352
+ * predicate. N events would describe an operation that did not happen: the
353
+ * caller did not access N files, they enumerated their own authorized set.
354
+ * - N events would put N hash-chain writes on the critical path of a read.
355
+ * The chain is serialized per org (see the chain-append note in store.ts),
356
+ * so a 200-row page would
357
+ * serialize 200 writes behind one lock. That is not a cost trade, it is an
358
+ * availability defect.
359
+ * - It would make unauthenticated chain flooding trivially worse: one
360
+ * request would append a page's worth of events.
361
+ * - A per-file loop would also emit a DENY for every file the caller may not
362
+ * see, which in a tenant with 100k files is 100k rows per listing screen.
363
+ *
364
+ * What is NOT given up: the event records the predicate (capability), the
365
+ * caller's role, the result cardinality and the returned ids, so "what did this
366
+ * principal learn the existence of, and when" is answerable from the log. And
367
+ * an empty result from a caller with no standing is recorded as a DENY with the
368
+ * same reason vocabulary the point check uses, so enumeration of a tenant by a
369
+ * non-member still shows up in a `decision = 'deny'` query.
370
+ */
371
+ export declare function authorizeList(deps: AuthzDeps, principal: Principal, orgId: string, opts: {
372
+ capability: Capability;
373
+ limit: number;
374
+ cursor: {
375
+ createdAt: Date;
376
+ id: string;
377
+ } | null;
378
+ }): Promise<ListResult>;
379
+ export type ShareDecision = {
380
+ allow: true;
381
+ via: AuthzPath;
382
+ /**
383
+ * The grant the issuer's authority came from, or null when it came from
384
+ * an org role. This becomes the child's `parent_grant_id`, and it is what
385
+ * makes revocation transitive (P4).
386
+ */
387
+ parentGrantId: string | null;
388
+ /** Everything the issuer holds; the child may not exceed this. */
389
+ held: Capability[];
390
+ } | {
391
+ allow: false;
392
+ reason: DenyReason;
393
+ };
394
+ /**
395
+ * May this principal mint a grant on this file carrying these capabilities, to
396
+ * this kind of subject?
397
+ *
398
+ * Three questions, and all three belong here rather than in the API layer:
399
+ *
400
+ * 1. May they share at all?
401
+ * 2. Is what they are handing out a SUBSET of what they hold? (capability)
402
+ * 3. Is who they are handing it to no WIDER than they may reach? (I6, breadth)
403
+ *
404
+ * (2) used to live in `filelayer.share()`, which meant any second entry point
405
+ * built on `authorize()` silently reintroduced the escalation: a holder
406
+ * of `{share}` minting themselves `{delete}`. It is an authorization question,
407
+ * so the authorization engine answers it. The schema enforces the same rule
408
+ * again on the row itself, because a rule that exists only in application code
409
+ * binds only the application.
410
+ *
411
+ * (3) is the same argument in the other dimension, and it is new (RFC-001, I6).
412
+ * Attenuation over capabilities says what a delegate may DO; without a rule
413
+ * over subject breadth, a contractor holding one `{read, share}` grant could
414
+ * re-grant to an entire organization -- or to `anonymous` -- and every
415
+ * capability check would pass, because the child's set is a subset. Authority
416
+ * derived from an ORG ROLE (`via` of 'role' or 'owner') may name any subject;
417
+ * authority derived from a GRANT may name only an `actor` or mint a `link`.
418
+ *
419
+ * The engine refuses first, so the refusal is a decision with a reason and an
420
+ * audit event. The trigger in schema.sql refuses the same row again, so the
421
+ * rule holds for a caller issuing raw SQL. Same structure, same reasoning, as
422
+ * capability attenuation.
423
+ *
424
+ * -----------------------------------------------------------------------------
425
+ * KNOWN GAP, RECORDED RATHER THAN HIDDEN: `held` IS A UNION, `parentGrantId` IS
426
+ * ONE ROW.
427
+ * -----------------------------------------------------------------------------
428
+ * `held` is the union of every capability the issuer holds from every source,
429
+ * while `parentGrantId` is the SINGLE grant that supplied `share`. When a
430
+ * principal holds several grants on one file, the engine can therefore approve
431
+ * a capability set that no single ancestor covers -- and the attenuation
432
+ * trigger, which compares the child against its ONE parent, then refuses the
433
+ * INSERT. The outcome is a 403 rather than a disclosure, so the failure is
434
+ * fail-CLOSED and P4 is intact; what is wrong is that a legitimate delegation
435
+ * can be refused, and which one depends on the order the grants come back in.
436
+ *
437
+ * That order is now defined (`getActorGrants` sorts oldest-first) so the
438
+ * behaviour is at least deterministic and reproducible. Closing the gap
439
+ * properly means either picking the parent that covers the requested set, or
440
+ * minting one child per contributing ancestor, and both are changes to the
441
+ * delegation model rather than to this rule. Out of scope for RFC-001; flagged
442
+ * here so it is a decision rather than an accident.
443
+ */
444
+ export declare function authorizeShare(deps: AuthzDeps, principal: Principal, fileId: string, requested: readonly Capability[], opts?: {
445
+ subjectType?: GrantSubjectType;
446
+ }): Promise<ShareDecision>;
447
+ /**
448
+ * May this principal revoke this grant?
449
+ *
450
+ * Found during the review of the delegation work and fixed with it: holding
451
+ * `share` on a file used to mean holding revoke over EVERY grant on that file.
452
+ * A contractor given a delegated `{read, share}` could therefore revoke the
453
+ * owner's unrelated share links -- not a disclosure, but a straightforward
454
+ * denial of service against the file's other recipients, and a strange thing
455
+ * for "you may pass this on" to imply.
456
+ *
457
+ * The rule mirrors the delegation model rather than adding a new one:
458
+ *
459
+ * - authority from an org ROLE (admin, owner, or the file's own owner)
460
+ * carries revoke over every grant on the file, as before;
461
+ * - authority from a GRANT carries revoke only over that grant's own subtree,
462
+ * which is exactly the authority it was given.
463
+ */
464
+ export declare function authorizeRevoke(deps: AuthzDeps, principal: Principal, grant: {
465
+ id: string;
466
+ fileId: string;
467
+ orgId: string;
468
+ }): Promise<Decision>;
469
+ export declare function authorizeOrg(deps: AuthzDeps, principal: Principal, orgId: string, capability: OrgCapability, opts?: {
470
+ action?: string;
471
+ emitAllow?: boolean;
472
+ }): Promise<Decision>;
473
+ /**
474
+ * May this principal set `targetActorId` to `newRole` (null = remove them)?
475
+ *
476
+ * Membership is the privilege that confers every other privilege, so the rules
477
+ * are stated here rather than left to the application:
478
+ *
479
+ * - you must hold `manage_members` (admin or owner);
480
+ * - you may not grant a role above your own -- otherwise `admin` is just
481
+ * `owner` with an extra step;
482
+ * - you may not modify anyone who currently outranks you;
483
+ * - you may not remove or demote the last owner, because an org nobody
484
+ * administers cannot honour a retention hold or a deletion request.
485
+ *
486
+ * Exactly one audit event is emitted per attempt, allow or deny. A membership
487
+ * change that leaves no trace is indistinguishable from a breach after the
488
+ * fact.
489
+ */
490
+ export declare function authorizeMembershipChange(deps: AuthzDeps, principal: Principal, orgId: string, targetActorId: string, newRole: OrgRole | null): Promise<Decision>;
491
+ /**
492
+ * Emit an audit event for a decision that never reached the engine because the
493
+ * credential presented could not be resolved to anything at all -- a link
494
+ * secret matching no grant. There is no file and no tenant, so it goes to the
495
+ * system chain. A truncated hash of the presented secret is recorded so a
496
+ * brute-force sweep is correlatable; it is a 48-bit prefix of a SHA-256, not
497
+ * the credential, and it is useless without the original.
498
+ */
499
+ export declare function auditUnresolvedSecret(deps: AuthzDeps, principal: Principal, secretHash: string): Promise<void>;
500
+ export declare function schemaRefusal(err: unknown): string | null;
501
+ /**
502
+ * Collapse internal deny reasons into what the caller is told.
503
+ *
504
+ * Internally we record precisely why access was denied, because that is what
505
+ * makes the audit log useful for incident response. Externally we return 404
506
+ * for everything that would otherwise confirm a file's existence -- otherwise
507
+ * the error message becomes an enumeration oracle across tenants.
508
+ *
509
+ * Grant-level lifecycle reasons (revoked / expired / exhausted / ancestor dead)
510
+ * collapse to 404 as well. A 410 "your link is used up" would be friendlier and
511
+ * is reachable only by someone holding a 256-bit secret, so the leak is
512
+ * theoretical -- but it costs nothing to make a dead link indistinguishable
513
+ * from a forged one, and the audit log carries the true reason for the operator
514
+ * who actually needs it.
515
+ *
516
+ * The asymmetry between what we log and what we return is intentional and is
517
+ * the kind of decision a hand-rolled integration requires developers to make
518
+ * themselves, in every route, correctly, every time.
519
+ */
520
+ export declare function toPublicError(reason: DenyReason): {
521
+ status: number;
522
+ code: string;
523
+ };
524
+ //# sourceMappingURL=authz.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"authz.d.ts","sourceRoot":"","sources":["../src/authz.ts"],"names":[],"mappings":"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"}