@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/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
|
+
);
|