@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/filelayer.ts
ADDED
|
@@ -0,0 +1,1836 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FILELAYER -- the surface an application developer actually touches.
|
|
3
|
+
*
|
|
4
|
+
* Design rule for this file: every method that touches a file or an org calls
|
|
5
|
+
* into the authorization engine and does nothing before it. There is no
|
|
6
|
+
* "internal" variant that skips the check, because the moment such a variant
|
|
7
|
+
* exists someone will call it from a route handler at 6pm on a Friday.
|
|
8
|
+
*
|
|
9
|
+
* SECOND design rule, added after the security review: this file contains no
|
|
10
|
+
* security LOGIC, only security PLUMBING. Every rule that used to live here as
|
|
11
|
+
* a "patch at the wrong layer" has moved into `authz.ts` or `schema.sql`:
|
|
12
|
+
*
|
|
13
|
+
* - capability attenuation on share -> authorizeShare() + a BEFORE
|
|
14
|
+
* INSERT trigger on file_grant
|
|
15
|
+
* - the 410/409 existence-oracle downgrade
|
|
16
|
+
* and its `hasStanding()` helper -> evaluation order in authorize()
|
|
17
|
+
* - the membership / viewer checks in
|
|
18
|
+
* upload() -> authorizeOrg('create_file')
|
|
19
|
+
* - the admin check in auditLog() -> authorizeOrg('read_audit')
|
|
20
|
+
* - the unaudited early return in redeem()
|
|
21
|
+
* for unknown secrets -> the engine's system chain
|
|
22
|
+
*
|
|
23
|
+
* Errors are thrown as `FilelayerError`, already collapsed through
|
|
24
|
+
* `toPublicError`, so the developer cannot accidentally return our internal
|
|
25
|
+
* deny reason to an attacker. That collapsing is a security-sensitive decision
|
|
26
|
+
* we make once, here, instead of asking the developer to make it per-route.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
30
|
+
import {
|
|
31
|
+
auditUnresolvedSecret,
|
|
32
|
+
authorize,
|
|
33
|
+
authorizeList,
|
|
34
|
+
authorizeMembershipChange,
|
|
35
|
+
authorizeOrg,
|
|
36
|
+
authorizeRevoke,
|
|
37
|
+
authorizeShare,
|
|
38
|
+
schemaRefusal,
|
|
39
|
+
toPublicError,
|
|
40
|
+
type Capability,
|
|
41
|
+
type Decision,
|
|
42
|
+
type FileRef,
|
|
43
|
+
type FileVisibility,
|
|
44
|
+
type GrantSubjectType,
|
|
45
|
+
type OrgRole,
|
|
46
|
+
type Principal,
|
|
47
|
+
} from './authz.ts';
|
|
48
|
+
import {
|
|
49
|
+
CommitThenThrow,
|
|
50
|
+
withTransaction,
|
|
51
|
+
type Queryable,
|
|
52
|
+
type Tx,
|
|
53
|
+
} from './db.ts';
|
|
54
|
+
import {
|
|
55
|
+
PostgresStore,
|
|
56
|
+
DEFAULT_PROJECT_ID,
|
|
57
|
+
isUuid,
|
|
58
|
+
toCapabilities,
|
|
59
|
+
LIST_DEFAULT_LIMIT,
|
|
60
|
+
LIST_MAX_LIMIT,
|
|
61
|
+
type AuditRow,
|
|
62
|
+
type AuditChainResult,
|
|
63
|
+
} from './store.ts';
|
|
64
|
+
import {
|
|
65
|
+
canList,
|
|
66
|
+
canPresign,
|
|
67
|
+
collectStream,
|
|
68
|
+
type ObjectStream,
|
|
69
|
+
type PutBody,
|
|
70
|
+
type StorageAdapter,
|
|
71
|
+
} from './storage.ts';
|
|
72
|
+
import { FilesApi, OrgsApi, SharesApi } from './simple.ts';
|
|
73
|
+
import {
|
|
74
|
+
contentDisposition,
|
|
75
|
+
deliveryHeaders,
|
|
76
|
+
isActiveContentType,
|
|
77
|
+
redirectHeaders,
|
|
78
|
+
resolveRedirectConfig,
|
|
79
|
+
safeContentType,
|
|
80
|
+
type Disposition,
|
|
81
|
+
type ProxyDelivery,
|
|
82
|
+
type RedirectDelivery,
|
|
83
|
+
type RedirectDeliveryConfig,
|
|
84
|
+
type ResolvedRedirectConfig,
|
|
85
|
+
type StreamedDelivery,
|
|
86
|
+
} from './delivery.ts';
|
|
87
|
+
import { FilelayerError } from './errors.ts';
|
|
88
|
+
|
|
89
|
+
// Declared in `errors.ts` so that `delivery.ts` can classify errors without
|
|
90
|
+
// importing this module (which imports `delivery.ts`). Re-exported here because
|
|
91
|
+
// this is where every existing caller imports it from.
|
|
92
|
+
export { FilelayerError };
|
|
93
|
+
|
|
94
|
+
export interface UploadInput {
|
|
95
|
+
name: string;
|
|
96
|
+
contentType: string;
|
|
97
|
+
/**
|
|
98
|
+
* Advisory. The AUTHORITATIVE size is what the adapter reports it actually
|
|
99
|
+
* wrote, and that is what lands in `size_bytes`. A caller-supplied size that
|
|
100
|
+
* disagrees with the object is how a `content-length` ends up truncating a
|
|
101
|
+
* download.
|
|
102
|
+
*/
|
|
103
|
+
size?: number;
|
|
104
|
+
/**
|
|
105
|
+
* Bytes, or a stream of bytes.
|
|
106
|
+
*
|
|
107
|
+
* A `Uint8Array` is the convenient form for the small-file case that
|
|
108
|
+
* dominates (avatars, PDFs, attachments) and is kept for exactly that reason.
|
|
109
|
+
* A `ReadableStream` is the form that does not put the whole object on the
|
|
110
|
+
* heap: with the S3 adapter it becomes a multipart upload whose peak memory
|
|
111
|
+
* is one part, whatever the object's size.
|
|
112
|
+
*/
|
|
113
|
+
body: PutBody;
|
|
114
|
+
/**
|
|
115
|
+
* Who, inside the owning org, can see this file before anybody shares it.
|
|
116
|
+
*
|
|
117
|
+
* 'private' (DEFAULT) -- owner + org admins/owners only. Everyone else
|
|
118
|
+
* needs an explicit grant.
|
|
119
|
+
* 'org' -- every member of the org may read it.
|
|
120
|
+
*
|
|
121
|
+
* The default is the restrictive one. See schema.sql, `file_visibility`.
|
|
122
|
+
*/
|
|
123
|
+
visibility?: FileVisibility;
|
|
124
|
+
/** Seconds until the file itself expires (lifecycle, not a grant). */
|
|
125
|
+
expiresIn?: number;
|
|
126
|
+
/** Seconds of retention floor: deletion is blocked until it passes. */
|
|
127
|
+
retainFor?: number;
|
|
128
|
+
metadata?: Record<string, unknown>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* WHO a grant is for. A grant's subject is a PRINCIPAL SET (RFC-001):
|
|
133
|
+
*
|
|
134
|
+
* actor exactly one person
|
|
135
|
+
* role every member of an org at role >= `minRole`
|
|
136
|
+
* org every member of an org, at any role
|
|
137
|
+
* link whoever holds the secret (bearer, not identity)
|
|
138
|
+
* anonymous everyone
|
|
139
|
+
*
|
|
140
|
+
* `org` and `role` may name an org OTHER than the file's own -- that is the
|
|
141
|
+
* point ("the company that posted this job may read this CV") -- but it must be
|
|
142
|
+
* an org in the same PROJECT. Cross-project is unrepresentable, by composite
|
|
143
|
+
* foreign key, and refused here with a 404 before it gets that far.
|
|
144
|
+
*
|
|
145
|
+
* I6: an issuer whose own authority came from a GRANT may only mint `actor` or
|
|
146
|
+
* `link`. See `authorizeShare`.
|
|
147
|
+
*/
|
|
148
|
+
export type ShareSubject =
|
|
149
|
+
| { type: 'link' }
|
|
150
|
+
| { type: 'anonymous' }
|
|
151
|
+
| { type: 'actor'; actorId: string }
|
|
152
|
+
/** Every member of `orgId`, at any role. */
|
|
153
|
+
| { type: 'org'; orgId: string }
|
|
154
|
+
/** Every member of `orgId` at `minRole` or above. */
|
|
155
|
+
| { type: 'role'; orgId: string; minRole: OrgRole };
|
|
156
|
+
|
|
157
|
+
export interface ShareInput {
|
|
158
|
+
subject: ShareSubject;
|
|
159
|
+
capabilities?: Capability[];
|
|
160
|
+
expiresIn?: number;
|
|
161
|
+
maxDownloads?: number;
|
|
162
|
+
password?: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface ShareResult {
|
|
166
|
+
grantId: string;
|
|
167
|
+
/** Returned exactly once. Only its SHA-256 is persisted. */
|
|
168
|
+
secret?: string;
|
|
169
|
+
url?: string;
|
|
170
|
+
/**
|
|
171
|
+
* The EFFECTIVE lifetime and cap, after attenuation against the parent grant.
|
|
172
|
+
* A delegated share can never exceed the authority it came from, so these may
|
|
173
|
+
* be tighter than what was asked for. They are returned rather than silently
|
|
174
|
+
* applied so that the clamp is visible to the caller.
|
|
175
|
+
*/
|
|
176
|
+
expiresAt: Date | null;
|
|
177
|
+
maxDownloads: number | null;
|
|
178
|
+
/** Non-null when this grant was delegated from another grant (P4). */
|
|
179
|
+
parentGrantId: string | null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface FileRecord extends FileRef {
|
|
183
|
+
name: string;
|
|
184
|
+
contentType: string;
|
|
185
|
+
sizeBytes: number | null;
|
|
186
|
+
/**
|
|
187
|
+
* WHICH store the bytes are in. This column used to be written as the literal
|
|
188
|
+
* `'memory'` on every insert regardless of the configured adapter, which
|
|
189
|
+
* meant a production deployment recorded every object as living in an
|
|
190
|
+
* in-process Map. It participates in `file_storage_key_idx`
|
|
191
|
+
* (UNIQUE (storage_provider, storage_key)), so it is half of an object's
|
|
192
|
+
* identity, not a label.
|
|
193
|
+
*/
|
|
194
|
+
storageProvider: string;
|
|
195
|
+
storageKey: string;
|
|
196
|
+
createdAt: Date;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A COMMITTED delivery decision, waiting for bytes.
|
|
201
|
+
*
|
|
202
|
+
* The split between this and the bytes is the whole shape of the fix: the
|
|
203
|
+
* decision, the download charge and the audit event commit as one unit, and
|
|
204
|
+
* only then does anything touch the object store -- which can take minutes and
|
|
205
|
+
* must not hold a database connection while it does.
|
|
206
|
+
*/
|
|
207
|
+
type Reservation =
|
|
208
|
+
| {
|
|
209
|
+
kind: 'proxy';
|
|
210
|
+
file: FileRecord;
|
|
211
|
+
headers: Record<string, string>;
|
|
212
|
+
remainingDownloads: number | null;
|
|
213
|
+
grantId: string | null;
|
|
214
|
+
}
|
|
215
|
+
| {
|
|
216
|
+
kind: 'redirect';
|
|
217
|
+
file: FileRecord;
|
|
218
|
+
headers: Record<string, string>;
|
|
219
|
+
remainingDownloads: number | null;
|
|
220
|
+
grantId: string | null;
|
|
221
|
+
url: string;
|
|
222
|
+
expiresAt: Date;
|
|
223
|
+
ttlSeconds: number;
|
|
224
|
+
cacheable: boolean;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
export interface GrantSummary {
|
|
228
|
+
id: string;
|
|
229
|
+
fileId: string;
|
|
230
|
+
parentGrantId: string | null;
|
|
231
|
+
subjectType: GrantSubjectType;
|
|
232
|
+
subjectId: string | null;
|
|
233
|
+
/** The org whose members are the subject, for 'org' and 'role' grants. */
|
|
234
|
+
subjectOrgId: string | null;
|
|
235
|
+
/** The role floor, for 'role' grants. Null on 'org' reads as 'viewer'. */
|
|
236
|
+
subjectMinRole: OrgRole | null;
|
|
237
|
+
capabilities: Capability[];
|
|
238
|
+
hasPassword: boolean;
|
|
239
|
+
expiresAt: Date | null;
|
|
240
|
+
maxDownloads: number | null;
|
|
241
|
+
downloadCount: number;
|
|
242
|
+
revokedAt: Date | null;
|
|
243
|
+
/** Recursive liveness: false if this grant OR any ancestor is dead. */
|
|
244
|
+
live: boolean;
|
|
245
|
+
createdBy: string | null;
|
|
246
|
+
createdAt: Date;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export interface FilelayerOptions {
|
|
250
|
+
baseUrl?: string;
|
|
251
|
+
/**
|
|
252
|
+
* The customer application this instance speaks for (P8).
|
|
253
|
+
*
|
|
254
|
+
* In a hosted deployment the API layer resolves a project from the request's API
|
|
255
|
+
* key and constructs one of these bound to it. A bound instance cannot see,
|
|
256
|
+
* list, audit or address anything in another project. Omit it and everything
|
|
257
|
+
* lands in the default project, which is the correct behaviour for a
|
|
258
|
+
* single-project deployment. Pass `null` for the control plane.
|
|
259
|
+
*/
|
|
260
|
+
projectId?: string | null;
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* OPT-IN REDIRECT DELIVERY. Absent means every delivery is proxied, which is
|
|
264
|
+
* the only mode with an unqualified "revocation is immediate" guarantee.
|
|
265
|
+
*
|
|
266
|
+
* Read the DELIVERY MODES block at the top of `delivery.ts` before setting
|
|
267
|
+
* this. It will not typecheck without the acknowledgement string, and the
|
|
268
|
+
* acknowledgement string says what you are accepting.
|
|
269
|
+
*/
|
|
270
|
+
redirectDelivery?: RedirectDeliveryConfig;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export class Filelayer {
|
|
274
|
+
readonly store: PostgresStore;
|
|
275
|
+
|
|
276
|
+
private readonly db: Queryable;
|
|
277
|
+
private readonly storage: StorageAdapter;
|
|
278
|
+
private readonly opts: FilelayerOptions;
|
|
279
|
+
|
|
280
|
+
private _files?: FilesApi;
|
|
281
|
+
private _orgs?: OrgsApi;
|
|
282
|
+
private _shares?: SharesApi;
|
|
283
|
+
|
|
284
|
+
/** Null unless redirect delivery was configured AND acknowledged. */
|
|
285
|
+
private readonly redirect: ResolvedRedirectConfig | null;
|
|
286
|
+
|
|
287
|
+
constructor(db: Queryable, storage: StorageAdapter, opts: FilelayerOptions = {}) {
|
|
288
|
+
this.db = db;
|
|
289
|
+
this.storage = storage;
|
|
290
|
+
this.opts = opts;
|
|
291
|
+
// A provider name is half of an object's primary identity (see the UNIQUE
|
|
292
|
+
// index). An adapter that does not supply one is a configuration error, not
|
|
293
|
+
// a default to guess at -- guessing is how it became 'memory' in the first
|
|
294
|
+
// place.
|
|
295
|
+
if (typeof storage.provider !== 'string' || storage.provider.length === 0) {
|
|
296
|
+
throw new Error('storage adapter must declare a non-empty `provider`');
|
|
297
|
+
}
|
|
298
|
+
this.store = new PostgresStore(db, {
|
|
299
|
+
...(opts.projectId !== undefined ? { projectId: opts.projectId } : {}),
|
|
300
|
+
});
|
|
301
|
+
this.redirect = opts.redirectDelivery ? resolveRedirectConfig(opts.redirectDelivery) : null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Run a unit of work in one transaction, on one connection.
|
|
306
|
+
*
|
|
307
|
+
* `fn` receives a store bound to the transaction, so the audit events the
|
|
308
|
+
* engine writes and the mutation they describe commit together -- and so
|
|
309
|
+
* `audit_append()`'s advisory lock, which is a `pg_advisory_XACT_lock`, is
|
|
310
|
+
* held across the whole unit rather than across one autocommit statement.
|
|
311
|
+
*
|
|
312
|
+
* THE ONE SUBTLETY, AND IT IS THE IMPORTANT ONE.
|
|
313
|
+
*
|
|
314
|
+
* A DENIAL writes an audit event and then throws. If a throw always rolled
|
|
315
|
+
* back we would lose exactly the events P5 exists to keep, silently, while
|
|
316
|
+
* the caller still saw their 403 -- an audit log that omits refusals is worse
|
|
317
|
+
* than no audit log, because it looks complete.
|
|
318
|
+
*
|
|
319
|
+
* So `FilelayerError` -- and ONLY `FilelayerError` -- is treated as a DECIDED
|
|
320
|
+
* outcome: commit, then throw. Every `FilelayerError` this library raises is
|
|
321
|
+
* a decision or a lookup miss, never a half-applied mutation; the one place
|
|
322
|
+
* that could have been (the schema attenuation backstop in `share()`) uses a
|
|
323
|
+
* SAVEPOINT so the failed INSERT is undone before the deny event is written.
|
|
324
|
+
* Anything else -- a driver error, an unanticipated constraint, a bug --
|
|
325
|
+
* rolls the whole unit back.
|
|
326
|
+
*/
|
|
327
|
+
#transaction<T>(fn: (tx: Tx, store: PostgresStore) => Promise<T>): Promise<T> {
|
|
328
|
+
return withTransaction(this.db, async (tx) => {
|
|
329
|
+
try {
|
|
330
|
+
return await fn(tx, this.store.withDb(tx));
|
|
331
|
+
} catch (err) {
|
|
332
|
+
if (err instanceof FilelayerError) throw new CommitThenThrow(err);
|
|
333
|
+
throw err;
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* A throwaway, in-process instance: PGlite + in-memory bytes.
|
|
340
|
+
*
|
|
341
|
+
* For a five-minute first run and for tests. **Everything is lost when the
|
|
342
|
+
* process exits** -- there is no file on disk and no bucket. Production is
|
|
343
|
+
* `new Filelayer(pgPool, new S3Storage({...}), { baseUrl })`; see
|
|
344
|
+
* docs/QUICKSTART.md, which does not hide the three configuration steps.
|
|
345
|
+
*/
|
|
346
|
+
static async quickstart(opts: { baseUrl?: string } = {}): Promise<Filelayer> {
|
|
347
|
+
const { createTestDb } = await import('./db.ts');
|
|
348
|
+
const { MemoryStorage } = await import('./storage.ts');
|
|
349
|
+
const { db } = await createTestDb();
|
|
350
|
+
return new Filelayer(db, new MemoryStorage(), {
|
|
351
|
+
baseUrl: opts.baseUrl ?? 'http://localhost:3000',
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Where public URLs are rooted. Read-only; set once at construction. */
|
|
356
|
+
get baseUrl(): string | undefined {
|
|
357
|
+
return this.opts.baseUrl;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** The project this instance is bound to. Null means unscoped. */
|
|
361
|
+
get projectId(): string | null {
|
|
362
|
+
return this.store.projectId;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
// The tiered surface (see src/simple.ts). Purely additive: every method below
|
|
367
|
+
// this line is unchanged, and the facade calls into it rather than around it.
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
get files(): FilesApi {
|
|
371
|
+
return (this._files ??= new FilesApi(this));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
get orgs(): OrgsApi {
|
|
375
|
+
return (this._orgs ??= new OrgsApi(this));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
get shares(): SharesApi {
|
|
379
|
+
return (this._shares ??= new SharesApi(this));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// Tenancy
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Create an organization, optionally with its first owner.
|
|
388
|
+
*
|
|
389
|
+
* Creating a tenant is a control-plane operation: there is no principal
|
|
390
|
+
* inside the system yet who could be authorized to do it, and pretending
|
|
391
|
+
* otherwise would be theatre. Passing `ownerActorId` closes the bootstrap
|
|
392
|
+
* gap that would otherwise exist -- an org is never memberless, so there is
|
|
393
|
+
* never a "the org has no members yet, let anyone in" path for an attacker to
|
|
394
|
+
* find. Every subsequent membership change is authorized (see `addMember`).
|
|
395
|
+
*/
|
|
396
|
+
async createOrg(
|
|
397
|
+
externalId: string,
|
|
398
|
+
name?: string,
|
|
399
|
+
opts: { ownerActorId?: string } = {},
|
|
400
|
+
): Promise<{ id: string }> {
|
|
401
|
+
const { rows } = await this.db.query<{ id: string }>(
|
|
402
|
+
`INSERT INTO org (project_id, external_id, name)
|
|
403
|
+
VALUES (coalesce($3::uuid, '${DEFAULT_PROJECT_ID}'::uuid), $1, $2) RETURNING id`,
|
|
404
|
+
[externalId, name ?? null, this.projectId],
|
|
405
|
+
);
|
|
406
|
+
const id = rows[0]!.id;
|
|
407
|
+
|
|
408
|
+
if (opts.ownerActorId) {
|
|
409
|
+
await this.db.query(
|
|
410
|
+
`INSERT INTO membership (org_id, actor_id, role) VALUES ($1,$2,'owner')`,
|
|
411
|
+
[id, opts.ownerActorId],
|
|
412
|
+
);
|
|
413
|
+
await this.store.audit({
|
|
414
|
+
orgId: id,
|
|
415
|
+
action: 'member.bootstrap',
|
|
416
|
+
decision: 'allow',
|
|
417
|
+
actorId: opts.ownerActorId,
|
|
418
|
+
fileId: null,
|
|
419
|
+
context: { targetActorId: opts.ownerActorId, toRole: 'owner', via: 'org.create' },
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
return { id };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async createActor(externalId: string): Promise<{ id: string }> {
|
|
426
|
+
const { rows } = await this.db.query<{ id: string }>(
|
|
427
|
+
`INSERT INTO actor (project_id, external_id)
|
|
428
|
+
VALUES (coalesce($2::uuid, '${DEFAULT_PROJECT_ID}'::uuid), $1) RETURNING id`,
|
|
429
|
+
[externalId, this.projectId],
|
|
430
|
+
);
|
|
431
|
+
return { id: rows[0]!.id };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Register a customer application. Control plane; see the note on the
|
|
436
|
+
* lifecycle methods below for why these three take no principal.
|
|
437
|
+
*/
|
|
438
|
+
async createProject(key: string, name?: string): Promise<{ id: string }> {
|
|
439
|
+
const { rows } = await this.db.query<{ id: string }>(
|
|
440
|
+
`INSERT INTO project (key, name) VALUES ($1, $2)
|
|
441
|
+
ON CONFLICT (key) DO UPDATE SET key = EXCLUDED.key
|
|
442
|
+
RETURNING id`,
|
|
443
|
+
[key, name ?? null],
|
|
444
|
+
);
|
|
445
|
+
return { id: rows[0]!.id };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
// Lifecycle: soft delete and restore (P7)
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
//
|
|
452
|
+
// WHY THESE ARE CONTROL-PLANE OPERATIONS AND TAKE NO `Principal`.
|
|
453
|
+
//
|
|
454
|
+
// It is tempting to require `owner` in the org to delete it. That design has
|
|
455
|
+
// a trap in it: deleting an org kills membership-derived access (that is the
|
|
456
|
+
// whole point), so the moment it succeeds NOBODY holds a role in that org and
|
|
457
|
+
// therefore nobody can ever restore it. An authorization rule that makes its
|
|
458
|
+
// own inverse unreachable is not a rule, it is a one-way door.
|
|
459
|
+
//
|
|
460
|
+
// So org and actor lifecycle sits where org and actor CREATION already sits:
|
|
461
|
+
// the control plane, authenticated by the customer's project credential at
|
|
462
|
+
// the API boundary rather than by an end-user principal inside the model.
|
|
463
|
+
// That boundary sits above the engine and is the same one that
|
|
464
|
+
// authenticates every other request. This is stated as an explicit
|
|
465
|
+
// operational requirement in SEMANTICS.md rather than left implicit.
|
|
466
|
+
//
|
|
467
|
+
// Every one of them is audited to the affected tenant's chain, so a
|
|
468
|
+
// control-plane action is as visible in the compliance record as a user one.
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Soft-delete a tenant. Every grant on every file in it is dead on the next
|
|
472
|
+
* request; every membership stops conferring anything. Nothing is erased and
|
|
473
|
+
* no row a retention hold protects is touched, so this cannot be used to
|
|
474
|
+
* defeat retention -- see SEMANTICS.md.
|
|
475
|
+
*/
|
|
476
|
+
async softDeleteOrg(orgId: string): Promise<void> {
|
|
477
|
+
await this.#setOrgDeleted(orgId, true);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Exactly reverses `softDeleteOrg`. Liveness is derived, so nothing is lost. */
|
|
481
|
+
async restoreOrg(orgId: string): Promise<void> {
|
|
482
|
+
await this.#setOrgDeleted(orgId, false);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async #setOrgDeleted(orgId: string, deleted: boolean): Promise<void> {
|
|
486
|
+
if (!isUuid(orgId)) throw new FilelayerError(404, 'not_found');
|
|
487
|
+
const { rows } = await this.db.query<{ id: string }>(
|
|
488
|
+
`UPDATE org SET deleted_at = ${deleted ? 'now()' : 'NULL'}
|
|
489
|
+
WHERE id = $1 AND ($2::uuid IS NULL OR project_id = $2::uuid)
|
|
490
|
+
RETURNING id`,
|
|
491
|
+
[orgId, this.projectId],
|
|
492
|
+
);
|
|
493
|
+
if (!rows[0]) throw new FilelayerError(404, 'not_found');
|
|
494
|
+
await this.store.audit({
|
|
495
|
+
orgId,
|
|
496
|
+
action: deleted ? 'org.delete' : 'org.restore',
|
|
497
|
+
decision: 'allow',
|
|
498
|
+
actorId: null,
|
|
499
|
+
fileId: null,
|
|
500
|
+
context: { via: 'control_plane' },
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Soft-delete an identity.
|
|
506
|
+
*
|
|
507
|
+
* Three things die at once, and all three are derived rather than written:
|
|
508
|
+
* their role-derived access, every grant issued TO them, and every grant they
|
|
509
|
+
* ISSUED. The third is the judgement call; the reasoning is in schema.sql at
|
|
510
|
+
* `grant_scope_is_live` and in SEMANTICS.md. It is loud on purpose: deleting
|
|
511
|
+
* a prolific sharer revokes a lot of links, and that is the correct reading of
|
|
512
|
+
* P4, not a side effect.
|
|
513
|
+
*/
|
|
514
|
+
async softDeleteActor(actorId: string): Promise<void> {
|
|
515
|
+
await this.#setActorDeleted(actorId, true);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async restoreActor(actorId: string): Promise<void> {
|
|
519
|
+
await this.#setActorDeleted(actorId, false);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async #setActorDeleted(actorId: string, deleted: boolean): Promise<void> {
|
|
523
|
+
if (!isUuid(actorId)) throw new FilelayerError(404, 'not_found');
|
|
524
|
+
const { rows } = await this.db.query<{ id: string }>(
|
|
525
|
+
`UPDATE actor SET deleted_at = ${deleted ? 'now()' : 'NULL'}
|
|
526
|
+
WHERE id = $1 AND ($2::uuid IS NULL OR project_id = $2::uuid)
|
|
527
|
+
RETURNING id`,
|
|
528
|
+
[actorId, this.projectId],
|
|
529
|
+
);
|
|
530
|
+
if (!rows[0]) throw new FilelayerError(404, 'not_found');
|
|
531
|
+
// Attributed to every org the identity is a member of: "who lost access
|
|
532
|
+
// here, and when" must be answerable from each affected tenant's own chain.
|
|
533
|
+
const { rows: orgs } = await this.db.query<{ org_id: string }>(
|
|
534
|
+
`SELECT org_id FROM membership WHERE actor_id = $1`,
|
|
535
|
+
[actorId],
|
|
536
|
+
);
|
|
537
|
+
for (const o of orgs) {
|
|
538
|
+
await this.store.audit({
|
|
539
|
+
orgId: o.org_id,
|
|
540
|
+
action: deleted ? 'actor.delete' : 'actor.restore',
|
|
541
|
+
decision: 'allow',
|
|
542
|
+
actorId,
|
|
543
|
+
fileId: null,
|
|
544
|
+
context: { via: 'control_plane', targetActorId: actorId },
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Soft-delete a customer application: every org, every file and every grant
|
|
551
|
+
* inside it stops working immediately. This is the "we terminated that
|
|
552
|
+
* customer" operation and it is the widest blast radius in the system.
|
|
553
|
+
*/
|
|
554
|
+
async softDeleteProject(projectId: string): Promise<void> {
|
|
555
|
+
await this.#setProjectDeleted(projectId, true);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async restoreProject(projectId: string): Promise<void> {
|
|
559
|
+
await this.#setProjectDeleted(projectId, false);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async #setProjectDeleted(projectId: string, deleted: boolean): Promise<void> {
|
|
563
|
+
if (!isUuid(projectId)) throw new FilelayerError(404, 'not_found');
|
|
564
|
+
const { rows } = await this.db.query<{ id: string }>(
|
|
565
|
+
`UPDATE project SET deleted_at = ${deleted ? 'now()' : 'NULL'}
|
|
566
|
+
WHERE id = $1 RETURNING id`,
|
|
567
|
+
[projectId],
|
|
568
|
+
);
|
|
569
|
+
if (!rows[0]) throw new FilelayerError(404, 'not_found');
|
|
570
|
+
// The system chain: a project is above every tenant, so there is no single
|
|
571
|
+
// tenant to charge the event to, and writing it to all of them would let a
|
|
572
|
+
// control-plane action inflate an arbitrary number of customer chains.
|
|
573
|
+
await this.store.audit({
|
|
574
|
+
orgId: null,
|
|
575
|
+
action: deleted ? 'project.delete' : 'project.restore',
|
|
576
|
+
decision: 'allow',
|
|
577
|
+
actorId: null,
|
|
578
|
+
fileId: null,
|
|
579
|
+
context: { chain: 'system', projectId, via: 'control_plane' },
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Add a member, or change an existing member's role.
|
|
585
|
+
*
|
|
586
|
+
* This used to take no principal at all. Anyone who could reach it could
|
|
587
|
+
* make anyone an owner of any org, and nothing was written to the audit log.
|
|
588
|
+
* Membership is the privilege that confers every other privilege, so it is
|
|
589
|
+
* now authorized by the same engine as everything else and audited on every
|
|
590
|
+
* outcome.
|
|
591
|
+
*/
|
|
592
|
+
async addMember(
|
|
593
|
+
principal: Principal,
|
|
594
|
+
orgId: string,
|
|
595
|
+
actorId: string,
|
|
596
|
+
role: OrgRole,
|
|
597
|
+
): Promise<void> {
|
|
598
|
+
const decision = await authorizeMembershipChange(this.store, principal, orgId, actorId, role);
|
|
599
|
+
this.#raise(decision);
|
|
600
|
+
await this.db.query(
|
|
601
|
+
`INSERT INTO membership (org_id, actor_id, role) VALUES ($1,$2,$3)
|
|
602
|
+
ON CONFLICT (org_id, actor_id) DO UPDATE SET role = EXCLUDED.role`,
|
|
603
|
+
[orgId, actorId, role],
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async removeMember(principal: Principal, orgId: string, actorId: string): Promise<void> {
|
|
608
|
+
const decision = await authorizeMembershipChange(this.store, principal, orgId, actorId, null);
|
|
609
|
+
this.#raise(decision);
|
|
610
|
+
await this.db.query(`DELETE FROM membership WHERE org_id = $1 AND actor_id = $2`, [
|
|
611
|
+
orgId,
|
|
612
|
+
actorId,
|
|
613
|
+
]);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ---------------------------------------------------------------------------
|
|
617
|
+
// Files
|
|
618
|
+
// ---------------------------------------------------------------------------
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Creation is the one file operation with no file to authorize against, so
|
|
622
|
+
* the question is org-scoped: does this actor hold `create_file` in this org?
|
|
623
|
+
* That is now asked of the engine rather than answered here.
|
|
624
|
+
*
|
|
625
|
+
* SIGNATURE CHANGE. This used to take a bare
|
|
626
|
+
* `actorId: string` while every other method took a `Principal`. That
|
|
627
|
+
* inconsistency was itself the defect: at the one call site in the example the
|
|
628
|
+
* developer passed `b.uploaderId` -- a value out of the REQUEST BODY -- rather
|
|
629
|
+
* than the authenticated actor, because the parameter's type did not tell them
|
|
630
|
+
* which one it wanted. Files are private-by-default AND owner-readable, so
|
|
631
|
+
* forging `owner_id` hands the wrong person permanent read access to the
|
|
632
|
+
* document, silently. A `Principal` is not confusable with a request field.
|
|
633
|
+
*/
|
|
634
|
+
async upload(principal: Principal, orgId: string, input: UploadInput): Promise<FileRecord> {
|
|
635
|
+
const actorId = principal.actorId;
|
|
636
|
+
// DELIBERATELY OUTSIDE THE TRANSACTION, and the reason is the storage write
|
|
637
|
+
// that has to happen between this and the INSERT.
|
|
638
|
+
//
|
|
639
|
+
// With `emitAllow: false` this call writes AT MOST ONE STATEMENT: an audit
|
|
640
|
+
// event on the deny path, which `audit_append()` already makes atomic on its
|
|
641
|
+
// own. There is no mutation for it to be atomic *with*. Wrapping it would
|
|
642
|
+
// mean either holding a database connection open across the whole object
|
|
643
|
+
// upload -- minutes, for a large file, on a pooled connection -- or opening a
|
|
644
|
+
// second transaction anyway. The allow event is emitted below, inside the
|
|
645
|
+
// transaction, carrying the file id.
|
|
646
|
+
const decision = await authorizeOrg(this.store, principal, orgId, 'create_file', {
|
|
647
|
+
action: 'file.create',
|
|
648
|
+
emitAllow: false, // the allow event is emitted below, with the file id on it
|
|
649
|
+
});
|
|
650
|
+
this.#raise(decision);
|
|
651
|
+
// `authorizeOrg` denies an anonymous principal before we get here, so the
|
|
652
|
+
// uploader is known. The engine is the thing that established that, which
|
|
653
|
+
// is the point: ownership is derived from the authorized identity and can
|
|
654
|
+
// no longer be supplied alongside it.
|
|
655
|
+
const uploaderId = actorId!;
|
|
656
|
+
|
|
657
|
+
const id = randomUUID();
|
|
658
|
+
const storageKey = `${orgId}/${id}`;
|
|
659
|
+
const now = Date.now();
|
|
660
|
+
const expiresAt = input.expiresIn ? new Date(now + input.expiresIn * 1000) : null;
|
|
661
|
+
const retainUntil = input.retainFor ? new Date(now + input.retainFor * 1000) : null;
|
|
662
|
+
const visibility: FileVisibility = input.visibility ?? 'private';
|
|
663
|
+
|
|
664
|
+
// ORDERING: BYTES FIRST, METADATA SECOND. See the long note in db.ts.
|
|
665
|
+
//
|
|
666
|
+
// The storage write cannot join the transaction, so one of the two possible
|
|
667
|
+
// orderings has to lose. Committing metadata first and crashing would leave
|
|
668
|
+
// a 'ready' file whose object does not exist -- permanent, customer-visible
|
|
669
|
+
// data loss on a row the customer can see in a listing. Writing bytes first
|
|
670
|
+
// and crashing leaves an object no row points at: unreachable (the key is a
|
|
671
|
+
// fresh UUID, never reissued, and every read path starts from a `file` row)
|
|
672
|
+
// and therefore purely a storage cost. That is the cheaper failure and it is
|
|
673
|
+
// the one we take. `collectStorageOrphans()` cleans up; running it is a
|
|
674
|
+
// required operational job, not an optional one.
|
|
675
|
+
//
|
|
676
|
+
// It also means the adapter, not the caller, reports how many bytes exist.
|
|
677
|
+
const put = await this.storage.put(storageKey, input.body, input.contentType, {
|
|
678
|
+
...(input.size !== undefined ? { contentLength: input.size } : {}),
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
return this.#transaction(async (tx, store) => {
|
|
682
|
+
const { rows } = await tx.query<Record<string, unknown>>(
|
|
683
|
+
`INSERT INTO file
|
|
684
|
+
(id, org_id, owner_id, name, content_type, size_bytes, storage_provider,
|
|
685
|
+
storage_key, state, visibility, expires_at, retain_until, metadata)
|
|
686
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'ready',$9,$10,$11,$12::jsonb)
|
|
687
|
+
RETURNING id, org_id, owner_id, name, content_type, size_bytes,
|
|
688
|
+
storage_provider, storage_key, state, visibility, expires_at,
|
|
689
|
+
retain_until, created_at`,
|
|
690
|
+
[
|
|
691
|
+
id,
|
|
692
|
+
orgId,
|
|
693
|
+
uploaderId,
|
|
694
|
+
input.name,
|
|
695
|
+
input.contentType,
|
|
696
|
+
// What was actually written, not what the caller claimed.
|
|
697
|
+
put.bytes,
|
|
698
|
+
// THE FIX. This was the literal 'memory'.
|
|
699
|
+
this.storage.provider,
|
|
700
|
+
storageKey,
|
|
701
|
+
visibility,
|
|
702
|
+
expiresAt?.toISOString() ?? null,
|
|
703
|
+
retainUntil?.toISOString() ?? null,
|
|
704
|
+
JSON.stringify(input.metadata ?? {}),
|
|
705
|
+
],
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
// Same transaction as the INSERT it describes. Before this, a failure
|
|
709
|
+
// between the two produced a file with no audit record -- in a product
|
|
710
|
+
// whose headline is a tamper-evident audit trail, an intact chain that
|
|
711
|
+
// simply does not mention the upload.
|
|
712
|
+
await store.audit({
|
|
713
|
+
orgId,
|
|
714
|
+
action: 'file.create',
|
|
715
|
+
decision: 'allow',
|
|
716
|
+
actorId: uploaderId,
|
|
717
|
+
fileId: id,
|
|
718
|
+
context: { visibility, storageProvider: this.storage.provider },
|
|
719
|
+
});
|
|
720
|
+
await store.recordUsage(orgId, 'write', put.bytes);
|
|
721
|
+
// Metering counts distinct FILE-OWNING USERS as well as bytes, because
|
|
722
|
+
// authorization load tracks people rather than volume. The write path is
|
|
723
|
+
// the only place a new owner can appear, so it is the only place this can
|
|
724
|
+
// be recorded.
|
|
725
|
+
await store.recordFileOwner(orgId, uploaderId);
|
|
726
|
+
return toFileRecord(rows[0]!);
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Read a file's bytes, WITH the headers required to serve them safely.
|
|
732
|
+
*
|
|
733
|
+
* The `headers` field closes a header-handling defect. Before it, this method returned a
|
|
734
|
+
* `Uint8Array` and the application decided what `Content-Type`,
|
|
735
|
+
* `Content-Disposition`, `X-Content-Type-Options` and `Cache-Control` to put
|
|
736
|
+
* on the response -- three security-sensitive decisions the library was
|
|
737
|
+
* handing back to the developer while claiming to have removed them, and
|
|
738
|
+
* which the example got wrong. They are computed here now, from the file
|
|
739
|
+
* record, with no option to disable them. See `delivery.ts`.
|
|
740
|
+
*
|
|
741
|
+
* This path now CHARGES the download cap. See `deliver()` below for the
|
|
742
|
+
* semantics and the reasoning.
|
|
743
|
+
*/
|
|
744
|
+
async read(
|
|
745
|
+
principal: Principal,
|
|
746
|
+
fileId: string,
|
|
747
|
+
opts: { disposition?: Disposition } = {},
|
|
748
|
+
): Promise<{
|
|
749
|
+
file: FileRecord;
|
|
750
|
+
body: Uint8Array;
|
|
751
|
+
headers: Record<string, string>;
|
|
752
|
+
grantId?: string;
|
|
753
|
+
/** Null when no cap binds this delivery (a role-derived read, or no cap). */
|
|
754
|
+
remainingDownloads: number | null;
|
|
755
|
+
}> {
|
|
756
|
+
// The buffered convenience form. It is `readStream()` plus a collect, so
|
|
757
|
+
// there is exactly one authorization path, one reservation and one audit
|
|
758
|
+
// event whichever form a caller uses. It FORCES proxy mode: a buffered read
|
|
759
|
+
// of a redirect is a contradiction, and silently fetching the presigned URL
|
|
760
|
+
// ourselves would spend the redirect's egress budget AND the proxy's.
|
|
761
|
+
const d = await this.readStream(principal, fileId, { ...opts, mode: 'proxy' });
|
|
762
|
+
if (d.mode !== 'proxy') throw new FilelayerError(500, 'internal', 'unexpected_redirect');
|
|
763
|
+
const body = await collectStream(d.body);
|
|
764
|
+
return {
|
|
765
|
+
file: d.file,
|
|
766
|
+
body,
|
|
767
|
+
headers: d.headers,
|
|
768
|
+
...(d.grantId ? { grantId: d.grantId } : {}),
|
|
769
|
+
remainingDownloads: d.remainingDownloads,
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* The streaming read. Same decision, same charge, same audit -- no buffer.
|
|
775
|
+
*
|
|
776
|
+
* Returns either a `ProxyDelivery` (bytes, as a stream) or, when redirect
|
|
777
|
+
* delivery is configured AND this delivery is eligible, a `RedirectDelivery`
|
|
778
|
+
* (a 302 to a short-lived presigned URL). The mode is on the returned object
|
|
779
|
+
* and in the audit log; nothing about it is implicit.
|
|
780
|
+
*/
|
|
781
|
+
async readStream(
|
|
782
|
+
principal: Principal,
|
|
783
|
+
fileId: string,
|
|
784
|
+
/**
|
|
785
|
+
* `mode` defaults to 'auto', which means "apply this instance's redirect
|
|
786
|
+
* policy". On an instance that has not configured `redirectDelivery` -- the
|
|
787
|
+
* default -- that policy is "never redirect", so 'auto' and 'proxy' are the
|
|
788
|
+
* same thing and nothing becomes cacheable that was not before. Pass
|
|
789
|
+
* 'proxy' to force proxying on an instance that HAS opted in.
|
|
790
|
+
*/
|
|
791
|
+
opts: { disposition?: Disposition; mode?: 'proxy' | 'auto'; range?: { start: number; end?: number } } = {},
|
|
792
|
+
): Promise<StreamedDelivery & { file: FileRecord; grantId?: string; remainingDownloads: number | null }> {
|
|
793
|
+
// THE DECISION AND THE CHARGE, IN ONE TRANSACTION.
|
|
794
|
+
//
|
|
795
|
+
// `authorize()` writes the access event and `consumeDownload()` spends the
|
|
796
|
+
// cap. Those two were separate autocommit statements, so a crash between
|
|
797
|
+
// them left an allow event for a delivery that was never charged, or -- on
|
|
798
|
+
// the redeem path -- a charge with no event. They now commit together.
|
|
799
|
+
const reserved = await this.#transaction(async (tx, store) => {
|
|
800
|
+
const decision = await authorize(store, principal, fileId, 'read');
|
|
801
|
+
this.#raise(decision);
|
|
802
|
+
return this.#reserve(tx, store, fileId, decision, principal, opts);
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
return this.#fetchDelivery(reserved, opts);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Metadata without bytes, authorized exactly like `read`.
|
|
810
|
+
*
|
|
811
|
+
* This exists because `getFileRecord()` used to be public and took no
|
|
812
|
+
* principal -- see the note on it below. Callers that wanted a file's
|
|
813
|
+
* metadata had an unauthorized way to get it; now they have an authorized one.
|
|
814
|
+
*
|
|
815
|
+
* It does NOT charge the download cap, and that asymmetry is the whole point
|
|
816
|
+
* of the delivery-cap rule: the cap counts BYTES LEAVING, and `stat` delivers
|
|
817
|
+
* none.
|
|
818
|
+
*/
|
|
819
|
+
async stat(principal: Principal, fileId: string): Promise<FileRecord> {
|
|
820
|
+
return this.#transaction(async (tx, store) => {
|
|
821
|
+
const decision = await authorize(store, principal, fileId, 'read');
|
|
822
|
+
this.#raise(decision);
|
|
823
|
+
const file = await getFileRecord(tx, this.projectId, fileId);
|
|
824
|
+
if (!file) throw new FilelayerError(404, 'not_found');
|
|
825
|
+
return file;
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* THE ONE PLACE BYTES LEAVE THE SYSTEM -- and therefore the one place the
|
|
831
|
+
* download cap is charged (P6).
|
|
832
|
+
*
|
|
833
|
+
* THE DEFECT. `max_downloads` was charged only by `redeem()`, the share-link
|
|
834
|
+
* path. An ACTOR grant carrying `maxDownloads: 3` permitted unlimited direct
|
|
835
|
+
* `read()` calls, because nothing on that path touched the counter. So the
|
|
836
|
+
* field meant "link redemptions" on one path and "nothing at all" on another,
|
|
837
|
+
* while being named, documented and billed as a download cap. In practice the
|
|
838
|
+
* direct path is the COMMON one -- the SDK calls `read()` --
|
|
839
|
+
* so the dimension was a lie on the path most customers use.
|
|
840
|
+
*
|
|
841
|
+
* THE DECISION, of the three that were on the table:
|
|
842
|
+
*
|
|
843
|
+
* (a) rename it `maxRedemptions`. Rejected: it would still be settable on
|
|
844
|
+
* an actor grant, where it would then mean nothing, so the ambiguity
|
|
845
|
+
* moves rather than closes.
|
|
846
|
+
* (b) refuse `maxDownloads` on non-link grants. Rejected: "you may read
|
|
847
|
+
* this three times" is a thing customers legitimately want to say about
|
|
848
|
+
* a named person, and refusing it removes a capability to avoid
|
|
849
|
+
* defining one.
|
|
850
|
+
* (c) CHARGE ON EVERY DELIVERY. Chosen. A cap of 3 means the bytes leave at
|
|
851
|
+
* most 3 times, through any path, by any principal, at any delegation
|
|
852
|
+
* depth. It is the reading a customer already has, it is the only one
|
|
853
|
+
* that is true on every path, and it makes the cap enforceable rather
|
|
854
|
+
* than advisory.
|
|
855
|
+
*
|
|
856
|
+
* THE RULE, precisely: a delivery is charged when, and only when, the
|
|
857
|
+
* authorization decision was reached VIA A GRANT. Authority from an org role
|
|
858
|
+
* is not a metered credential and is not charged -- an admin doing their job
|
|
859
|
+
* must not silently burn a contractor's link budget. `authorize()` alone does
|
|
860
|
+
* not charge (it is a decision, not a delivery) and neither does `stat()`.
|
|
861
|
+
*
|
|
862
|
+
* ORDERING: reserve BEFORE fetching bytes, exactly as `redeem()` does. The
|
|
863
|
+
* reservation is the write (P6), so two concurrent deliveries against a cap
|
|
864
|
+
* of 1 yield one delivery; doing it the other way round would let both read
|
|
865
|
+
* the object and only then discover one of them was over budget. A storage
|
|
866
|
+
* failure after a successful reservation therefore still spends a download.
|
|
867
|
+
* That is the fail-closed direction and it is deliberate.
|
|
868
|
+
*
|
|
869
|
+
* KNOWN COST, recorded rather than hidden. `consume_download` runs even
|
|
870
|
+
* when no grant in the chain carries a cap, because `download_count` is also
|
|
871
|
+
* the answer to "how many times has this link been downloaded", which
|
|
872
|
+
* `listGrants` reports and a compliance screen asks for. That makes every
|
|
873
|
+
* grant-authorized delivery a row UPDATE holding a row lock -- and for a
|
|
874
|
+
* TIER-1 PUBLIC ASSET, where one anonymous grant row serves every request,
|
|
875
|
+
* that single row becomes a write hotspot under load. It is a scalability
|
|
876
|
+
* problem, not a correctness one, and the fix (skip the write when no
|
|
877
|
+
* ancestor has a cap, and meter deliveries elsewhere) trades away the
|
|
878
|
+
* per-grant download count. Not taken here because that count is a shipped
|
|
879
|
+
* feature; flagged so the trade is made deliberately when volume forces it.
|
|
880
|
+
*/
|
|
881
|
+
async #reserve(
|
|
882
|
+
tx: Tx,
|
|
883
|
+
store: PostgresStore,
|
|
884
|
+
fileId: string,
|
|
885
|
+
decision: Extract<Decision, { allow: true }>,
|
|
886
|
+
principal: Principal,
|
|
887
|
+
opts: { disposition?: Disposition; mode?: 'proxy' | 'auto' },
|
|
888
|
+
): Promise<Reservation> {
|
|
889
|
+
const grantId = decision.grantId ?? null;
|
|
890
|
+
let remainingDownloads: number | null = null;
|
|
891
|
+
|
|
892
|
+
if (grantId !== null) {
|
|
893
|
+
const consumed = await store.consumeDownload(grantId);
|
|
894
|
+
if (!consumed.granted) {
|
|
895
|
+
// Reachable only when the cap is hit between the decision and the
|
|
896
|
+
// reservation. The engine records the ordinary case; this records the
|
|
897
|
+
// race, so the two cannot silently become one.
|
|
898
|
+
const file = await getFileRecord(tx, this.projectId, fileId);
|
|
899
|
+
await store.audit({
|
|
900
|
+
orgId: file?.orgId ?? null,
|
|
901
|
+
action: 'file.read',
|
|
902
|
+
decision: 'deny',
|
|
903
|
+
reason: 'grant_exhausted',
|
|
904
|
+
actorId: principal.actorId,
|
|
905
|
+
fileId,
|
|
906
|
+
grantId,
|
|
907
|
+
...(principal.ip !== undefined ? { ip: principal.ip } : {}),
|
|
908
|
+
context: { race: true, ...(file ? {} : { chain: 'system' }) },
|
|
909
|
+
});
|
|
910
|
+
throw new FilelayerError(404, 'not_found', 'grant_exhausted');
|
|
911
|
+
}
|
|
912
|
+
remainingDownloads = consumed.remaining;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const file = await getFileRecord(tx, this.projectId, fileId);
|
|
916
|
+
if (!file) throw new FilelayerError(404, 'not_found');
|
|
917
|
+
|
|
918
|
+
const headers = deliveryHeaders(file, opts);
|
|
919
|
+
const mode = this.#redirectEligible(decision, opts.mode ?? 'auto');
|
|
920
|
+
|
|
921
|
+
if (mode === 'proxy') {
|
|
922
|
+
return { kind: 'proxy', file, headers, remainingDownloads, grantId };
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// The presigned URL is minted INSIDE the transaction, before the audit
|
|
926
|
+
// event that records it. `presignGet` for the S3 adapter is local HMAC with
|
|
927
|
+
// no I/O; an adapter for which that is not true must still keep it cheap,
|
|
928
|
+
// because it sits inside an open transaction. Minting first means we never
|
|
929
|
+
// audit a redirect we then failed to produce.
|
|
930
|
+
const redirect = this.redirect!;
|
|
931
|
+
const url = await (this.storage as Required<Pick<StorageAdapter, 'presignGet'>>).presignGet(
|
|
932
|
+
file.storageKey,
|
|
933
|
+
{
|
|
934
|
+
expiresInSeconds: redirect.ttlSeconds,
|
|
935
|
+
// Pin the SAME neutralised type and disposition the proxied path would
|
|
936
|
+
// have sent, so a redirect cannot be a way to lose them.
|
|
937
|
+
responseContentType: headers['content-type']!,
|
|
938
|
+
responseContentDisposition: headers['content-disposition']!,
|
|
939
|
+
},
|
|
940
|
+
);
|
|
941
|
+
const expiresAt = new Date(Date.now() + redirect.ttlSeconds * 1000);
|
|
942
|
+
|
|
943
|
+
// THE EVENT THAT MAKES THE MODE AUDITABLE. Written only for redirects, in
|
|
944
|
+
// the same transaction as the reservation. A compliance auditor asking "which
|
|
945
|
+
// deliveries left our control?" filters `action = 'file.deliver'`; every
|
|
946
|
+
// other delivery was proxied.
|
|
947
|
+
await store.audit({
|
|
948
|
+
orgId: file.orgId,
|
|
949
|
+
action: 'file.deliver',
|
|
950
|
+
decision: 'allow',
|
|
951
|
+
actorId: principal.actorId,
|
|
952
|
+
fileId,
|
|
953
|
+
grantId,
|
|
954
|
+
...(principal.ip !== undefined ? { ip: principal.ip } : {}),
|
|
955
|
+
...(principal.userAgent !== undefined ? { userAgent: principal.userAgent } : {}),
|
|
956
|
+
context: {
|
|
957
|
+
mode: 'redirect',
|
|
958
|
+
via: decision.via,
|
|
959
|
+
ttlSeconds: redirect.ttlSeconds,
|
|
960
|
+
// The number a compliance document quotes. Spelled out rather than
|
|
961
|
+
// derived, so it survives a change to how the TTL is computed.
|
|
962
|
+
revocationWindowSeconds: redirect.ttlSeconds,
|
|
963
|
+
expiresAt: expiresAt.toISOString(),
|
|
964
|
+
cacheable: decision.via === 'grant:anonymous',
|
|
965
|
+
},
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
return {
|
|
969
|
+
kind: 'redirect',
|
|
970
|
+
file,
|
|
971
|
+
headers,
|
|
972
|
+
remainingDownloads,
|
|
973
|
+
grantId,
|
|
974
|
+
url,
|
|
975
|
+
expiresAt,
|
|
976
|
+
ttlSeconds: redirect.ttlSeconds,
|
|
977
|
+
cacheable: decision.via === 'grant:anonymous',
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Is this delivery allowed to be a redirect?
|
|
983
|
+
*
|
|
984
|
+
* Four conditions, all required, and the default answer is no:
|
|
985
|
+
* 1. the caller asked for 'auto' (routes default to 'proxy');
|
|
986
|
+
* 2. redirect delivery is configured -- which required the acknowledgement;
|
|
987
|
+
* 3. the adapter can actually mint a presigned URL;
|
|
988
|
+
* 4. the authority came from an ANONYMOUS grant, unless the scope was
|
|
989
|
+
* explicitly widened to 'all-grants'.
|
|
990
|
+
*
|
|
991
|
+
* Condition 4 is the one that matters. `via` is the engine's own account of
|
|
992
|
+
* where the authority came from, so "public" here means "the customer
|
|
993
|
+
* published this file", not "the request looked public".
|
|
994
|
+
*/
|
|
995
|
+
#redirectEligible(
|
|
996
|
+
decision: Extract<Decision, { allow: true }>,
|
|
997
|
+
requested: 'proxy' | 'auto',
|
|
998
|
+
): 'proxy' | 'redirect' {
|
|
999
|
+
if (requested !== 'auto') return 'proxy';
|
|
1000
|
+
if (this.redirect === null) return 'proxy';
|
|
1001
|
+
if (!canPresign(this.storage)) return 'proxy';
|
|
1002
|
+
if (this.redirect.scope === 'anonymous-grants-only' && decision.via !== 'grant:anonymous') {
|
|
1003
|
+
return 'proxy';
|
|
1004
|
+
}
|
|
1005
|
+
return 'redirect';
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Turn a committed reservation into bytes (or a 302).
|
|
1010
|
+
*
|
|
1011
|
+
* Deliberately OUTSIDE the transaction. Fetching an object can take minutes;
|
|
1012
|
+
* holding a database connection open for that is how a pool dies. It also
|
|
1013
|
+
* preserves the documented P6 property that a storage failure after a
|
|
1014
|
+
* successful reservation still spends a download -- the reservation is
|
|
1015
|
+
* already committed, so nothing can give it back.
|
|
1016
|
+
*/
|
|
1017
|
+
async #fetchDelivery(
|
|
1018
|
+
r: Reservation,
|
|
1019
|
+
opts: { range?: { start: number; end?: number } },
|
|
1020
|
+
): Promise<StreamedDelivery & { file: FileRecord; grantId?: string; remainingDownloads: number | null }> {
|
|
1021
|
+
if (r.kind === 'redirect') {
|
|
1022
|
+
const d: RedirectDelivery & {
|
|
1023
|
+
file: FileRecord;
|
|
1024
|
+
grantId?: string;
|
|
1025
|
+
remainingDownloads: number | null;
|
|
1026
|
+
} = {
|
|
1027
|
+
mode: 'redirect',
|
|
1028
|
+
file: r.file,
|
|
1029
|
+
status: 302,
|
|
1030
|
+
url: r.url,
|
|
1031
|
+
expiresAt: r.expiresAt,
|
|
1032
|
+
revocationWindowSeconds: r.ttlSeconds,
|
|
1033
|
+
headers: redirectHeaders(r.url, { ttlSeconds: r.ttlSeconds, cacheable: r.cacheable }),
|
|
1034
|
+
remainingDownloads: r.remainingDownloads,
|
|
1035
|
+
...(r.grantId ? { grantId: r.grantId } : {}),
|
|
1036
|
+
};
|
|
1037
|
+
return d;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
const obj: ObjectStream | null = await this.storage.stream(r.file.storageKey, {
|
|
1041
|
+
...(opts.range ? { range: opts.range } : {}),
|
|
1042
|
+
});
|
|
1043
|
+
if (!obj) throw new FilelayerError(404, 'not_found');
|
|
1044
|
+
|
|
1045
|
+
// Metering is deliberately outside the transaction and best-effort: it is
|
|
1046
|
+
// not a decision, and a metering failure must not fail a delivery the
|
|
1047
|
+
// system already authorized, charged and audited.
|
|
1048
|
+
const bytes = obj.size ?? r.file.sizeBytes ?? 0;
|
|
1049
|
+
await this.store.recordUsage(r.file.orgId, 'read', bytes).catch(() => {});
|
|
1050
|
+
|
|
1051
|
+
const headers = { ...r.headers };
|
|
1052
|
+
// Trust the store's length over the column: a `size_bytes` that disagrees
|
|
1053
|
+
// with the object truncates or hangs the response.
|
|
1054
|
+
if (obj.size !== null) headers['content-length'] = String(obj.size);
|
|
1055
|
+
else delete headers['content-length'];
|
|
1056
|
+
if (obj.range) {
|
|
1057
|
+
headers['content-range'] = `bytes ${obj.range.start}-${obj.range.end}/${obj.range.total}`;
|
|
1058
|
+
headers['accept-ranges'] = 'bytes';
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
const d: ProxyDelivery & {
|
|
1062
|
+
file: FileRecord;
|
|
1063
|
+
grantId?: string;
|
|
1064
|
+
remainingDownloads: number | null;
|
|
1065
|
+
} = {
|
|
1066
|
+
mode: 'proxy',
|
|
1067
|
+
file: r.file,
|
|
1068
|
+
headers,
|
|
1069
|
+
body: obj.body,
|
|
1070
|
+
bytes: obj.size,
|
|
1071
|
+
remainingDownloads: r.remainingDownloads,
|
|
1072
|
+
...(r.grantId ? { grantId: r.grantId } : {}),
|
|
1073
|
+
};
|
|
1074
|
+
return d;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// ---------------------------------------------------------------------------
|
|
1078
|
+
// Listing -- the authorized query surface
|
|
1079
|
+
// ---------------------------------------------------------------------------
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Which files in this org may this principal `capability`?
|
|
1083
|
+
*
|
|
1084
|
+
* This is the primitive that was missing, and its absence was
|
|
1085
|
+
* the reason the "0 authorization lines" claim survived: the example simply
|
|
1086
|
+
* did not have a listing screen, and building one meant hand-rolling the org
|
|
1087
|
+
* filter, the visibility rule, the owner check, the role check and the union
|
|
1088
|
+
* over `file_grant` in application SQL.
|
|
1089
|
+
*
|
|
1090
|
+
* WHY IT CANNOT BE GOT WRONG.
|
|
1091
|
+
*
|
|
1092
|
+
* - There is no filter parameter. The signature takes a principal, an org,
|
|
1093
|
+
* a capability, a page size and an opaque cursor. There is nothing here to
|
|
1094
|
+
* forget to pass and nothing that widens the result set.
|
|
1095
|
+
* - The predicate is generated from the same role table and the same
|
|
1096
|
+
* lifecycle gate `authorize()` uses (see `listPredicate` in authz.ts), so
|
|
1097
|
+
* the two cannot drift by editing one of them.
|
|
1098
|
+
* - `test/listing.test.ts` asserts set equality against `authorize()` over a
|
|
1099
|
+
* randomized corpus, on every capability, on every run.
|
|
1100
|
+
*
|
|
1101
|
+
* WHAT IT COSTS. One SQL query and one audit event, independent of page size.
|
|
1102
|
+
* A per-file `authorize()` loop would be 4 round trips x N.
|
|
1103
|
+
*
|
|
1104
|
+
* The empty page is a valid answer: a caller with no standing sees nothing,
|
|
1105
|
+
* and so does a caller naming an org that does not exist. Neither is an error,
|
|
1106
|
+
* because distinguishing them would rebuild the existence oracle.
|
|
1107
|
+
*/
|
|
1108
|
+
async listFiles(
|
|
1109
|
+
principal: Principal,
|
|
1110
|
+
orgId: string,
|
|
1111
|
+
opts: ListFilesOptions = {},
|
|
1112
|
+
): Promise<FileListPage> {
|
|
1113
|
+
// A link secret is a bearer credential for exactly one file. Refused rather
|
|
1114
|
+
// than ignored: a silently-dropped credential is how a caller ends up
|
|
1115
|
+
// believing they listed something they did not.
|
|
1116
|
+
if (principal.linkSecret !== undefined) {
|
|
1117
|
+
throw new FilelayerError(400, 'link_principal_cannot_list', 'link_principal_cannot_list');
|
|
1118
|
+
}
|
|
1119
|
+
const capability = opts.capability ?? 'read';
|
|
1120
|
+
const limit = Math.max(1, Math.min(opts.limit ?? LIST_DEFAULT_LIMIT, LIST_MAX_LIMIT));
|
|
1121
|
+
|
|
1122
|
+
const { files, hasMore } = await authorizeList(this.store, principal, orgId, {
|
|
1123
|
+
capability,
|
|
1124
|
+
limit,
|
|
1125
|
+
cursor: decodeCursor(opts.cursor),
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
const last = files[files.length - 1];
|
|
1129
|
+
return {
|
|
1130
|
+
// `ListedFile` and `FileRecord` are the same shape; the store returns the
|
|
1131
|
+
// columns `getFileRecord` returns, so nothing is re-fetched per row.
|
|
1132
|
+
files: files as FileRecord[],
|
|
1133
|
+
nextCursor: hasMore && last ? encodeCursor(last.createdAt, last.id) : null,
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* ORDERING, and it is the mirror image of `upload()`.
|
|
1139
|
+
*
|
|
1140
|
+
* The metadata delete COMMITS FIRST -- the decision, the audit event the
|
|
1141
|
+
* engine wrote for it, and the state change, all in one transaction -- and
|
|
1142
|
+
* only then are the bytes removed. A crash in between leaves an object no row
|
|
1143
|
+
* points at, which is an orphan and therefore a garbage-collection problem.
|
|
1144
|
+
* The other ordering would leave a live, listable, authorizable `file` row
|
|
1145
|
+
* whose object is gone, which is data loss.
|
|
1146
|
+
*
|
|
1147
|
+
* The bytes are removed OUTSIDE the transaction for the same reason they are
|
|
1148
|
+
* written outside it: object storage cannot roll back, so including it would
|
|
1149
|
+
* mean a rolled-back transaction had already destroyed the object.
|
|
1150
|
+
*/
|
|
1151
|
+
async delete(principal: Principal, fileId: string): Promise<void> {
|
|
1152
|
+
const file = await this.#transaction(async (tx, store) => {
|
|
1153
|
+
const decision = await authorize(store, principal, fileId, 'delete');
|
|
1154
|
+
this.#raise(decision);
|
|
1155
|
+
|
|
1156
|
+
const f = await getFileRecord(tx, this.projectId, fileId);
|
|
1157
|
+
if (!f) throw new FilelayerError(404, 'not_found');
|
|
1158
|
+
await tx.query(
|
|
1159
|
+
`UPDATE file SET state = 'deleted', deleted_at = now(), updated_at = now()
|
|
1160
|
+
WHERE id = $1`,
|
|
1161
|
+
[fileId],
|
|
1162
|
+
);
|
|
1163
|
+
return f;
|
|
1164
|
+
});
|
|
1165
|
+
await this.storage.delete(file.storageKey);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* COLLECT ORPHANED OBJECTS. A REQUIRED OPERATIONAL JOB.
|
|
1170
|
+
*
|
|
1171
|
+
* An orphan is an object in the store with no `file` row pointing at
|
|
1172
|
+
* (provider, key). Two things produce them, both of them by design:
|
|
1173
|
+
*
|
|
1174
|
+
* - a crash between `storage.put()` and the metadata commit in `upload()`;
|
|
1175
|
+
* - a crash between the metadata commit and `storage.delete()` in
|
|
1176
|
+
* `delete()`.
|
|
1177
|
+
*
|
|
1178
|
+
* Neither is a correctness problem -- an orphan is unreachable, because every
|
|
1179
|
+
* read path in the system starts from a `file` row, and keys are fresh UUIDs
|
|
1180
|
+
* that are never reissued -- but both cost money, and an uncollected orphan
|
|
1181
|
+
* from a delete is a compliance problem: the customer was told the bytes were
|
|
1182
|
+
* gone.
|
|
1183
|
+
*
|
|
1184
|
+
* WHAT MAKES THIS SAFE. Two things, and they are both load-bearing:
|
|
1185
|
+
*
|
|
1186
|
+
* 1. `olderThanSeconds` (default 1 hour, minimum 60s). An object written
|
|
1187
|
+
* seconds ago may belong to an upload whose transaction has not committed
|
|
1188
|
+
* yet. Deleting it would turn a successful upload into permanent data
|
|
1189
|
+
* loss -- the exact failure this whole ordering exists to avoid. The grace
|
|
1190
|
+
* period must exceed the longest plausible upload-plus-commit.
|
|
1191
|
+
* 2. The `file` lookup is by (storage_provider, storage_key), the pair the
|
|
1192
|
+
* UNIQUE index is on, and it is NOT project-scoped and NOT filtered on
|
|
1193
|
+
* `deleted_at`. A soft-deleted file whose bytes were never removed still
|
|
1194
|
+
* has a row; this job must not race the delete path into removing bytes a
|
|
1195
|
+
* retention hold is protecting. It only removes what NOTHING references.
|
|
1196
|
+
*
|
|
1197
|
+
* Control plane: it takes no principal for the same reason the other
|
|
1198
|
+
* lifecycle operations do not (see above). `dryRun` is the default.
|
|
1199
|
+
*/
|
|
1200
|
+
async collectStorageOrphans(
|
|
1201
|
+
opts: {
|
|
1202
|
+
prefix?: string;
|
|
1203
|
+
olderThanSeconds?: number;
|
|
1204
|
+
limit?: number;
|
|
1205
|
+
dryRun?: boolean;
|
|
1206
|
+
} = {},
|
|
1207
|
+
): Promise<{ scanned: number; orphans: string[]; deleted: number; truncated: boolean }> {
|
|
1208
|
+
if (!canList(this.storage)) {
|
|
1209
|
+
throw new FilelayerError(
|
|
1210
|
+
500,
|
|
1211
|
+
'storage_cannot_list',
|
|
1212
|
+
'orphan collection needs a storage adapter that implements list()',
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
const grace = Math.max(60, opts.olderThanSeconds ?? 3600) * 1000;
|
|
1216
|
+
const limit = Math.max(1, Math.min(opts.limit ?? 1000, 10_000));
|
|
1217
|
+
const cutoff = Date.now() - grace;
|
|
1218
|
+
const dryRun = opts.dryRun ?? true;
|
|
1219
|
+
|
|
1220
|
+
let cursor: string | null = null;
|
|
1221
|
+
let scanned = 0;
|
|
1222
|
+
const orphans: string[] = [];
|
|
1223
|
+
let truncated = false;
|
|
1224
|
+
|
|
1225
|
+
do {
|
|
1226
|
+
const page: { entries: Array<{ key: string; lastModified: Date | null }>; cursor: string | null } =
|
|
1227
|
+
await this.storage.list(opts.prefix ?? '', {
|
|
1228
|
+
limit: Math.min(1000, limit),
|
|
1229
|
+
cursor,
|
|
1230
|
+
});
|
|
1231
|
+
cursor = page.cursor;
|
|
1232
|
+
for (const e of page.entries) {
|
|
1233
|
+
scanned++;
|
|
1234
|
+
// No timestamp means we cannot prove it is old. Fail closed: skip it.
|
|
1235
|
+
if (e.lastModified === null || e.lastModified.getTime() > cutoff) continue;
|
|
1236
|
+
const { rows } = await this.db.query(
|
|
1237
|
+
`SELECT 1 FROM file WHERE storage_provider = $1 AND storage_key = $2`,
|
|
1238
|
+
[this.storage.provider, e.key],
|
|
1239
|
+
);
|
|
1240
|
+
if (rows.length > 0) continue;
|
|
1241
|
+
orphans.push(e.key);
|
|
1242
|
+
if (orphans.length >= limit) {
|
|
1243
|
+
truncated = true;
|
|
1244
|
+
break;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
} while (cursor !== null && !truncated);
|
|
1248
|
+
|
|
1249
|
+
let deleted = 0;
|
|
1250
|
+
if (!dryRun) {
|
|
1251
|
+
for (const key of orphans) {
|
|
1252
|
+
await this.storage.delete(key);
|
|
1253
|
+
deleted++;
|
|
1254
|
+
}
|
|
1255
|
+
await this.store.audit({
|
|
1256
|
+
orgId: null,
|
|
1257
|
+
action: 'storage.gc',
|
|
1258
|
+
decision: 'allow',
|
|
1259
|
+
actorId: null,
|
|
1260
|
+
fileId: null,
|
|
1261
|
+
context: {
|
|
1262
|
+
chain: 'system',
|
|
1263
|
+
provider: this.storage.provider,
|
|
1264
|
+
scanned,
|
|
1265
|
+
deleted,
|
|
1266
|
+
via: 'control_plane',
|
|
1267
|
+
},
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
return { scanned, orphans, deleted, truncated };
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// ---------------------------------------------------------------------------
|
|
1274
|
+
// Grants
|
|
1275
|
+
// ---------------------------------------------------------------------------
|
|
1276
|
+
|
|
1277
|
+
async share(principal: Principal, fileId: string, input: ShareInput): Promise<ShareResult> {
|
|
1278
|
+
const capabilities = input.capabilities ?? (['read'] as Capability[]);
|
|
1279
|
+
|
|
1280
|
+
// ONE TRANSACTION: the decision, the grant row, and the audit event that
|
|
1281
|
+
// records both. Previously these were three autocommit statements, so a
|
|
1282
|
+
// failure in the middle could leave a live grant that the audit log has no
|
|
1283
|
+
// record of anyone creating -- a grant with no provenance, which for a
|
|
1284
|
+
// capability system is the worst possible row to be missing.
|
|
1285
|
+
return this.#transaction(async (tx, store) => {
|
|
1286
|
+
// One call, two questions: may you share, and is what you are handing out a
|
|
1287
|
+
// subset of what you hold? Both are answered by the engine. The
|
|
1288
|
+
// engine also tells us which grant your authority came from, which becomes
|
|
1289
|
+
// this grant's parent and is what makes revocation transitive.
|
|
1290
|
+
// The subject type is part of the authorization question, not a detail of
|
|
1291
|
+
// the row: I6 says a grant-derived issuer may not widen the population.
|
|
1292
|
+
// Passing it here is what lets the ENGINE refuse -- with a reason and an
|
|
1293
|
+
// audit event -- rather than leaving the trigger to raise at INSERT time.
|
|
1294
|
+
const decision = await authorizeShare(store, principal, fileId, capabilities, {
|
|
1295
|
+
subjectType: input.subject.type,
|
|
1296
|
+
});
|
|
1297
|
+
if (!decision.allow) {
|
|
1298
|
+
const pub = toPublicError(decision.reason);
|
|
1299
|
+
throw new FilelayerError(pub.status, pub.code, decision.reason);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
const file = await getFileRecord(tx, this.projectId, fileId);
|
|
1303
|
+
if (!file) throw new FilelayerError(404, 'not_found');
|
|
1304
|
+
|
|
1305
|
+
const expiresAt = input.expiresIn ? new Date(Date.now() + input.expiresIn * 1000) : null;
|
|
1306
|
+
|
|
1307
|
+
let secret: string | undefined;
|
|
1308
|
+
let secretHash: string | null = null;
|
|
1309
|
+
let subjectId: string | null = null;
|
|
1310
|
+
let subjectOrgId: string | null = null;
|
|
1311
|
+
let subjectMinRole: OrgRole | null = null;
|
|
1312
|
+
|
|
1313
|
+
if (input.subject.type === 'link') {
|
|
1314
|
+
// 256 bits from the CSPRNG. Base64url so it survives a URL path segment.
|
|
1315
|
+
secret = randomBytes(32).toString('base64url');
|
|
1316
|
+
secretHash = await this.store.hashSecret(secret);
|
|
1317
|
+
} else if (input.subject.type === 'actor') {
|
|
1318
|
+
subjectId = input.subject.actorId;
|
|
1319
|
+
} else if (input.subject.type === 'org' || input.subject.type === 'role') {
|
|
1320
|
+
// I1/P8. The composite FK already makes a cross-PROJECT subject org
|
|
1321
|
+
// unrepresentable; this resolves it first so the caller gets the same
|
|
1322
|
+
// uniform 404 they get for any other id they may not name, instead of a
|
|
1323
|
+
// foreign-key violation that would confirm the id exists somewhere. An
|
|
1324
|
+
// org in another project, an org that does not exist, and a malformed id
|
|
1325
|
+
// are one answer -- the same symmetry the file paths keep.
|
|
1326
|
+
subjectOrgId = await this.#resolveSubjectOrg(tx, input.subject.orgId);
|
|
1327
|
+
if (input.subject.type === 'role') subjectMinRole = input.subject.minRole;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
const passwordHash = input.password ? await this.store.hashPassword(input.password) : null;
|
|
1331
|
+
|
|
1332
|
+
// org_id is taken from the FILE, never from the caller. The composite FK
|
|
1333
|
+
// (file_id, org_id) -> file(id, org_id) makes a mismatch unrepresentable,
|
|
1334
|
+
// but taking it from the caller at all would be an invitation.
|
|
1335
|
+
//
|
|
1336
|
+
// The RETURNING clause reads back expires_at and max_downloads because the
|
|
1337
|
+
// attenuation trigger may have tightened them against the parent grant. We
|
|
1338
|
+
// report what was actually stored, not what was asked for.
|
|
1339
|
+
//
|
|
1340
|
+
// THE SAVEPOINT IS NOT OPTIONAL. A statement that RAISES inside a Postgres
|
|
1341
|
+
// transaction aborts the whole transaction: every subsequent statement
|
|
1342
|
+
// fails with "current transaction is aborted". The attenuation trigger
|
|
1343
|
+
// raising is precisely the case where we must keep going, because the
|
|
1344
|
+
// refusal has to be AUDITED. Without the savepoint the audit write below
|
|
1345
|
+
// would itself fail and the refusal would vanish -- the transaction work
|
|
1346
|
+
// would have silently deleted a security event.
|
|
1347
|
+
let rows: Array<{ id: string; expires_at: string | null; max_downloads: number | null }>;
|
|
1348
|
+
try {
|
|
1349
|
+
({ rows } = await tx.savepoint(() => tx.query<{
|
|
1350
|
+
id: string;
|
|
1351
|
+
expires_at: string | null;
|
|
1352
|
+
max_downloads: number | null;
|
|
1353
|
+
}>(
|
|
1354
|
+
`INSERT INTO file_grant
|
|
1355
|
+
(file_id, org_id, parent_grant_id, subject_type, subject_id, subject_org_id,
|
|
1356
|
+
subject_min_role, capabilities,
|
|
1357
|
+
secret_hash, password_hash, expires_at, max_downloads, created_by)
|
|
1358
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8::grant_capability[],$9,$10,$11,$12,$13)
|
|
1359
|
+
RETURNING id, expires_at, max_downloads`,
|
|
1360
|
+
[
|
|
1361
|
+
fileId,
|
|
1362
|
+
file.orgId,
|
|
1363
|
+
decision.parentGrantId,
|
|
1364
|
+
input.subject.type,
|
|
1365
|
+
subjectId,
|
|
1366
|
+
subjectOrgId,
|
|
1367
|
+
subjectMinRole,
|
|
1368
|
+
pgArrayLiteral(capabilities),
|
|
1369
|
+
secretHash,
|
|
1370
|
+
passwordHash,
|
|
1371
|
+
expiresAt?.toISOString() ?? null,
|
|
1372
|
+
input.maxDownloads ?? null,
|
|
1373
|
+
principal.actorId,
|
|
1374
|
+
],
|
|
1375
|
+
)));
|
|
1376
|
+
} catch (err) {
|
|
1377
|
+
// The schema is the backstop for attenuation; if it fires, it has caught
|
|
1378
|
+
// something the engine let through.
|
|
1379
|
+
const refusal = schemaRefusal(err);
|
|
1380
|
+
if (!refusal) throw err;
|
|
1381
|
+
await store.audit({
|
|
1382
|
+
orgId: file.orgId,
|
|
1383
|
+
action: 'grant.create',
|
|
1384
|
+
decision: 'deny',
|
|
1385
|
+
reason: refusal,
|
|
1386
|
+
actorId: principal.actorId,
|
|
1387
|
+
fileId,
|
|
1388
|
+
grantId: decision.parentGrantId,
|
|
1389
|
+
context: { capabilities, subjectType: input.subject.type },
|
|
1390
|
+
});
|
|
1391
|
+
throw new FilelayerError(403, 'forbidden', refusal);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
const grantId = rows[0]!.id;
|
|
1396
|
+
const effectiveExpiry = rows[0]!.expires_at ? new Date(rows[0]!.expires_at) : null;
|
|
1397
|
+
const effectiveCap = rows[0]!.max_downloads ?? null;
|
|
1398
|
+
|
|
1399
|
+
await store.audit({
|
|
1400
|
+
orgId: file.orgId,
|
|
1401
|
+
action: 'grant.create',
|
|
1402
|
+
decision: 'allow',
|
|
1403
|
+
actorId: principal.actorId,
|
|
1404
|
+
fileId,
|
|
1405
|
+
grantId,
|
|
1406
|
+
context: {
|
|
1407
|
+
subjectType: input.subject.type,
|
|
1408
|
+
...(subjectOrgId ? { subjectOrgId } : {}),
|
|
1409
|
+
...(subjectMinRole ? { subjectMinRole } : {}),
|
|
1410
|
+
capabilities,
|
|
1411
|
+
parentGrantId: decision.parentGrantId,
|
|
1412
|
+
expiresAt: effectiveExpiry?.toISOString() ?? null,
|
|
1413
|
+
maxDownloads: effectiveCap,
|
|
1414
|
+
},
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
return {
|
|
1418
|
+
grantId,
|
|
1419
|
+
...(secret ? { secret } : {}),
|
|
1420
|
+
...(secret && this.opts.baseUrl ? { url: `${this.opts.baseUrl}/d/${secret}` } : {}),
|
|
1421
|
+
expiresAt: effectiveExpiry,
|
|
1422
|
+
maxDownloads: effectiveCap,
|
|
1423
|
+
parentGrantId: decision.parentGrantId,
|
|
1424
|
+
};
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* Resolve the org named by an `org` / `role` grant subject (RFC-001, I1).
|
|
1430
|
+
*
|
|
1431
|
+
* Three things have to be true and only one of them is about convenience:
|
|
1432
|
+
*
|
|
1433
|
+
* - the org must EXIST and not be soft-deleted (a grant naming a dead tenant
|
|
1434
|
+
* would be born non-live anyway -- see `grant_scope_is_live` -- so minting
|
|
1435
|
+
* one is a caller error worth reporting);
|
|
1436
|
+
* - it must be in THIS instance's project. That is the P8 boundary, and it
|
|
1437
|
+
* is the thing that makes a cross-project group grant unrepresentable. The
|
|
1438
|
+
* composite foreign key enforces it regardless; this exists so the answer
|
|
1439
|
+
* is a clean 404 rather than a constraint violation whose message would
|
|
1440
|
+
* itself confirm the id resolves to a row somewhere in the database.
|
|
1441
|
+
* - it need NOT be the file's own org. Cross-ORG group grants inside one
|
|
1442
|
+
* project are the whole point of the feature.
|
|
1443
|
+
*/
|
|
1444
|
+
async #resolveSubjectOrg(tx: Tx, orgId: string): Promise<string> {
|
|
1445
|
+
if (!isUuid(orgId)) throw new FilelayerError(404, 'not_found', 'unknown_subject_org');
|
|
1446
|
+
const { rows } = await tx.query<{ id: string }>(
|
|
1447
|
+
`SELECT o.id FROM org o
|
|
1448
|
+
JOIN project p ON p.id = o.project_id
|
|
1449
|
+
WHERE o.id = $1
|
|
1450
|
+
AND o.deleted_at IS NULL
|
|
1451
|
+
AND p.deleted_at IS NULL
|
|
1452
|
+
AND ($2::uuid IS NULL OR o.project_id = $2::uuid)`,
|
|
1453
|
+
[orgId, this.projectId],
|
|
1454
|
+
);
|
|
1455
|
+
if (!rows[0]) throw new FilelayerError(404, 'not_found', 'unknown_subject_org');
|
|
1456
|
+
return rows[0].id;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/**
|
|
1460
|
+
* Revoke a grant.
|
|
1461
|
+
*
|
|
1462
|
+
* Nothing cascades, and nothing needs to: liveness is evaluated over the
|
|
1463
|
+
* ancestor chain, so every grant ever delegated from this one dies in the
|
|
1464
|
+
* same instant, at any depth, with no second write to get wrong (P4).
|
|
1465
|
+
*/
|
|
1466
|
+
async revoke(principal: Principal, grantId: string): Promise<void> {
|
|
1467
|
+
if (!isUuid(grantId)) throw new FilelayerError(404, 'not_found');
|
|
1468
|
+
// Revocation is the operation the product is sold on, so the state change
|
|
1469
|
+
// and the event proving it happened must not be separable. Both are in this
|
|
1470
|
+
// transaction.
|
|
1471
|
+
//
|
|
1472
|
+
// LOCK ORDERING, and it is not decorative. Because `audit_append()` takes a
|
|
1473
|
+
// `pg_advisory_XACT_lock` on the org's chain, a transaction now holds that
|
|
1474
|
+
// lock from its FIRST audit write until commit -- which it did not before,
|
|
1475
|
+
// when every statement was its own transaction. Two transactions that take
|
|
1476
|
+
// the chain lock and a `file_grant` row lock in OPPOSITE orders deadlock.
|
|
1477
|
+
// The rule, followed by every method here, is:
|
|
1478
|
+
//
|
|
1479
|
+
// THE AUDIT CHAIN LOCK IS ALWAYS TAKEN BEFORE ANY ROW LOCK.
|
|
1480
|
+
//
|
|
1481
|
+
// `authorizeRevoke` emits its allow event (chain lock) before the UPDATE
|
|
1482
|
+
// below (row lock), and the delivery path likewise audits in `authorize()`
|
|
1483
|
+
// before `consume_download()` touches the grant row. This SELECT therefore
|
|
1484
|
+
// deliberately does NOT take `FOR UPDATE`: that would grab the row lock
|
|
1485
|
+
// first and invert the order against every other path. Nothing is lost --
|
|
1486
|
+
// the UPDATE is `WHERE revoked_at IS NULL`, so a concurrent revoke is
|
|
1487
|
+
// idempotent rather than a lost update.
|
|
1488
|
+
await this.#transaction(async (tx, store) => {
|
|
1489
|
+
const { rows } = await tx.query<{ file_id: string; org_id: string }>(
|
|
1490
|
+
`SELECT file_id, org_id FROM file_grant WHERE id = $1`,
|
|
1491
|
+
[grantId],
|
|
1492
|
+
);
|
|
1493
|
+
const g = rows[0];
|
|
1494
|
+
// Unknown grant and "not yours" are the same answer, for the same reason
|
|
1495
|
+
// file ids are: otherwise this endpoint is a grant-id oracle.
|
|
1496
|
+
if (!g) throw new FilelayerError(404, 'not_found');
|
|
1497
|
+
|
|
1498
|
+
const decision = await authorizeRevoke(store, principal, {
|
|
1499
|
+
id: grantId,
|
|
1500
|
+
fileId: g.file_id,
|
|
1501
|
+
orgId: g.org_id,
|
|
1502
|
+
});
|
|
1503
|
+
this.#raise(decision);
|
|
1504
|
+
|
|
1505
|
+
await tx.query(
|
|
1506
|
+
`UPDATE file_grant SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL`,
|
|
1507
|
+
[grantId],
|
|
1508
|
+
);
|
|
1509
|
+
await store.audit({
|
|
1510
|
+
orgId: g.org_id,
|
|
1511
|
+
action: 'grant.revoke',
|
|
1512
|
+
decision: 'allow',
|
|
1513
|
+
actorId: principal.actorId,
|
|
1514
|
+
fileId: g.file_id,
|
|
1515
|
+
grantId,
|
|
1516
|
+
});
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
async listGrants(principal: Principal, fileId: string): Promise<GrantSummary[]> {
|
|
1521
|
+
const decision = await authorize(this.store, principal, fileId, 'share');
|
|
1522
|
+
this.#raise(decision);
|
|
1523
|
+
|
|
1524
|
+
const { rows } = await this.db.query<Record<string, unknown>>(
|
|
1525
|
+
`SELECT id, file_id, parent_grant_id, subject_type, subject_id,
|
|
1526
|
+
subject_org_id, subject_min_role, capabilities,
|
|
1527
|
+
(password_hash IS NOT NULL) AS has_password,
|
|
1528
|
+
expires_at, max_downloads, download_count, revoked_at,
|
|
1529
|
+
grant_is_live(id) AS live,
|
|
1530
|
+
created_by, created_at
|
|
1531
|
+
FROM file_grant WHERE file_id = $1 ORDER BY created_at ASC`,
|
|
1532
|
+
[fileId],
|
|
1533
|
+
);
|
|
1534
|
+
// Note what is NOT selected: secret_hash and password_hash. A "list what
|
|
1535
|
+
// we've shared" screen is exactly where a hash would leak into a log.
|
|
1536
|
+
return rows.map((r) => ({
|
|
1537
|
+
id: r['id'] as string,
|
|
1538
|
+
fileId: r['file_id'] as string,
|
|
1539
|
+
parentGrantId: (r['parent_grant_id'] as string | null) ?? null,
|
|
1540
|
+
subjectType: r['subject_type'] as GrantSubjectType,
|
|
1541
|
+
subjectId: (r['subject_id'] as string | null) ?? null,
|
|
1542
|
+
subjectOrgId: (r['subject_org_id'] as string | null) ?? null,
|
|
1543
|
+
subjectMinRole: (r['subject_min_role'] as OrgRole | null) ?? null,
|
|
1544
|
+
capabilities: toCapabilities(r['capabilities'] as Capability[] | string),
|
|
1545
|
+
hasPassword: Boolean(r['has_password']),
|
|
1546
|
+
expiresAt: r['expires_at'] ? new Date(r['expires_at'] as string) : null,
|
|
1547
|
+
maxDownloads: (r['max_downloads'] as number | null) ?? null,
|
|
1548
|
+
downloadCount: Number(r['download_count']),
|
|
1549
|
+
revokedAt: r['revoked_at'] ? new Date(r['revoked_at'] as string) : null,
|
|
1550
|
+
live: Boolean(r['live']),
|
|
1551
|
+
createdBy: (r['created_by'] as string | null) ?? null,
|
|
1552
|
+
createdAt: new Date(r['created_at'] as string),
|
|
1553
|
+
}));
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
/**
|
|
1557
|
+
* The share-link download path.
|
|
1558
|
+
*
|
|
1559
|
+
* Order matters: authorize FIRST (which re-validates the grant and its whole
|
|
1560
|
+
* ancestor chain against `live_grant` -- P4, revocation beats a live URL),
|
|
1561
|
+
* then consume the counter atomically (P6). Consuming before authorizing
|
|
1562
|
+
* would let a revoked link burn a download; authorizing without consuming
|
|
1563
|
+
* would make the cap a suggestion.
|
|
1564
|
+
*/
|
|
1565
|
+
async redeem(
|
|
1566
|
+
linkSecret: string,
|
|
1567
|
+
opts: {
|
|
1568
|
+
password?: string;
|
|
1569
|
+
ip?: string;
|
|
1570
|
+
userAgent?: string;
|
|
1571
|
+
disposition?: Disposition;
|
|
1572
|
+
} = {},
|
|
1573
|
+
): Promise<{
|
|
1574
|
+
file: FileRecord;
|
|
1575
|
+
body: Uint8Array;
|
|
1576
|
+
headers: Record<string, string>;
|
|
1577
|
+
remainingDownloads: number | null;
|
|
1578
|
+
}> {
|
|
1579
|
+
// Buffered convenience form of `redeemStream()`, exactly as `read()` is of
|
|
1580
|
+
// `readStream()`. Forces proxy mode for the same reason.
|
|
1581
|
+
const d = await this.redeemStream(linkSecret, { ...opts, mode: 'proxy' });
|
|
1582
|
+
if (d.mode !== 'proxy') throw new FilelayerError(500, 'internal', 'unexpected_redirect');
|
|
1583
|
+
return {
|
|
1584
|
+
file: d.file,
|
|
1585
|
+
body: await collectStream(d.body),
|
|
1586
|
+
headers: d.headers,
|
|
1587
|
+
remainingDownloads: d.remainingDownloads,
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
/** The streaming share-link path. See `redeem()` for the ordering rationale. */
|
|
1592
|
+
async redeemStream(
|
|
1593
|
+
linkSecret: string,
|
|
1594
|
+
opts: {
|
|
1595
|
+
password?: string;
|
|
1596
|
+
ip?: string;
|
|
1597
|
+
userAgent?: string;
|
|
1598
|
+
disposition?: Disposition;
|
|
1599
|
+
mode?: 'proxy' | 'auto';
|
|
1600
|
+
range?: { start: number; end?: number };
|
|
1601
|
+
} = {},
|
|
1602
|
+
): Promise<StreamedDelivery & { file: FileRecord; remainingDownloads: number | null }> {
|
|
1603
|
+
const principal: Principal = {
|
|
1604
|
+
actorId: null,
|
|
1605
|
+
linkSecret,
|
|
1606
|
+
...(opts.password !== undefined ? { password: opts.password } : {}),
|
|
1607
|
+
...(opts.ip !== undefined ? { ip: opts.ip } : {}),
|
|
1608
|
+
...(opts.userAgent !== undefined ? { userAgent: opts.userAgent } : {}),
|
|
1609
|
+
};
|
|
1610
|
+
|
|
1611
|
+
const hash = await this.store.hashSecret(linkSecret);
|
|
1612
|
+
|
|
1613
|
+
const reserved = await this.#transaction(async (tx, store) => {
|
|
1614
|
+
// Resolve the secret to a FILE, not to an authorization. This
|
|
1615
|
+
// deliberately reads through the non-live lookup: a revoked, expired or
|
|
1616
|
+
// exhausted link must still reach the engine so that the denial is
|
|
1617
|
+
// attributed to the right tenant and recorded with the right reason.
|
|
1618
|
+
// Nothing here grants anything -- `authorize` below re-resolves through
|
|
1619
|
+
// `live_grant`.
|
|
1620
|
+
const grant = await store.findGrantBySecret(hash);
|
|
1621
|
+
if (!grant) {
|
|
1622
|
+
// No file and no tenant: the system chain exists precisely so that a
|
|
1623
|
+
// brute-force sweep against the credential itself is not invisible.
|
|
1624
|
+
// In the transaction, so the sweep cannot be made invisible by a
|
|
1625
|
+
// failure on the way out either.
|
|
1626
|
+
await auditUnresolvedSecret(store, principal, hash);
|
|
1627
|
+
throw new FilelayerError(404, 'not_found', 'bad_link_secret');
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
const decision = await authorize(store, principal, grant.fileId, 'read');
|
|
1631
|
+
this.#raise(decision);
|
|
1632
|
+
|
|
1633
|
+
// Same reservation path as `readStream()`: one place charges the cap, one
|
|
1634
|
+
// place decides the mode, one place computes the headers. `no-store` on
|
|
1635
|
+
// the proxied response is not cosmetic here -- immediate revocation is the
|
|
1636
|
+
// product's headline property, and a cacheable share response makes a
|
|
1637
|
+
// revoked link replayable from the recipient's disk cache or from any
|
|
1638
|
+
// intermediary.
|
|
1639
|
+
return this.#reserve(tx, store, grant.fileId, decision, principal, opts);
|
|
1640
|
+
});
|
|
1641
|
+
|
|
1642
|
+
return this.#fetchDelivery(reserved, opts);
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
// ---------------------------------------------------------------------------
|
|
1646
|
+
// Audit
|
|
1647
|
+
// ---------------------------------------------------------------------------
|
|
1648
|
+
|
|
1649
|
+
/** Requires `read_audit` in the org, which is admin+. Asked of the engine. */
|
|
1650
|
+
async auditLog(
|
|
1651
|
+
principal: Principal,
|
|
1652
|
+
orgId: string,
|
|
1653
|
+
filter: {
|
|
1654
|
+
decision?: 'allow' | 'deny';
|
|
1655
|
+
fileId?: string;
|
|
1656
|
+
actorId?: string;
|
|
1657
|
+
action?: string;
|
|
1658
|
+
limit?: number;
|
|
1659
|
+
} = {},
|
|
1660
|
+
): Promise<AuditRow[]> {
|
|
1661
|
+
const decision = await authorizeOrg(this.store, principal, orgId, 'read_audit', {
|
|
1662
|
+
action: 'audit.read',
|
|
1663
|
+
emitAllow: false, // reading the log should not spam the log
|
|
1664
|
+
});
|
|
1665
|
+
this.#raise(decision);
|
|
1666
|
+
return this.store.listAudit(orgId, filter);
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
/**
|
|
1670
|
+
* Verify the tamper-evidence chain for an org.
|
|
1671
|
+
*
|
|
1672
|
+
* Authorized, because `checked` is a count of everything that has ever
|
|
1673
|
+
* happened in the org and an unauthenticated caller should not be able to
|
|
1674
|
+
* measure another tenant's activity.
|
|
1675
|
+
*/
|
|
1676
|
+
async verifyAuditChain(principal: Principal, orgId: string): Promise<AuditChainResult> {
|
|
1677
|
+
const decision = await authorizeOrg(this.store, principal, orgId, 'read_audit', {
|
|
1678
|
+
action: 'audit.verify',
|
|
1679
|
+
emitAllow: false,
|
|
1680
|
+
});
|
|
1681
|
+
this.#raise(decision);
|
|
1682
|
+
return this.store.verifyAuditChain(orgId);
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// ---------------------------------------------------------------------------
|
|
1686
|
+
// Internals
|
|
1687
|
+
// ---------------------------------------------------------------------------
|
|
1688
|
+
|
|
1689
|
+
/**
|
|
1690
|
+
* Turn a denial into an HTTP-shaped error.
|
|
1691
|
+
*
|
|
1692
|
+
* That is all it does now. It used to additionally re-run the whole
|
|
1693
|
+
* authorization question in order to downgrade 410/409 to 404 for callers
|
|
1694
|
+
* with no standing, because `authorize()` evaluated lifecycle gates before
|
|
1695
|
+
* establishing standing and therefore leaked existence. The engine
|
|
1696
|
+
* evaluates standing first now, so there is nothing left to compensate for --
|
|
1697
|
+
* and, not incidentally, one fewer place for a second entry point to forget.
|
|
1698
|
+
*/
|
|
1699
|
+
#raise(decision: Decision): asserts decision is Extract<Decision, { allow: true }> {
|
|
1700
|
+
if (decision.allow) return;
|
|
1701
|
+
const pub = toPublicError(decision.reason);
|
|
1702
|
+
throw new FilelayerError(pub.status, pub.code, decision.reason);
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
/**
|
|
1707
|
+
* A file's full record from its id, with NO principal.
|
|
1708
|
+
*
|
|
1709
|
+
* IT IS A MODULE-LEVEL FUNCTION, NOT A PRIVATE METHOD, AND THAT IS THE POINT.
|
|
1710
|
+
*
|
|
1711
|
+
* This was once a PUBLIC method taking a file id and no principal, returning
|
|
1712
|
+
* name, content type, size, storage key, owner and org for any file in the
|
|
1713
|
+
* database. It sat under an `// Internals` comment, which binds nobody:
|
|
1714
|
+
* `fl.getFileRecord(anyUuid)` was a complete cross-tenant metadata read with no
|
|
1715
|
+
* decision, no denial and no audit event. A resource id without a principal is
|
|
1716
|
+
* not a question the system is allowed to answer.
|
|
1717
|
+
*
|
|
1718
|
+
* Making it `private` fixed the TYPE and not the RUNTIME. TypeScript's `private`
|
|
1719
|
+
* is erased at compile time: `(fl as any).getFileRecord(id)` still worked, and
|
|
1720
|
+
* so did `fl['getFileRecord'](id)` from plain JavaScript -- which is what an SDK
|
|
1721
|
+
* consumer actually holds. A test in test/persistence.test.ts caught exactly
|
|
1722
|
+
* that. Module scope is the only privacy JavaScript actually enforces, so the
|
|
1723
|
+
* function lives out here, where nothing outside this file can name it.
|
|
1724
|
+
*
|
|
1725
|
+
* Every caller inside the class reaches it only AFTER `authorize()` has
|
|
1726
|
+
* returned allow for the same file; the authorized replacement for external
|
|
1727
|
+
* callers is `stat()`. It is project-scoped, so even an internal caller cannot
|
|
1728
|
+
* read across a project boundary.
|
|
1729
|
+
*/
|
|
1730
|
+
async function getFileRecord(
|
|
1731
|
+
db: Queryable,
|
|
1732
|
+
projectId: string | null,
|
|
1733
|
+
fileId: string,
|
|
1734
|
+
): Promise<FileRecord | null> {
|
|
1735
|
+
if (!isUuid(fileId)) return null;
|
|
1736
|
+
const { rows } = await db.query<Record<string, unknown>>(
|
|
1737
|
+
`SELECT id, org_id, owner_id, name, content_type, size_bytes,
|
|
1738
|
+
storage_provider, storage_key,
|
|
1739
|
+
state, visibility, expires_at, retain_until, created_at, deleted_at
|
|
1740
|
+
FROM file WHERE id = $1 AND ($2::uuid IS NULL OR project_id = $2::uuid)`,
|
|
1741
|
+
[fileId, projectId],
|
|
1742
|
+
);
|
|
1743
|
+
return rows[0] ? toFileRecord(rows[0]) : null;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
/**
|
|
1747
|
+
* Listing options.
|
|
1748
|
+
*
|
|
1749
|
+
* Note what is absent: any way to express a WHERE clause, a raw filter, an
|
|
1750
|
+
* "include everything" flag, or a way to name another org. Everything here
|
|
1751
|
+
* NARROWS the authorized set; nothing widens it. That is what "fail-closed by
|
|
1752
|
+
* construction" has to mean for a query API -- not that the default is safe,
|
|
1753
|
+
* but that the unsafe result is not expressible.
|
|
1754
|
+
*/
|
|
1755
|
+
export interface ListFilesOptions {
|
|
1756
|
+
/** Which capability the caller must hold. Default `read`. */
|
|
1757
|
+
capability?: Capability;
|
|
1758
|
+
/** Page size. Clamped to [1, 200]. */
|
|
1759
|
+
limit?: number;
|
|
1760
|
+
/** Opaque keyset cursor from a previous page's `nextCursor`. */
|
|
1761
|
+
cursor?: string | null;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
export interface FileListPage {
|
|
1765
|
+
files: FileRecord[];
|
|
1766
|
+
/** Null when this is the last page. */
|
|
1767
|
+
nextCursor: string | null;
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
/**
|
|
1771
|
+
* Keyset pagination over (created_at, id).
|
|
1772
|
+
*
|
|
1773
|
+
* Keyset, not OFFSET: with OFFSET a row inserted or deleted between pages
|
|
1774
|
+
* shifts the window and a file silently skips a page, which on a compliance
|
|
1775
|
+
* listing screen is a file the reviewer never saw.
|
|
1776
|
+
*
|
|
1777
|
+
* The cursor is opaque but not authenticated, and it does not need to be: it
|
|
1778
|
+
* carries only a position, it is applied AFTER the authorization predicate, and
|
|
1779
|
+
* it is confined to the org named in the call. A forged cursor can move you
|
|
1780
|
+
* within your own authorized set and nowhere else. It is validated on the way
|
|
1781
|
+
* in so that a malformed one is a 400 rather than a silently-ignored filter.
|
|
1782
|
+
*/
|
|
1783
|
+
function encodeCursor(createdAt: Date, id: string): string {
|
|
1784
|
+
return Buffer.from(`${createdAt.toISOString()}|${id}`, 'utf8').toString('base64url');
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
function decodeCursor(cursor: string | null | undefined): { createdAt: Date; id: string } | null {
|
|
1788
|
+
if (cursor === undefined || cursor === null || cursor === '') return null;
|
|
1789
|
+
let decoded: string;
|
|
1790
|
+
try {
|
|
1791
|
+
decoded = Buffer.from(cursor, 'base64url').toString('utf8');
|
|
1792
|
+
} catch {
|
|
1793
|
+
throw new FilelayerError(400, 'bad_cursor');
|
|
1794
|
+
}
|
|
1795
|
+
const sep = decoded.lastIndexOf('|');
|
|
1796
|
+
if (sep < 0) throw new FilelayerError(400, 'bad_cursor');
|
|
1797
|
+
const createdAt = new Date(decoded.slice(0, sep));
|
|
1798
|
+
const id = decoded.slice(sep + 1);
|
|
1799
|
+
if (Number.isNaN(createdAt.getTime()) || !isUuid(id)) {
|
|
1800
|
+
throw new FilelayerError(400, 'bad_cursor');
|
|
1801
|
+
}
|
|
1802
|
+
return { createdAt, id };
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
/**
|
|
1806
|
+
* Postgres array literal. The driver will not serialize a JS array into an
|
|
1807
|
+
* enum[] parameter, and a silent misencoding here would either error loudly
|
|
1808
|
+
* (fine) or store a single bogus capability (not fine), so the encoding is
|
|
1809
|
+
* explicit. Enum labels are a closed set of [a-z]+ and cannot contain a
|
|
1810
|
+
* separator, so no quoting is required -- but we assert that rather than
|
|
1811
|
+
* assume it.
|
|
1812
|
+
*/
|
|
1813
|
+
export function pgArrayLiteral(values: readonly string[]): string {
|
|
1814
|
+
for (const v of values) {
|
|
1815
|
+
if (!/^[a-z_]+$/.test(v)) throw new Error(`unexpected capability literal: ${v}`);
|
|
1816
|
+
}
|
|
1817
|
+
return `{${values.join(',')}}`;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
function toFileRecord(r: Record<string, unknown>): FileRecord {
|
|
1821
|
+
return {
|
|
1822
|
+
id: r['id'] as string,
|
|
1823
|
+
orgId: r['org_id'] as string,
|
|
1824
|
+
ownerId: (r['owner_id'] as string | null) ?? null,
|
|
1825
|
+
name: r['name'] as string,
|
|
1826
|
+
contentType: r['content_type'] as string,
|
|
1827
|
+
sizeBytes: r['size_bytes'] == null ? null : Number(r['size_bytes']),
|
|
1828
|
+
storageProvider: r['storage_provider'] as string,
|
|
1829
|
+
storageKey: r['storage_key'] as string,
|
|
1830
|
+
state: r['deleted_at'] ? 'deleted' : (r['state'] as 'pending' | 'ready' | 'deleted'),
|
|
1831
|
+
visibility: r['visibility'] as FileVisibility,
|
|
1832
|
+
expiresAt: r['expires_at'] ? new Date(r['expires_at'] as string) : null,
|
|
1833
|
+
retainUntil: r['retain_until'] ? new Date(r['retain_until'] as string) : null,
|
|
1834
|
+
createdAt: new Date(r['created_at'] as string),
|
|
1835
|
+
};
|
|
1836
|
+
}
|