@filelayer/core 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +338 -0
  2. package/LICENSE +202 -0
  3. package/MIGRATIONS.md +328 -0
  4. package/NOTICE +37 -0
  5. package/README.md +343 -0
  6. package/SEMANTICS.md +729 -0
  7. package/dist/authz.d.ts +524 -0
  8. package/dist/authz.d.ts.map +1 -0
  9. package/dist/authz.js +889 -0
  10. package/dist/authz.js.map +1 -0
  11. package/dist/db.d.ts +145 -0
  12. package/dist/db.d.ts.map +1 -0
  13. package/dist/db.js +217 -0
  14. package/dist/db.js.map +1 -0
  15. package/dist/delivery.d.ts +293 -0
  16. package/dist/delivery.d.ts.map +1 -0
  17. package/dist/delivery.js +519 -0
  18. package/dist/delivery.js.map +1 -0
  19. package/dist/errors.d.ts +16 -0
  20. package/dist/errors.d.ts.map +1 -0
  21. package/dist/errors.js +21 -0
  22. package/dist/errors.js.map +1 -0
  23. package/dist/filelayer.d.ts +542 -0
  24. package/dist/filelayer.d.ts.map +1 -0
  25. package/dist/filelayer.js +1360 -0
  26. package/dist/filelayer.js.map +1 -0
  27. package/dist/index.d.ts +8 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +8 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/simple.d.ts +297 -0
  32. package/dist/simple.d.ts.map +1 -0
  33. package/dist/simple.js +492 -0
  34. package/dist/simple.js.map +1 -0
  35. package/dist/storage.d.ts +269 -0
  36. package/dist/storage.d.ts.map +1 -0
  37. package/dist/storage.js +700 -0
  38. package/dist/storage.js.map +1 -0
  39. package/dist/store.d.ts +432 -0
  40. package/dist/store.d.ts.map +1 -0
  41. package/dist/store.js +862 -0
  42. package/dist/store.js.map +1 -0
  43. package/package.json +77 -0
  44. package/schema.sql +1190 -0
  45. package/src/authz.ts +1398 -0
  46. package/src/db.ts +271 -0
  47. package/src/delivery.ts +737 -0
  48. package/src/errors.ts +24 -0
  49. package/src/filelayer.ts +1836 -0
  50. package/src/index.ts +7 -0
  51. package/src/simple.ts +666 -0
  52. package/src/storage.ts +917 -0
  53. package/src/store.ts +1072 -0
  54. package/test/delivery.test.ts +0 -0
  55. package/test/group-subjects.test.ts +1072 -0
  56. package/test/helpers.ts +65 -0
  57. package/test/listing.test.ts +689 -0
  58. package/test/local-s3.d.mts +33 -0
  59. package/test/local-s3.mjs +400 -0
  60. package/test/persistence.test.ts +953 -0
  61. package/test/regression.test.ts +619 -0
  62. package/test/s3-live.test.ts +322 -0
  63. package/test/security.test.ts +1652 -0
  64. package/test/semantics.test.ts +888 -0
  65. package/test/storage.test.ts +437 -0
  66. package/test/tiers.test.ts +432 -0
  67. package/test/vault-example.test.ts +302 -0
  68. package/tsconfig.build.json +29 -0
  69. package/tsconfig.json +19 -0
package/schema.sql ADDED
@@ -0,0 +1,1190 @@
1
+ -- =============================================================================
2
+ -- FILELAYER CORE SCHEMA
3
+ -- Multi-tenant file authorization + lifecycle
4
+ -- =============================================================================
5
+ --
6
+ -- DESIGN PRINCIPLES (each of these is a security property we sell):
7
+ --
8
+ -- P1. DENY BY DEFAULT. There is no row that grants access implicitly. Access
9
+ -- exists only as an explicit row in `membership` or `grant`. Absence of a
10
+ -- row is denial. There is no "public" boolean anywhere in this schema --
11
+ -- public delivery is a *separate, explicitly created* grant with
12
+ -- subject_type='anonymous'. This is the single most important decision in
13
+ -- the file: the Fiverr/Cloudinary tax-return leak and the entire "the
14
+ -- bucket was public" class of incident are impossible to express here.
15
+ --
16
+ -- P1 now holds at the FILE boundary as well as the tenant boundary. A file
17
+ -- is created `private` unless the creator explicitly asks for org-wide
18
+ -- visibility (see `file.visibility`). Previously every viewer in an org
19
+ -- could read every file in it; that defect is fixed.
20
+ --
21
+ -- P2. NO AMBIENT AUTHORITY. Every authorization answer is derived from
22
+ -- (actor, file) -> traversal of membership + grants. Storage location is
23
+ -- never an input to an access decision. Knowing an object key, a URL, or a
24
+ -- file id grants nothing.
25
+ --
26
+ -- P3. TENANT ISOLATION IS STRUCTURAL, NOT CONDITIONAL. Every access-bearing
27
+ -- table carries org_id, and every uniqueness/foreign-key constraint is
28
+ -- org-scoped. Cross-tenant access is not "prevented by a WHERE clause" --
29
+ -- it is unrepresentable, because a grant's org_id must equal its file's
30
+ -- org_id (enforced by composite FK, see `grant` table).
31
+ --
32
+ -- P4. A SIGNED URL MAY NEVER OUTLIVE THE PERMISSION THAT CREATED IT.
33
+ -- This is the defect in Convex ("the only way to revoke a file URL is by
34
+ -- deleting the file"), Cloudinary ("once a URL is exposed, anyone with it
35
+ -- can access the asset"), and raw S3 presigning. Here, every signed URL
36
+ -- embeds a grant_id; delivery re-validates the grant on every request.
37
+ -- Revocation is therefore immediate and beats a live URL. This property is
38
+ -- the product. If we ever relax it for performance, we have no product.
39
+ --
40
+ -- P4 IS NOW TRANSITIVE. A grant may be delegated (see `parent_grant_id`),
41
+ -- and liveness is evaluated over the whole ancestor chain: a grant is live
42
+ -- only if it AND every ancestor is unrevoked, unexpired and under cap.
43
+ -- Revoking a grant kills everything ever delegated from it, at any depth,
44
+ -- with no cascading write. Previously a delegated grant survived revocation
45
+ -- of its parent, which was the reason P4 was only true for
46
+ -- directly-issued grants. That is fixed here, in the schema, because it is
47
+ -- a data-model property and cannot be a convention.
48
+ --
49
+ -- P5. EVERY ACCESS DECISION IS AUDITED, INCLUDING DENIALS. Denials are the
50
+ -- security-relevant events. A trail that only records successes cannot
51
+ -- evidence an attempted breach. Decisions that cannot be attributed to a
52
+ -- tenant (a probe against a file id that does not exist, a sweep against
53
+ -- link secrets) are written to the SYSTEM chain, `org_id IS NULL`, which no
54
+ -- tenant can read. Attributing them to a guessed org would itself be an
55
+ -- existence oracle; dropping them, which is what we used to do, made
56
+ -- enumeration invisible.
57
+ --
58
+ -- P6. COUNTERS ARE ATOMIC. Download caps are enforced by a conditional UPDATE
59
+ -- that is itself the reservation. Read-then-write is a bypass under
60
+ -- concurrency and would make "max 3 downloads" a lie. A download now
61
+ -- consumes the budget of the whole ancestor chain, so a delegated link
62
+ -- cannot spend more than its parent had left.
63
+ --
64
+ -- P6 NOW COVERS EVERY DELIVERY, NOT JUST REDEMPTIONS. The cap used
65
+ -- to be charged only by the share-link path, so an actor grant carrying
66
+ -- `max_downloads = 1` permitted unlimited direct reads: the dimension was
67
+ -- enforced on one path and decorative on the other, while being named and
68
+ -- documented as a download cap. A download is now charged whenever bytes
69
+ -- leave through a GRANT, by any principal, on any path. Authority from an
70
+ -- org role is not charged -- it is not a metered credential -- and metadata
71
+ -- reads are not charged, because no bytes leave. See `Filelayer.deliver()`.
72
+ --
73
+ -- P7. DELETION IS A PREDICATE, NOT A CASCADE. A grant is live only while
74
+ -- its whole SCOPE exists: its file, that file's org, that org's project,
75
+ -- the actor it was issued to, THE ORG WHOSE MEMBERS IT NAMES (for a group
76
+ -- grant), and the actor who issued it. Delete any of
77
+ -- them and every grant beneath dies on the next request, at any delegation
78
+ -- depth, with no cascading write -- and restoring it revives exactly what
79
+ -- it killed, because nothing was written. Deleting a tenant that leaves its
80
+ -- share links serving bytes is not a deletion. See `grant_scope_is_live`.
81
+ --
82
+ -- Soft delete SUSPENDS ACCESS; it does not erase. It touches no row a
83
+ -- retention hold protects and it leaves the tenant's audit chain intact and
84
+ -- verifiable, so it cannot be used to destroy records under legal hold.
85
+ --
86
+ -- P8. THE CUSTOMER'S ID SPACE IS THE CUSTOMER'S. In a hosted deployment,
87
+ -- every customer application shares this database, so `external_id` is
88
+ -- unique WITHIN a project and meaningless across projects. Every table that
89
+ -- can name an actor carries `project_id` under a composite foreign key, so
90
+ -- a cross-project membership, ownership, grant subject or grant issuer is
91
+ -- unrepresentable -- P3, one level up.
92
+ --
93
+ -- Target: PostgreSQL 15+ (`ON DELETE SET NULL (column)`). Runs on PGlite for
94
+ -- test/CI, which is PostgreSQL 17 (real planner, constraints, enums, arrays,
95
+ -- rules, advisory locks and transactional semantics).
96
+ -- =============================================================================
97
+
98
+ CREATE EXTENSION IF NOT EXISTS pgcrypto;
99
+
100
+ -- -----------------------------------------------------------------------------
101
+ -- PROJECT -- the customer's application (P8)
102
+ -- -----------------------------------------------------------------------------
103
+ --
104
+ -- A HOSTED, SHARED DATABASE MADE THIS NECESSARY, AND IT IS A BREAKING CHANGE.
105
+ --
106
+ -- `org.external_id` and `actor.external_id` used to be GLOBALLY unique. That is
107
+ -- correct for a library each customer runs against their own database, and it
108
+ -- is a cross-customer data breach the moment Filelayer owns the database and
109
+ -- every customer's application shares it. Two facts made it worse than a
110
+ -- collision:
111
+ --
112
+ -- * `Identities.org()` resolves a customer's own org id with
113
+ -- `INSERT ... ON CONFLICT (external_id) DO UPDATE ... RETURNING id`. Under
114
+ -- a global unique index, customer B calling `put({ org: 'acme' })` does not
115
+ -- get an error -- it gets customer A's org id, and then `Identities`
116
+ -- helpfully adds B's user to A's tenant as a `member`. That is a complete
117
+ -- cross-tenant compromise reachable from the most ergonomic entry point we
118
+ -- ship, with no attacker skill required beyond picking a common org name.
119
+ -- * `Identities.actor()` is the same shape, so `as: 'alice'` in customer B's
120
+ -- app resolves to customer A's Alice.
121
+ --
122
+ -- The fix is a scope above the tenant: the PROJECT, which is one customer
123
+ -- application. `external_id` is the customer's id space, so it is unique
124
+ -- WITHIN a project and meaningless across projects.
125
+ --
126
+ -- A project is also the unit an API key authenticates (see SEMANTICS.md, "The
127
+ -- ingest boundary"), which is what turns audit-chain flooding from "any
128
+ -- internet caller can flood any tenant's audit chain" into "an authenticated
129
+ -- customer can flood their own tenant's chain", i.e. a quota problem rather
130
+ -- than a security one.
131
+ --
132
+ -- MIGRATION: there are no customers, so the migration is a drop and recreate.
133
+ -- For a deployment that had data:
134
+ -- ALTER TABLE org DROP CONSTRAINT org_external_id_key;
135
+ -- ALTER TABLE actor DROP CONSTRAINT actor_external_id_key;
136
+ -- INSERT INTO project (id, key, name) VALUES (DEFAULT_PROJECT_ID, ...);
137
+ -- ALTER TABLE org ADD COLUMN project_id uuid NOT NULL DEFAULT <that id>;
138
+ -- ALTER TABLE actor ADD COLUMN project_id uuid NOT NULL DEFAULT <that id>;
139
+ -- ...then the unique/foreign keys below. Every existing row lands in one
140
+ -- project, which is exactly what a pre-hosted deployment was.
141
+
142
+ CREATE TABLE project (
143
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
144
+ key text NOT NULL UNIQUE, -- OUR id for the customer app
145
+ name text,
146
+ created_at timestamptz NOT NULL DEFAULT now(),
147
+ deleted_at timestamptz
148
+ );
149
+
150
+ -- The project every row lands in when nobody named one. It exists so that a
151
+ -- single-project deployment (self-hosted, CI, the examples) needs no project
152
+ -- vocabulary at all, and so that the migration above is a one-liner. It is NOT
153
+ -- a hole in the model: it is a real project row with a real id, exactly as
154
+ -- DEFAULT_WORKSPACE is a real org. The hosted API layer always names a project
155
+ -- explicitly, because it resolves one from the request's API key.
156
+ INSERT INTO project (id, key, name)
157
+ VALUES ('00000000-0000-0000-0000-0000000f11e1', '__filelayer_default_project__', 'Default project');
158
+
159
+ -- -----------------------------------------------------------------------------
160
+ -- TENANCY
161
+ -- -----------------------------------------------------------------------------
162
+
163
+ CREATE TABLE org (
164
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
165
+ project_id uuid NOT NULL DEFAULT '00000000-0000-0000-0000-0000000f11e1'
166
+ REFERENCES project(id) ON DELETE CASCADE,
167
+ external_id text NOT NULL, -- the customer's own org id
168
+ name text,
169
+ created_at timestamptz NOT NULL DEFAULT now(),
170
+ deleted_at timestamptz,
171
+
172
+ -- P8. The customer's id space is scoped to the customer.
173
+ UNIQUE (project_id, external_id),
174
+ -- ...and this is what lets every access-bearing table below carry a
175
+ -- project_id that is PROVABLY the org's, by composite foreign key, in the
176
+ -- same way P3 makes a grant's org provably its file's.
177
+ UNIQUE (id, project_id)
178
+ );
179
+
180
+ CREATE TABLE actor (
181
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
182
+ project_id uuid NOT NULL DEFAULT '00000000-0000-0000-0000-0000000f11e1'
183
+ REFERENCES project(id) ON DELETE CASCADE,
184
+ external_id text NOT NULL, -- the customer's own user id
185
+ created_at timestamptz NOT NULL DEFAULT now(),
186
+ deleted_at timestamptz,
187
+ UNIQUE (project_id, external_id),
188
+ UNIQUE (id, project_id)
189
+ );
190
+
191
+ -- P3, ONE LEVEL UP. Every table that can name an actor also carries the
192
+ -- project, and the pair (actor_id, project_id) is a foreign key into
193
+ -- actor(id, project_id). A membership, a file ownership, a grant subject or a
194
+ -- grant issuer that crosses a project boundary is therefore UNREPRESENTABLE,
195
+ -- not merely unlikely -- the same standard the cross-tenant case is held to.
196
+ --
197
+ -- The project column is DERIVED, never supplied: this trigger overwrites
198
+ -- whatever a writer passed with the value read from the org. So there is no
199
+ -- way to write a row whose project_id disagrees with its org, and the foreign
200
+ -- keys then do the rest. A writer that supplies a wrong actor gets a foreign
201
+ -- key violation rather than a silently cross-project row.
202
+ CREATE FUNCTION project_from_org() RETURNS trigger
203
+ LANGUAGE plpgsql AS $$
204
+ BEGIN
205
+ SELECT o.project_id INTO NEW.project_id FROM org o WHERE o.id = NEW.org_id;
206
+ IF NEW.project_id IS NULL THEN
207
+ RAISE EXCEPTION 'project_unresolved: org % does not exist', NEW.org_id;
208
+ END IF;
209
+ RETURN NEW;
210
+ END;
211
+ $$;
212
+
213
+ -- Roles are fixed and ordered. We deliberately do NOT ship custom roles in v1:
214
+ -- an unbounded role system is the fastest way to reintroduce the complexity we
215
+ -- claim to remove, and it makes the claim unfalsifiable.
216
+ CREATE TYPE org_role AS ENUM ('viewer', 'member', 'admin', 'owner');
217
+
218
+ CREATE TABLE membership (
219
+ org_id uuid NOT NULL REFERENCES org(id) ON DELETE CASCADE,
220
+ actor_id uuid NOT NULL,
221
+ project_id uuid NOT NULL, -- derived from org, see trigger
222
+ role org_role NOT NULL,
223
+ created_at timestamptz NOT NULL DEFAULT now(),
224
+ PRIMARY KEY (org_id, actor_id),
225
+ FOREIGN KEY (org_id, project_id) REFERENCES org (id, project_id) ON DELETE CASCADE,
226
+ FOREIGN KEY (actor_id, project_id) REFERENCES actor (id, project_id) ON DELETE CASCADE
227
+ );
228
+
229
+ CREATE TRIGGER membership_project
230
+ BEFORE INSERT OR UPDATE OF org_id ON membership
231
+ FOR EACH ROW EXECUTE FUNCTION project_from_org();
232
+
233
+ CREATE INDEX membership_actor_idx ON membership (actor_id);
234
+
235
+ -- -----------------------------------------------------------------------------
236
+ -- FILES
237
+ -- -----------------------------------------------------------------------------
238
+
239
+ CREATE TYPE file_state AS ENUM ('pending', 'ready', 'deleted');
240
+
241
+ -- Who, inside the owning org, can see a file at all before anybody shares
242
+ -- it. This is a product decision made explicit rather than an emergent property
243
+ -- of the role table:
244
+ --
245
+ -- 'private' (DEFAULT) -- the file exists for its owner and for org
246
+ -- admins/owners only. Every other member of the org,
247
+ -- at any role, needs an explicit grant. This is P1 at
248
+ -- the file boundary.
249
+ -- 'org' -- every member of the org may read it, as before.
250
+ -- Correct for a genuinely shared workspace; wrong for
251
+ -- HR, legal and finance documents, which is the Vault
252
+ -- scenario.
253
+ --
254
+ -- The default is the restrictive one on purpose: the failure mode of the wrong
255
+ -- default in this direction is "someone has to ask for access", and in the
256
+ -- other direction it is a disclosure. Org admins/owners keep access under both
257
+ -- settings, because a file no administrator can reach cannot be retained,
258
+ -- deleted, or produced under legal hold -- and a compliance control that the
259
+ -- accountable party cannot exercise is not a control.
260
+ --
261
+ -- RELATIONSHIP TO GROUP GRANTS (RFC-001). Now that `subject_type = 'org'`
262
+ -- exists, `visibility = 'org'` is exactly describable as an IMPLICIT ORG GRANT:
263
+ -- a read grant whose subject org is the file's own org. It is RETAINED as sugar
264
+ -- for that one case, deliberately, for two reasons. It is the case that needs
265
+ -- no vocabulary at all ("this document is for the team"), and it is a column on
266
+ -- the file rather than a row, which is what lets the role matrix stay a total
267
+ -- function over 4 roles x 2 ownerships x 2 visibilities -- the enumeration the
268
+ -- listing predicate is DERIVED from. It does not violate P1: it is still not a
269
+ -- boolean that opens a file to a population beyond the tenant that owns it, and
270
+ -- every wider audience is still an explicit, revocable, auditable grant row.
271
+ CREATE TYPE file_visibility AS ENUM ('private', 'org');
272
+
273
+ CREATE TABLE file (
274
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
275
+ org_id uuid NOT NULL REFERENCES org(id) ON DELETE CASCADE,
276
+ project_id uuid NOT NULL, -- derived from org, see trigger
277
+ owner_id uuid,
278
+
279
+ name text NOT NULL,
280
+ content_type text NOT NULL,
281
+ size_bytes bigint,
282
+ checksum_sha256 text,
283
+
284
+ -- Storage is an implementation detail and is never an access input (P2).
285
+ storage_provider text NOT NULL DEFAULT 'r2',
286
+ storage_key text NOT NULL,
287
+
288
+ state file_state NOT NULL DEFAULT 'pending',
289
+ visibility file_visibility NOT NULL DEFAULT 'private',
290
+
291
+ -- Lifecycle
292
+ expires_at timestamptz, -- hard lifecycle expiry
293
+ retain_until timestamptz, -- retention floor: blocks deletion
294
+ created_at timestamptz NOT NULL DEFAULT now(),
295
+ updated_at timestamptz NOT NULL DEFAULT now(),
296
+ deleted_at timestamptz,
297
+
298
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
299
+
300
+ -- P3: this composite unique is what makes cross-tenant grants
301
+ -- unrepresentable. `grant` references (file_id, org_id) as a pair, so a
302
+ -- grant can never point at a file in a different org.
303
+ UNIQUE (id, org_id),
304
+
305
+ -- P8: the owner must be an identity from the SAME project as the file's
306
+ -- org. `ON DELETE SET NULL (owner_id)` names the column explicitly (PG 15+)
307
+ -- so that a hard actor delete nulls the owner without also trying to null
308
+ -- project_id, which is NOT NULL. Hard deletes are not the model -- see
309
+ -- SEMANTICS.md, actors are soft-deleted -- but the constraint must still be
310
+ -- coherent if one happens.
311
+ FOREIGN KEY (org_id, project_id) REFERENCES org (id, project_id) ON DELETE CASCADE,
312
+ FOREIGN KEY (owner_id, project_id) REFERENCES actor (id, project_id) ON DELETE SET NULL (owner_id),
313
+
314
+ CONSTRAINT file_retention_before_expiry
315
+ CHECK (retain_until IS NULL OR expires_at IS NULL OR retain_until <= expires_at)
316
+ );
317
+
318
+ CREATE TRIGGER file_project
319
+ BEFORE INSERT OR UPDATE OF org_id ON file
320
+ FOR EACH ROW EXECUTE FUNCTION project_from_org();
321
+
322
+ CREATE INDEX file_org_idx ON file (org_id) WHERE deleted_at IS NULL;
323
+ CREATE INDEX file_owner_idx ON file (owner_id) WHERE deleted_at IS NULL;
324
+ CREATE INDEX file_expiry_idx ON file (expires_at) WHERE deleted_at IS NULL AND expires_at IS NOT NULL;
325
+ CREATE UNIQUE INDEX file_storage_key_idx ON file (storage_provider, storage_key);
326
+
327
+ -- -----------------------------------------------------------------------------
328
+ -- GRANTS -- the core of the product
329
+ -- -----------------------------------------------------------------------------
330
+ --
331
+ -- A grant is a first-class, revocable, queryable resource. This is the central
332
+ -- difference from every competitor, all of whom represent sharing as an opaque
333
+ -- signed string that the system cannot subsequently see, list, or revoke.
334
+ --
335
+ -- Because a grant is a row:
336
+ -- - it can be listed ("what have we shared, with whom?")
337
+ -- - it can be revoked (immediately, even for URLs already in the wild)
338
+ -- - it can be capped (download limits, atomically)
339
+ -- - it can be audited (it has an id that appears in every access event)
340
+ -- - it can expire (server-side, not merely encoded in a token)
341
+ -- - it can be DELEGATED, and the delegation is a row too, pointing at its
342
+ -- parent -- which is what makes revocation transitive (P4).
343
+
344
+ -- A GRANT'S SUBJECT IS A PRINCIPAL SET (RFC-001).
345
+ --
346
+ -- The three original subject types were already sets; we simply never said so:
347
+ --
348
+ -- actor exactly one principal
349
+ -- link whoever holds the secret (bearer, not identity)
350
+ -- anonymous everyone
351
+ --
352
+ -- The gap was that nothing sat between "one" and "everyone" except a bearer
353
+ -- token, so "every member of this organization may read this file" had no
354
+ -- representation at all. Two subject types close it:
355
+ --
356
+ -- org every member of `subject_org_id`, at ANY role
357
+ -- role every member of `subject_org_id` at role >= `subject_min_role`
358
+ --
359
+ -- `org` is `role` with the floor at 'viewer'. Both exist because the common
360
+ -- case should not require naming a role.
361
+ --
362
+ -- BREADTH ORDERING (this is what invariant I6, below, attenuates over):
363
+ --
364
+ -- actor < role <= org < anonymous
365
+ -- link -- orthogonal: a bearer credential, not an identity
366
+ --
367
+ -- WHAT THIS DELIBERATELY IS NOT. There are no custom groups, no nested orgs, no
368
+ -- configurable inheritance and no arbitrary permission sets. A group IS an org,
369
+ -- and `subject_min_role` is ONLY a threshold over the existing four-value
370
+ -- `org_role` enum. A general group table is the road to the unauditable
371
+ -- permission model this schema exists to refuse.
372
+ --
373
+ -- RESOLUTION IS A JOIN, NEVER A MATERIALIZATION. There is no fan-out table and
374
+ -- no per-member row. A `role`/`org` grant matches a principal iff that
375
+ -- principal has a live `membership` row in `subject_org_id` at a sufficient
376
+ -- role, evaluated at read time. THAT IS THE POINT: adding or removing a member
377
+ -- changes access on the very next request, with no recomputation and no write
378
+ -- to any grant -- the same property that makes revocation immediate. Fan-out
379
+ -- would make membership eventually-consistent with access, which is the
380
+ -- property we sell.
381
+ CREATE TYPE grant_subject AS ENUM ('actor', 'org', 'role', 'link', 'anonymous');
382
+ CREATE TYPE grant_capability AS ENUM ('read', 'write', 'delete', 'share');
383
+
384
+ CREATE TABLE file_grant (
385
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
386
+
387
+ file_id uuid NOT NULL,
388
+ org_id uuid NOT NULL,
389
+ project_id uuid NOT NULL, -- derived from org, see trigger
390
+
391
+ -- P4, transitive. NULL means "issued directly from an org role", i.e. this
392
+ -- grant is a root of a delegation tree. Non-NULL means the issuer's own
393
+ -- authority was itself a grant, and this grant can never be more than that
394
+ -- grant was: not in capability, not in lifetime, not in download budget.
395
+ parent_grant_id uuid REFERENCES file_grant(id) ON DELETE CASCADE,
396
+
397
+ subject_type grant_subject NOT NULL,
398
+ subject_id uuid, -- when subject_type='actor'
399
+
400
+ -- I1 / P3 / P8, ONE LEVEL UP AGAIN. The org whose members are the subject,
401
+ -- when subject_type is 'org' or 'role'. It is NOT required to equal
402
+ -- `org_id`: a cross-ORG group grant inside one project is exactly the case
403
+ -- this exists for ("the company that posted this job may read this CV").
404
+ -- What is unrepresentable is a cross-PROJECT one, and it is unrepresentable
405
+ -- structurally -- see the composite foreign key below -- not by a WHERE
406
+ -- clause somebody has to remember.
407
+ subject_org_id uuid,
408
+
409
+ -- The role FLOOR, when subject_type='role'. A threshold over the existing
410
+ -- `org_role` enum and nothing else: there are no custom roles, no nested
411
+ -- roles and no configurable inheritance here. 'org' leaves it NULL, which
412
+ -- reads as the floor being 'viewer'.
413
+ subject_min_role org_role,
414
+
415
+ capabilities grant_capability[] NOT NULL,
416
+
417
+ -- Link-type grants: the secret is stored ONLY as a hash. A database dump
418
+ -- does not yield working share links.
419
+ secret_hash text,
420
+
421
+ -- Optional password on top of the link secret (two-factor for a share).
422
+ password_hash text,
423
+
424
+ -- Lifecycle of the grant itself
425
+ expires_at timestamptz,
426
+ max_downloads integer CHECK (max_downloads IS NULL OR max_downloads > 0),
427
+ download_count integer NOT NULL DEFAULT 0,
428
+
429
+ revoked_at timestamptz,
430
+ created_by uuid,
431
+ created_at timestamptz NOT NULL DEFAULT now(),
432
+
433
+ -- P3: cross-tenant grants are structurally unrepresentable.
434
+ FOREIGN KEY (file_id, org_id) REFERENCES file (id, org_id) ON DELETE CASCADE,
435
+
436
+ -- P8: and so are cross-PROJECT grants. Neither the subject of a grant nor
437
+ -- its issuer may be an identity belonging to a different customer
438
+ -- application. `subject_id` and `created_by` are nullable and these are
439
+ -- MATCH SIMPLE foreign keys, so a link or anonymous grant (subject_id NULL)
440
+ -- and a grant issued by the control plane (created_by NULL) are unaffected.
441
+ FOREIGN KEY (org_id, project_id) REFERENCES org (id, project_id) ON DELETE CASCADE,
442
+ FOREIGN KEY (subject_id, project_id) REFERENCES actor (id, project_id) ON DELETE CASCADE,
443
+ FOREIGN KEY (created_by, project_id) REFERENCES actor (id, project_id) ON DELETE SET NULL (created_by),
444
+
445
+ -- I1. THE GROUP SUBJECT IS HELD TO THE SAME STANDARD AS EVERY OTHER
446
+ -- IDENTITY IN THIS TABLE. `project_id` is DERIVED FROM THE FILE'S ORG by
447
+ -- the trigger below and can therefore never be supplied by a writer, so
448
+ -- this composite key says exactly: the subject org must live in the same
449
+ -- PROJECT as the file. A cross-project group grant is unrepresentable --
450
+ -- P8, and the same standard P3 already holds cross-tenant grants to.
451
+ -- Cross-ORG group grants within a project remain legal, which is the point
452
+ -- of the feature.
453
+ FOREIGN KEY (subject_org_id, project_id) REFERENCES org (id, project_id) ON DELETE CASCADE,
454
+
455
+ -- ...and so is delegation ACROSS files. A child grant must concern the same
456
+ -- file as its parent. This is the confused-deputy check for delegation, and
457
+ -- like the tenant one it is a foreign key rather than a rule someone has to
458
+ -- remember: (parent_grant_id, file_id) must be an existing (id, file_id).
459
+ UNIQUE (id, file_id),
460
+ FOREIGN KEY (parent_grant_id, file_id) REFERENCES file_grant (id, file_id),
461
+
462
+ CONSTRAINT grant_no_self_parent CHECK (parent_grant_id IS NULL OR parent_grant_id <> id),
463
+
464
+ -- A grant must have a subject appropriate to its type. Total over the enum:
465
+ -- every subject type appears exactly once and pins EVERY subject column, so
466
+ -- a row carrying, say, both a `subject_id` and a `subject_org_id` -- which
467
+ -- would be a grant with two different meanings depending on which query read
468
+ -- it -- cannot be written at all.
469
+ CONSTRAINT grant_subject_coherent CHECK (
470
+ (subject_type = 'actor' AND subject_id IS NOT NULL AND secret_hash IS NULL
471
+ AND subject_org_id IS NULL AND subject_min_role IS NULL)
472
+ OR (subject_type = 'link' AND subject_id IS NULL AND secret_hash IS NOT NULL
473
+ AND subject_org_id IS NULL AND subject_min_role IS NULL)
474
+ OR (subject_type = 'anonymous' AND subject_id IS NULL AND secret_hash IS NULL
475
+ AND subject_org_id IS NULL AND subject_min_role IS NULL)
476
+ OR (subject_type = 'org' AND subject_id IS NULL AND secret_hash IS NULL
477
+ AND subject_org_id IS NOT NULL AND subject_min_role IS NULL)
478
+ OR (subject_type = 'role' AND subject_id IS NULL AND secret_hash IS NULL
479
+ AND subject_org_id IS NOT NULL AND subject_min_role IS NOT NULL)
480
+ ),
481
+
482
+ -- Capability escalation guard: an anonymous grant may only ever read.
483
+ CONSTRAINT grant_anonymous_read_only CHECK (
484
+ subject_type <> 'anonymous' OR capabilities = ARRAY['read']::grant_capability[]
485
+ ),
486
+
487
+ -- Same rule for link grants. A share link is a bearer credential that
488
+ -- travels through mail clients, chat logs and browser history; anything it
489
+ -- carries beyond `read` turns a disclosure into a destruction. "Share link"
490
+ -- reads as read-only to everyone who will ever use this API, so the schema
491
+ -- now says so. Delegation onward from a link is likewise not possible,
492
+ -- because `share` is not an available capability here: only a named actor
493
+ -- can be trusted to pass authority on, and a named actor is revocable and
494
+ -- attributable.
495
+ CONSTRAINT grant_link_read_only CHECK (
496
+ subject_type <> 'link' OR capabilities = ARRAY['read']::grant_capability[]
497
+ ),
498
+
499
+ CONSTRAINT grant_capabilities_nonempty CHECK (cardinality(capabilities) > 0),
500
+
501
+ CONSTRAINT grant_downloads_within_cap CHECK (
502
+ max_downloads IS NULL OR download_count <= max_downloads
503
+ )
504
+ );
505
+
506
+ CREATE TRIGGER file_grant_project
507
+ BEFORE INSERT OR UPDATE OF org_id ON file_grant
508
+ FOR EACH ROW EXECUTE FUNCTION project_from_org();
509
+
510
+ CREATE INDEX grant_file_idx ON file_grant (file_id) WHERE revoked_at IS NULL;
511
+ CREATE INDEX grant_subject_idx ON file_grant (subject_id) WHERE revoked_at IS NULL AND subject_id IS NOT NULL;
512
+ CREATE INDEX grant_secret_idx ON file_grant (secret_hash) WHERE revoked_at IS NULL AND secret_hash IS NOT NULL;
513
+ CREATE INDEX grant_org_idx ON file_grant (org_id);
514
+ CREATE INDEX grant_parent_idx ON file_grant (parent_grant_id) WHERE parent_grant_id IS NOT NULL;
515
+ -- The group-grant path: (file, subject org) is what the membership join keys on,
516
+ -- and it is what keeps a group grant one index probe rather than a scan.
517
+ CREATE INDEX grant_subject_org_idx ON file_grant (file_id, subject_org_id)
518
+ WHERE revoked_at IS NULL AND subject_org_id IS NOT NULL;
519
+
520
+ -- -----------------------------------------------------------------------------
521
+ -- GRANT LINEAGE AND LIVENESS (P4, transitive)
522
+ -- -----------------------------------------------------------------------------
523
+ --
524
+ -- The delegation chain, root last. Walks UP from a grant through its parents.
525
+ -- Depth is bounded by GRANT_MAX_DEPTH below and by the attenuation trigger, so
526
+ -- this terminates even if a cycle were somehow written.
527
+
528
+ CREATE FUNCTION grant_ancestry(p_grant_id uuid)
529
+ RETURNS TABLE (id uuid, depth integer)
530
+ LANGUAGE sql STABLE AS $$
531
+ WITH RECURSIVE chain AS (
532
+ SELECT g.id, g.parent_grant_id, 1 AS depth
533
+ FROM file_grant g
534
+ WHERE g.id = p_grant_id
535
+ UNION ALL
536
+ SELECT p.id, p.parent_grant_id, c.depth + 1
537
+ FROM chain c
538
+ JOIN file_grant p ON p.id = c.parent_grant_id
539
+ WHERE c.depth < 64
540
+ )
541
+ SELECT chain.id, chain.depth FROM chain;
542
+ $$;
543
+
544
+ -- -----------------------------------------------------------------------------
545
+ -- GRANT SCOPE LIVENESS (P7) -- what a soft delete means
546
+ -- -----------------------------------------------------------------------------
547
+ --
548
+ -- THE DEFECT. `getMembership()` joined `org` on `deleted_at IS NULL`, so
549
+ -- soft-deleting a tenant removed every membership-derived access. Nothing
550
+ -- touched grants. So revoking an organization removed the OWNER's access and
551
+ -- left the CONTRACTOR's share links serving bytes, indefinitely. Nobody chose
552
+ -- that; it was an emergent asymmetry between two store methods, and it was a
553
+ -- silent ambiguity in the one property we sell hardest.
554
+ --
555
+ -- THE DECISION. A grant is live only while its SCOPE exists. Scope is the
556
+ -- transitive containment of the grant: its file, that file's org, that org's
557
+ -- project -- and the identities the grant is about: its subject and its issuer.
558
+ -- Delete any of them and every grant in that scope is dead on the next request,
559
+ -- at any delegation depth, with no cascading write.
560
+ --
561
+ -- project deleted -> every grant in every org of that project dies
562
+ -- org deleted -> every grant on every file of that org dies
563
+ -- file deleted -> every grant on that file dies
564
+ -- subject deleted -> every grant TO that actor dies
565
+ -- SUBJECT ORG deleted -> every group grant naming that org dies
566
+ -- issuer deleted -> every grant THAT actor minted dies
567
+ --
568
+ -- WHY EACH, BRIEFLY (the long form is in SEMANTICS.md):
569
+ --
570
+ -- * ORG. A tenant whose deletion leaves its share links working is not
571
+ -- deleted. This case is indefensible on its face: "we
572
+ -- offboarded that customer" must mean the URLs stop.
573
+ -- * SUBJECT. Offboarding a person must end that person's access. This is the
574
+ -- least surprising rule in the file and it did not exist.
575
+ -- * SUBJECT ORG (I2, RFC-001). A group grant's subject is "the members of org
576
+ -- O". Delete O and that set is not empty, it is GONE -- there is no longer a
577
+ -- tenant whose members the grant could mean. The rule already says a deleted
578
+ -- org kills the grants ON its files; symmetry (and the plain reading of "we
579
+ -- offboarded that customer") requires it to kill the grants HELD BY its
580
+ -- members too. Without this term, soft-deleting a partner org would leave
581
+ -- its former members reading the other tenant's documents, which is the
582
+ -- org-deletion defect P7 exists to close, re-opened on a new axis.
583
+ -- * ISSUER. This is the one that is a judgement call, and the call is P4:
584
+ -- "a signed URL may never outlive the permission that created it". A root
585
+ -- grant is minted from the issuer's org-role authority. Delete the identity
586
+ -- and that authority is gone, so the grant must go with it. The alternative
587
+ -- -- links outliving the person who made them -- is the "the intern left
588
+ -- two years ago and their Dropbox link still works" failure, which is the
589
+ -- failure this product exists to remove. Note the deliberate asymmetry with
590
+ -- REMOVING A MEMBERSHIP, which does NOT kill issued grants: membership
591
+ -- removal is a role change inside a living tenant (duties get transferred),
592
+ -- identity deletion is erasure. See SEMANTICS.md.
593
+ --
594
+ -- WHY IT IS HERE AND NOT IN authz.ts. Exactly the reason transitive liveness is
595
+ -- here: a rule that lives in application code binds only the application. This
596
+ -- one predicate is what `live_grant`, `consume_download` and the attenuation
597
+ -- trigger all read, so there is no query that can opt out of it.
598
+ --
599
+ -- REVERSIBLE, BY CONSTRUCTION. Nothing is written when a scope is deleted, so
600
+ -- clearing `deleted_at` restores exactly the grants that were live before and
601
+ -- no others. Undelete is free precisely because delete was derived. That is
602
+ -- also why soft delete cannot be used to defeat RETENTION: it changes no row a
603
+ -- retention hold protects, and `retain_until` still blocks the hard delete.
604
+ --
605
+ -- NOT INCLUDED, DELIBERATELY: file EXPIRY. Expiry is a time gate, it is
606
+ -- extendable, and it is already evaluated by `lifecycleDenial()` on every
607
+ -- access and by the listing predicate. Scope liveness is about EXISTENCE. Two
608
+ -- different questions kept as two different mechanisms.
609
+ CREATE FUNCTION grant_scope_is_live(
610
+ p_file_id uuid,
611
+ p_subject_id uuid,
612
+ p_created_by uuid,
613
+ p_subject_org_id uuid DEFAULT NULL
614
+ )
615
+ RETURNS boolean
616
+ LANGUAGE sql STABLE AS $$
617
+ SELECT EXISTS (
618
+ SELECT 1
619
+ FROM file f
620
+ JOIN org o ON o.id = f.org_id
621
+ JOIN project pr ON pr.id = o.project_id
622
+ WHERE f.id = p_file_id
623
+ AND f.deleted_at IS NULL
624
+ AND f.state <> 'deleted'
625
+ AND o.deleted_at IS NULL
626
+ AND pr.deleted_at IS NULL
627
+ )
628
+ AND NOT EXISTS (
629
+ SELECT 1 FROM actor a WHERE a.id = p_subject_id AND a.deleted_at IS NOT NULL
630
+ )
631
+ AND NOT EXISTS (
632
+ SELECT 1 FROM actor a WHERE a.id = p_created_by AND a.deleted_at IS NOT NULL
633
+ )
634
+ -- I2: the SUBJECT ORG of a group grant. Written as NOT EXISTS(deleted),
635
+ -- exactly like the two actor terms above, so a NULL argument -- every
636
+ -- actor, link and anonymous grant -- passes without touching `org`. The
637
+ -- project half is already covered: the composite FK pins the subject org
638
+ -- to the file's project, and the file's project is checked in the first
639
+ -- term. So this term is exactly "is that tenant still there".
640
+ --
641
+ -- MEASURED COST, because this runs for EVERY grant on EVERY liveness
642
+ -- check and most grants are not group grants: +6.5% on an
643
+ -- eight-deep actor-grant lookup (0.848ms -> 0.902ms, PGlite, 400
644
+ -- samples, same database, term removed vs present). An
645
+ -- `p_subject_org_id IS NULL OR ...` short-circuit was tried and measured
646
+ -- SLOWER (0.913ms) -- the planner already resolves the NULL probe
647
+ -- without a scan -- so the simpler form is the one that ships. See the
648
+ -- measurement section of the RFC-001 report.
649
+ AND NOT EXISTS (
650
+ SELECT 1 FROM org g WHERE g.id = p_subject_org_id AND g.deleted_at IS NOT NULL
651
+ );
652
+ $$;
653
+
654
+ -- A grant is LIVE iff it, and every one of its ancestors, is unrevoked,
655
+ -- unexpired, under its download cap, AND within a scope that still exists.
656
+ --
657
+ -- WHY A FUNCTION AND NOT A TOP-DOWN RECURSIVE VIEW:
658
+ -- the obvious formulation is a view that recurses downward from live roots.
659
+ -- It is correct and it is also unusable: Postgres cannot push a predicate into
660
+ -- a recursive CTE, so `SELECT * FROM live_grant WHERE file_id = $1` would walk
661
+ -- every delegation tree in the entire database on every authorization check.
662
+ -- Packaging the recursion as a per-grant function keeps the predicate exactly
663
+ -- once (this function IS the definition; the view below is written in terms of
664
+ -- it) while letting the planner use grant_file_idx / grant_secret_idx first and
665
+ -- evaluate liveness only for the handful of rows that survive. COST 100 tells
666
+ -- the planner to order it last among the quals.
667
+ --
668
+ -- FAILS CLOSED: an unknown id, a chain longer than GRANT_MAX_DEPTH, or a cycle
669
+ -- all return false.
670
+ CREATE FUNCTION grant_is_live(p_grant_id uuid)
671
+ RETURNS boolean
672
+ LANGUAGE sql STABLE COST 100 AS $$
673
+ WITH RECURSIVE chain AS (
674
+ SELECT g.id,
675
+ g.parent_grant_id,
676
+ 1 AS depth,
677
+ ( g.revoked_at IS NULL
678
+ AND (g.expires_at IS NULL OR g.expires_at > now())
679
+ AND (g.max_downloads IS NULL OR g.download_count < g.max_downloads)
680
+ AND grant_scope_is_live(g.file_id, g.subject_id, g.created_by, g.subject_org_id)
681
+ ) AS self_live
682
+ FROM file_grant g
683
+ WHERE g.id = p_grant_id
684
+ UNION ALL
685
+ SELECT p.id,
686
+ p.parent_grant_id,
687
+ c.depth + 1,
688
+ ( p.revoked_at IS NULL
689
+ AND (p.expires_at IS NULL OR p.expires_at > now())
690
+ AND (p.max_downloads IS NULL OR p.download_count < p.max_downloads)
691
+ -- Scope is checked for every ANCESTOR too, not just the leaf.
692
+ -- Delegation is same-file by foreign key, so the file/org/
693
+ -- project half is redundant here -- but the SUBJECT and ISSUER
694
+ -- halves are not: deleting the person a parent grant was issued
695
+ -- to, or the person who minted it, must kill everything
696
+ -- delegated below it. That is P7 obeying P4 transitively.
697
+ AND grant_scope_is_live(p.file_id, p.subject_id, p.created_by, p.subject_org_id)
698
+ )
699
+ FROM chain c
700
+ JOIN file_grant p ON p.id = c.parent_grant_id
701
+ WHERE c.depth < 64
702
+ AND c.self_live -- stop as soon as an ancestor is dead
703
+ )
704
+ SELECT coalesce(bool_and(chain.self_live), false)
705
+ -- If we stopped because of the depth bound rather than because we
706
+ -- ran out of ancestors, we have not proven liveness. Deny.
707
+ AND NOT EXISTS (
708
+ SELECT 1 FROM chain d
709
+ WHERE d.depth >= 64 AND d.parent_grant_id IS NOT NULL
710
+ )
711
+ FROM chain;
712
+ $$;
713
+
714
+ -- The liveness predicate, expressed once, so no caller can get it subtly wrong.
715
+ -- Every grant lookup in store.ts reads through this view.
716
+ CREATE VIEW live_grant AS
717
+ SELECT * FROM file_grant WHERE grant_is_live(id);
718
+
719
+ -- An INDEPENDENT, top-down formulation of the same property, used only by the
720
+ -- test suite as a cross-check on `grant_is_live`. Two implementations that must
721
+ -- agree on every row is a much stronger statement than one implementation
722
+ -- agreeing with itself. Not used on any hot path -- see the comment above for
723
+ -- why it must not be.
724
+ CREATE VIEW live_grant_recursive AS
725
+ WITH RECURSIVE live AS (
726
+ SELECT g.*
727
+ FROM file_grant g
728
+ WHERE g.parent_grant_id IS NULL
729
+ AND g.revoked_at IS NULL
730
+ AND (g.expires_at IS NULL OR g.expires_at > now())
731
+ AND (g.max_downloads IS NULL OR g.download_count < g.max_downloads)
732
+ AND grant_scope_is_live(g.file_id, g.subject_id, g.created_by, g.subject_org_id)
733
+ UNION ALL
734
+ SELECT c.*
735
+ FROM live p
736
+ JOIN file_grant c ON c.parent_grant_id = p.id
737
+ WHERE c.revoked_at IS NULL
738
+ AND (c.expires_at IS NULL OR c.expires_at > now())
739
+ AND (c.max_downloads IS NULL OR c.download_count < c.max_downloads)
740
+ AND grant_scope_is_live(c.file_id, c.subject_id, c.created_by, c.subject_org_id)
741
+ )
742
+ SELECT * FROM live;
743
+
744
+ -- -----------------------------------------------------------------------------
745
+ -- ATTENUATION -- enforced by trigger, and why it must be
746
+ -- -----------------------------------------------------------------------------
747
+ --
748
+ -- The rule: a delegated grant may never exceed its parent in ANY dimension --
749
+ -- capability set, expiry, or remaining download budget.
750
+ --
751
+ -- WHY NOT A CHECK CONSTRAINT: a CHECK may only reference columns of the row
752
+ -- being written. This invariant is inter-row (child vs parent), and Postgres
753
+ -- rejects subqueries in CHECK. So a CHECK is not merely weaker here, it is
754
+ -- impossible to express.
755
+ --
756
+ -- WHY NOT APPLICATION CODE: it was application code (`filelayer.share()`), and
757
+ -- that is precisely the defect -- any second writer, including
758
+ -- a psql session, a migration, an admin tool or a future endpoint, bypasses it.
759
+ --
760
+ -- A BEFORE ROW trigger is therefore the strongest enforcement Postgres offers
761
+ -- for this shape of invariant: it binds every writer, it runs inside the same
762
+ -- transaction as the INSERT, and it can normalise as well as reject.
763
+ --
764
+ -- Capabilities are REJECTED when they exceed the parent (silently narrowing an
765
+ -- authority someone asked for would hide a bug). Lifetime and budget are
766
+ -- CLAMPED to the parent's, because "inherit the parent's expiry" is the correct
767
+ -- and expected behaviour for a delegation with no expiry of its own -- and the
768
+ -- API returns the effective values, so the clamp is visible rather than silent.
769
+ --
770
+ -- -----------------------------------------------------------------------------
771
+ -- I6 -- SUBJECT BREADTH MAY NOT BE AMPLIFIED BY DELEGATION (RFC-001)
772
+ -- -----------------------------------------------------------------------------
773
+ --
774
+ -- THE RULE:
775
+ --
776
+ -- An issuer whose authority is ROLE-DERIVED (admin/owner of the file's org,
777
+ -- or the file's owner) may create ANY subject type.
778
+ --
779
+ -- An issuer whose authority is GRANT-DERIVED may delegate only to `actor`
780
+ -- or `link`. Never `org`, never `role`, never `anonymous`.
781
+ --
782
+ -- WHY. Capability attenuation already prevents a delegate from DOING MORE than
783
+ -- the authority they were handed. It says nothing at all about REACHING MORE
784
+ -- PEOPLE. Without I6, a contractor holding a single `{read, share}` grant --
785
+ -- the narrowest useful authority we issue -- could re-grant to an entire
786
+ -- organization, or to `anonymous`, and every capability check would still pass
787
+ -- because the child's capability set is a subset of the parent's. One
788
+ -- consultant's read access becomes a public link, and attenuation is satisfied
789
+ -- the whole way. Both dimensions have to be attenuated or neither is.
790
+ --
791
+ -- WHY IT IS ENFORCED HERE, IN THE KERNEL, AND NOT ONLY IN authz.ts. Precisely
792
+ -- the argument that put capability attenuation in this trigger: a rule that
793
+ -- lives in application code binds only the application. A psql session, a
794
+ -- migration, an admin tool or a future endpoint that writes `file_grant`
795
+ -- directly would otherwise bypass I6 completely. `parent_grant_id IS NOT NULL`
796
+ -- IS the definition of "this authority was grant-derived" -- it is the column
797
+ -- the engine sets from `decision.grantId`, and it is the same column the whole
798
+ -- of P4 is already built on -- so the invariant is expressible against the row
799
+ -- being written and needs no knowledge of the caller.
800
+ --
801
+ -- WHY NOT A CHECK CONSTRAINT: it could be one, since it only reads columns of
802
+ -- NEW. It lives in the trigger anyway so that the two halves of attenuation --
803
+ -- what you may DO and who you may REACH -- are stated, and refused, in one
804
+ -- place, with one vocabulary of `grant_*` refusal names that `schemaRefusal()`
805
+ -- in authz.ts already mirrors.
806
+
807
+ CREATE FUNCTION file_grant_attenuate() RETURNS trigger
808
+ LANGUAGE plpgsql AS $$
809
+ DECLARE
810
+ p file_grant%ROWTYPE;
811
+ v_remain integer;
812
+ v_depth integer;
813
+ BEGIN
814
+ -- Re-parenting is how a cycle would be created, and a cycle in a liveness
815
+ -- graph is a denial-of-service at best. Lineage is immutable.
816
+ IF TG_OP = 'UPDATE' AND NEW.parent_grant_id IS DISTINCT FROM OLD.parent_grant_id THEN
817
+ RAISE EXCEPTION 'grant_lineage_immutable: parent_grant_id cannot be changed';
818
+ END IF;
819
+
820
+ IF NEW.parent_grant_id IS NULL THEN
821
+ RETURN NEW;
822
+ END IF;
823
+
824
+ -- I6. SUBJECT BREADTH. Checked FIRST, and before the parent is even read,
825
+ -- because it is the one attenuation dimension that does not depend on the
826
+ -- parent's contents: grant-derived authority may name a person or mint a
827
+ -- bearer link, and nothing wider, whatever the parent happens to hold.
828
+ IF NEW.subject_type NOT IN ('actor', 'link') THEN
829
+ RAISE EXCEPTION
830
+ 'grant_subject_amplification: a delegated grant may only name an actor or a link, not % (I6)',
831
+ NEW.subject_type;
832
+ END IF;
833
+
834
+ SELECT * INTO p FROM file_grant WHERE id = NEW.parent_grant_id;
835
+ IF NOT FOUND THEN
836
+ RAISE EXCEPTION 'grant_parent_missing: parent grant % does not exist', NEW.parent_grant_id;
837
+ END IF;
838
+
839
+ -- Delegation depth is bounded so that liveness evaluation is bounded.
840
+ SELECT count(*) INTO v_depth FROM grant_ancestry(NEW.parent_grant_id);
841
+ IF v_depth >= 32 THEN
842
+ RAISE EXCEPTION 'grant_delegation_too_deep: delegation chain would exceed 32';
843
+ END IF;
844
+
845
+ -- You cannot delegate an authority you no longer have. (Belt; the recursive
846
+ -- liveness check is the braces -- a child minted in a race with the
847
+ -- parent's revocation is born dead rather than orphaned.)
848
+ IF NOT grant_is_live(p.id) THEN
849
+ RAISE EXCEPTION 'grant_parent_not_live: cannot delegate from a dead grant';
850
+ END IF;
851
+
852
+ -- Capability attenuation. The child's set must be a subset.
853
+ IF NOT (NEW.capabilities <@ p.capabilities) THEN
854
+ RAISE EXCEPTION 'grant_capability_amplification: % exceeds parent %',
855
+ NEW.capabilities, p.capabilities;
856
+ END IF;
857
+
858
+ -- Lifetime attenuation. A child may be shorter-lived, never longer.
859
+ IF p.expires_at IS NOT NULL
860
+ AND (NEW.expires_at IS NULL OR NEW.expires_at > p.expires_at) THEN
861
+ NEW.expires_at := p.expires_at;
862
+ END IF;
863
+
864
+ -- Budget attenuation, against the parent's REMAINING budget.
865
+ IF p.max_downloads IS NOT NULL THEN
866
+ v_remain := p.max_downloads - p.download_count;
867
+ IF v_remain <= 0 THEN
868
+ RAISE EXCEPTION 'grant_parent_exhausted: parent has no downloads left to delegate';
869
+ END IF;
870
+ IF NEW.max_downloads IS NULL OR NEW.max_downloads > v_remain THEN
871
+ NEW.max_downloads := v_remain;
872
+ END IF;
873
+ END IF;
874
+
875
+ RETURN NEW;
876
+ END;
877
+ $$;
878
+
879
+ -- `UPDATE OF` matters: this must NOT fire for `consume_download` (which touches
880
+ -- only download_count) or for revocation (revoked_at), both of which can make a
881
+ -- parent legitimately non-live while its children are being updated.
882
+ --
883
+ -- The subject columns are in the list for I6. Attenuation that binds only at
884
+ -- INSERT is attenuation a second statement walks around: without them,
885
+ -- `UPDATE file_grant SET subject_type='org', subject_org_id=... WHERE id=<child>`
886
+ -- would widen a delegated grant from one person to an entire organization and
887
+ -- never touch this function.
888
+ CREATE TRIGGER file_grant_attenuation
889
+ BEFORE INSERT OR UPDATE OF parent_grant_id, capabilities, expires_at, max_downloads,
890
+ subject_type, subject_id, subject_org_id, subject_min_role
891
+ ON file_grant
892
+ FOR EACH ROW EXECUTE FUNCTION file_grant_attenuate();
893
+
894
+ -- -----------------------------------------------------------------------------
895
+ -- ATOMIC DOWNLOAD RESERVATION (P6)
896
+ -- -----------------------------------------------------------------------------
897
+ -- The reservation is the write. There is no window between checking and
898
+ -- consuming, so N concurrent requests against max_downloads=1 yield exactly 1
899
+ -- success.
900
+ --
901
+ -- A download now consumes the budget of the ENTIRE ancestor chain. Without
902
+ -- that, "a child's cap may not exceed the parent's remaining budget" is true
903
+ -- only at the instant of creation: mint three children from a parent with 5
904
+ -- downloads left and you have sold 15. Charging every ancestor makes the
905
+ -- parent's cap a real budget over the whole delegation tree, and it makes
906
+ -- exhaustion propagate downward for free through `grant_is_live`.
907
+ --
908
+ -- Rows are locked in id order, which is what makes concurrent redemptions of
909
+ -- two siblings deadlock-free.
910
+ --
911
+ -- This ALWAYS returns exactly one row. Denial is `(false, 0)`, not the
912
+ -- absence of a row, so the plausible caller mistake `rows[0]?.granted ?? true`
913
+ -- can no longer fail open.
914
+ CREATE FUNCTION consume_download(p_grant_id uuid)
915
+ RETURNS TABLE (granted boolean, remaining integer)
916
+ LANGUAGE plpgsql AS $$
917
+ DECLARE
918
+ v_ids uuid[];
919
+ v_id uuid;
920
+ v_ok boolean;
921
+ v_remaining integer;
922
+ BEGIN
923
+ SELECT array_agg(a.id ORDER BY a.id) INTO v_ids FROM grant_ancestry(p_grant_id) a;
924
+ IF v_ids IS NULL THEN
925
+ RETURN QUERY SELECT false, 0;
926
+ RETURN;
927
+ END IF;
928
+
929
+ -- Deterministic lock order over the whole chain.
930
+ FOREACH v_id IN ARRAY v_ids LOOP
931
+ PERFORM 1 FROM file_grant g WHERE g.id = v_id FOR UPDATE;
932
+ END LOOP;
933
+
934
+ -- The same four dimensions `grant_is_live` uses, re-evaluated here UNDER THE
935
+ -- ROW LOCKS rather than trusted from the caller's earlier check. Scope
936
+ -- liveness (P7) adds
937
+ -- the fourth: a grant whose org, project, file, subject or issuer has been
938
+ -- deleted may not spend a download, even in the window between the
939
+ -- authorization decision and the reservation.
940
+ SELECT bool_and(
941
+ g.revoked_at IS NULL
942
+ AND (g.expires_at IS NULL OR g.expires_at > now())
943
+ AND (g.max_downloads IS NULL OR g.download_count < g.max_downloads)
944
+ AND grant_scope_is_live(g.file_id, g.subject_id, g.created_by, g.subject_org_id)
945
+ )
946
+ INTO v_ok
947
+ FROM file_grant g
948
+ WHERE g.id = ANY (v_ids);
949
+
950
+ IF NOT coalesce(v_ok, false) THEN
951
+ RETURN QUERY SELECT false, 0;
952
+ RETURN;
953
+ END IF;
954
+
955
+ UPDATE file_grant g
956
+ SET download_count = g.download_count + 1
957
+ WHERE g.id = ANY (v_ids);
958
+
959
+ -- The binding constraint is the tightest remaining budget in the chain.
960
+ SELECT min(g.max_downloads - g.download_count)
961
+ INTO v_remaining
962
+ FROM file_grant g
963
+ WHERE g.id = ANY (v_ids) AND g.max_downloads IS NOT NULL;
964
+
965
+ RETURN QUERY SELECT true, v_remaining;
966
+ END;
967
+ $$;
968
+
969
+ -- -----------------------------------------------------------------------------
970
+ -- AUDIT (P5)
971
+ -- -----------------------------------------------------------------------------
972
+ -- Append-only. Records denials as well as successes. Hash-chained per org so
973
+ -- that deletion or alteration of history is detectable -- this is what makes
974
+ -- the log a compliance artifact rather than a convenience.
975
+ --
976
+ -- org_id IS NULLABLE, and that is a security feature, not laxity. A probe
977
+ -- against a file id that does not exist, or a sweep against link secrets, has
978
+ -- no tenant to charge it to. Guessing one would leak existence; dropping the
979
+ -- event, which is what we did before, made the single most characteristic
980
+ -- reconnaissance pattern against an object store completely invisible.
981
+ -- Such events go to the SYSTEM chain (org_id IS NULL), which is chained
982
+ -- and verifiable like any other and which no tenant-facing API can read.
983
+
984
+ CREATE TABLE audit_event (
985
+ id bigserial PRIMARY KEY,
986
+ org_id uuid REFERENCES org(id) ON DELETE CASCADE, -- NULL = system chain
987
+
988
+ occurred_at timestamptz NOT NULL DEFAULT now(),
989
+ action text NOT NULL, -- file.read, grant.revoke, member.add, ...
990
+ decision text NOT NULL CHECK (decision IN ('allow', 'deny')),
991
+ reason text, -- why denied; null when allowed
992
+
993
+ -- These three carry NO foreign key, and that is deliberate.
994
+ --
995
+ -- `actor_id` used to be `REFERENCES actor(id) ON DELETE SET NULL`, and it
996
+ -- was a serious defect in two directions at once:
997
+ --
998
+ -- (a) A caller presenting a WELL-FORMED BUT UNREGISTERED actor id could
999
+ -- not be audited at all: the INSERT raised a foreign-key violation,
1000
+ -- the exception propagated out of `authorize()`, the denial was never
1001
+ -- recorded, and the caller received a 500 instead of the uniform 404.
1002
+ -- A registered actor with no access got 404 and an unregistered one
1003
+ -- got 500, so the error surface was an ACTOR-EXISTENCE ORACLE -- the
1004
+ -- existence oracle reopened on a different axis -- and an actor-id
1005
+ -- sweep left no trace, which is exactly the invisible-enumeration
1006
+ -- defect this table exists to prevent.
1007
+ -- (b) `ON DELETE SET NULL` erased attribution from history when an actor
1008
+ -- was deleted. An audit log that forgets who did something is not an
1009
+ -- audit log; deleting a user must not rewrite what they did.
1010
+ --
1011
+ -- `file_id` and `grant_id` were already FK-free for reason (a). The audit
1012
+ -- log must be able to record an identifier that does not exist, because
1013
+ -- recording probes at identifiers that do not exist is its job.
1014
+ actor_id uuid,
1015
+ file_id uuid,
1016
+ grant_id uuid,
1017
+
1018
+ ip inet,
1019
+ user_agent text,
1020
+ context jsonb NOT NULL DEFAULT '{}'::jsonb,
1021
+
1022
+ -- Tamper evidence: each event chains to the previous event in its org.
1023
+ -- The chain now commits to every forensically relevant column, not
1024
+ -- just the seven that used to be covered.
1025
+ prev_hash text,
1026
+ hash text NOT NULL
1027
+ );
1028
+
1029
+ CREATE INDEX audit_org_time_idx ON audit_event (org_id, occurred_at DESC);
1030
+ CREATE INDEX audit_file_idx ON audit_event (file_id) WHERE file_id IS NOT NULL;
1031
+ CREATE INDEX audit_actor_idx ON audit_event (actor_id) WHERE actor_id IS NOT NULL;
1032
+ CREATE INDEX audit_deny_idx ON audit_event (org_id, occurred_at DESC) WHERE decision = 'deny';
1033
+ CREATE INDEX audit_system_idx ON audit_event (occurred_at DESC) WHERE org_id IS NULL;
1034
+
1035
+ -- Append-only enforcement. Audit rows cannot be updated or deleted through
1036
+ -- normal privileges; retention trimming is a separate privileged path.
1037
+ CREATE RULE audit_no_update AS ON UPDATE TO audit_event DO INSTEAD NOTHING;
1038
+ CREATE RULE audit_no_delete AS ON DELETE TO audit_event DO INSTEAD NOTHING;
1039
+
1040
+ -- -----------------------------------------------------------------------------
1041
+ -- THE CHAIN CANNOT FORK
1042
+ -- -----------------------------------------------------------------------------
1043
+ --
1044
+ -- THE DEFECT. The chain was appended by a SELECT (read the last hash) followed
1045
+ -- by an INSERT, in application code, in two separate statements. That is atomic
1046
+ -- only if there is exactly one writer. With two, both read the same `prev_hash`
1047
+ -- and both insert: the chain FORKS. `verifyAuditChain` then reports
1048
+ -- `prev_hash_mismatch` at the second of the two -- so a perfectly honest log
1049
+ -- looks tampered with, and, worse, a genuinely tampered log is no longer
1050
+ -- distinguishable from routine concurrency. Tamper evidence that cries wolf is
1051
+ -- not tamper evidence.
1052
+ --
1053
+ -- It was documented as a single-writer limitation. A hosted deployment WILL
1054
+ -- have concurrent writers -- one per API process, many per box -- so the
1055
+ -- limitation is now a defect.
1056
+ --
1057
+ -- THE FIX, AND WHY IT IS ONE STATEMENT. `pg_advisory_xact_lock` is held until
1058
+ -- the end of the TRANSACTION. In autocommit -- which is how the store issues
1059
+ -- queries, and how `pg.Pool.query` behaves -- each statement is its own
1060
+ -- transaction, so taking the lock in one statement and inserting in the next
1061
+ -- releases it in between and buys nothing at all. The lock, the read of the
1062
+ -- predecessor, the hash and the insert must therefore be ONE statement. They
1063
+ -- are: this function. `SELECT * FROM audit_append(...)` is a single statement,
1064
+ -- so the lock is held from the moment it is taken until the row is committed,
1065
+ -- and a second writer on the same chain waits.
1066
+ --
1067
+ -- It also means the chain construction is no longer something a caller can get
1068
+ -- wrong or skip: there is no supported path that writes `audit_event` without
1069
+ -- coming through here.
1070
+ --
1071
+ -- WHY THE HASH IS COMPUTED IN SQL, AND WHY THAT IS NOT A SECOND IMPLEMENTATION.
1072
+ -- The digest input is `JSON.stringify([prevHash, orgId, ...])`, and `prevHash`
1073
+ -- is the FIRST element -- deliberately, so that everything AFTER it can be
1074
+ -- serialized by the caller and passed in as `p_hash_tail`. This function only
1075
+ -- prepends the predecessor it just read under the lock. So the canonical
1076
+ -- encoding still exists in exactly one place (store.ts, `auditHashTail`), and
1077
+ -- this function performs one string concatenation, not a re-implementation.
1078
+ -- `verifyAuditChain` recomputes the whole digest in TypeScript on read, so
1079
+ -- every write is cross-checked against an independent implementation by every
1080
+ -- test that verifies a chain.
1081
+ --
1082
+ -- WHAT PGlite CANNOT PROVE: PGlite has ONE backend. Two transactions cannot
1083
+ -- exist at the same instant, so no test in this repository can demonstrate lock
1084
+ -- contention, a waiting writer, or a fork prevented. What the tests DO prove is
1085
+ -- stated in test/semantics.test.ts: that the lock is taken on the write
1086
+ -- path, that the key is per-chain, that the SQL digest equals the TypeScript
1087
+ -- digest, and -- by simulating the fork the old code would have produced -- that
1088
+ -- a forked chain is detected. Proving serialization requires a real
1089
+ -- multi-connection Postgres and belongs in the deployment test suite.
1090
+
1091
+ -- One lock namespace, so that Filelayer's chain lock cannot collide with an
1092
+ -- advisory lock taken by anything else sharing the database. The system chain
1093
+ -- (org_id IS NULL) is its own chain and gets its own key.
1094
+ CREATE FUNCTION audit_chain_lock_key(p_org_id uuid) RETURNS bigint
1095
+ LANGUAGE sql IMMUTABLE AS $$
1096
+ SELECT (hashtext('filelayer.audit_chain')::bigint << 32)
1097
+ | (hashtext(coalesce(p_org_id::text, '__system__'))::bigint & x'ffffffff'::bigint);
1098
+ $$;
1099
+
1100
+ CREATE FUNCTION audit_append(
1101
+ p_org_id uuid,
1102
+ p_occurred_at timestamptz,
1103
+ p_action text,
1104
+ p_decision text,
1105
+ p_reason text,
1106
+ p_actor_id uuid,
1107
+ p_file_id uuid,
1108
+ p_grant_id uuid,
1109
+ p_ip inet,
1110
+ p_user_agent text,
1111
+ p_context jsonb,
1112
+ p_hash_tail text -- JSON.stringify([...everything after prev_hash])
1113
+ )
1114
+ RETURNS TABLE (id bigint, prev_hash text, hash text)
1115
+ LANGUAGE plpgsql AS $$
1116
+ DECLARE
1117
+ v_prev text;
1118
+ v_hash text;
1119
+ BEGIN
1120
+ -- Serializes every writer on this chain for the rest of the transaction.
1121
+ -- Because this function is invoked as one statement, "the rest of the
1122
+ -- transaction" is "until this row is committed".
1123
+ PERFORM pg_advisory_xact_lock(audit_chain_lock_key(p_org_id));
1124
+
1125
+ SELECT a.hash INTO v_prev
1126
+ FROM audit_event a
1127
+ WHERE a.org_id IS NOT DISTINCT FROM p_org_id
1128
+ ORDER BY a.id DESC
1129
+ LIMIT 1;
1130
+
1131
+ v_hash := encode(
1132
+ digest(
1133
+ convert_to(
1134
+ '[' || coalesce(to_json(v_prev)::text, 'null') || ',' || p_hash_tail,
1135
+ 'utf8'),
1136
+ 'sha256'),
1137
+ 'hex');
1138
+
1139
+ RETURN QUERY
1140
+ INSERT INTO audit_event
1141
+ (org_id, occurred_at, action, decision, reason, actor_id, file_id,
1142
+ grant_id, ip, user_agent, context, prev_hash, hash)
1143
+ VALUES
1144
+ (p_org_id, p_occurred_at, p_action, p_decision, p_reason, p_actor_id,
1145
+ p_file_id, p_grant_id, p_ip, p_user_agent, p_context, v_prev, v_hash)
1146
+ RETURNING audit_event.id, audit_event.prev_hash, audit_event.hash;
1147
+ END;
1148
+ $$;
1149
+
1150
+ -- -----------------------------------------------------------------------------
1151
+ -- METERING
1152
+ -- -----------------------------------------------------------------------------
1153
+ -- Two per-tenant, per-day rollups an operator needs and cannot reconstruct
1154
+ -- after the fact, because neither is derivable from the object store and the
1155
+ -- audit trail is an append-only record of decisions rather than a counter.
1156
+ --
1157
+ -- The work this system does is dominated by the authorization decision, not by
1158
+ -- the bytes. Every read, every share redemption and every listing walks
1159
+ -- membership, roles and grants; that walk scales with the number of distinct
1160
+ -- people who hold files, not with how much they hold. A deployment that only
1161
+ -- watches stored bytes therefore has no signal for the load it is carrying and
1162
+ -- no way to answer "which tenant is driving this?" when latency moves.
1163
+ --
1164
+ -- These counters are written best-effort and outside the delivery transaction:
1165
+ -- a metering failure must never fail a request that was already authorized and
1166
+ -- audited. Treat them as a capacity and attribution signal. The audit trail,
1167
+ -- not this, is the record of what happened.
1168
+
1169
+ CREATE TABLE usage_daily (
1170
+ org_id uuid NOT NULL REFERENCES org(id) ON DELETE CASCADE,
1171
+ day date NOT NULL,
1172
+ authz_checks bigint NOT NULL DEFAULT 0,
1173
+ file_reads bigint NOT NULL DEFAULT 0,
1174
+ file_writes bigint NOT NULL DEFAULT 0,
1175
+ bytes_stored bigint NOT NULL DEFAULT 0,
1176
+ bytes_egressed bigint NOT NULL DEFAULT 0,
1177
+ PRIMARY KEY (org_id, day)
1178
+ );
1179
+
1180
+ -- Distinct actors who owned or accessed a file on a given day. `usage_daily`
1181
+ -- tells you how much work happened; this tells you how many distinct people it
1182
+ -- was spread across, which is the quantity authorization load actually tracks.
1183
+ -- The primary key makes it idempotent: one row per actor per day, however many
1184
+ -- files that actor touches.
1185
+ CREATE TABLE file_owning_user_daily (
1186
+ org_id uuid NOT NULL REFERENCES org(id) ON DELETE CASCADE,
1187
+ day date NOT NULL,
1188
+ actor_id uuid NOT NULL REFERENCES actor(id) ON DELETE CASCADE,
1189
+ PRIMARY KEY (org_id, day, actor_id)
1190
+ );