@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/dist/store.js
ADDED
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STORE LAYER
|
|
3
|
+
*
|
|
4
|
+
* Implements `AuthzDeps` (see authz.ts) against PostgreSQL.
|
|
5
|
+
*
|
|
6
|
+
* The authorization grant lookups below MUST read through the `live_grant`
|
|
7
|
+
* view, never `file_grant`. The liveness predicate -- not revoked, not expired,
|
|
8
|
+
* under cap, AND the same for every ancestor of the grant -- is expressed
|
|
9
|
+
* exactly once, in the schema, and every caller inherits it. If a future query
|
|
10
|
+
* here reaches for `file_grant` directly it has silently opted out of
|
|
11
|
+
* revocation, expiry and delegation control, which is P4, which is the product.
|
|
12
|
+
* The view is the enforcement point, not a convenience.
|
|
13
|
+
*
|
|
14
|
+
* The one deliberate exception is `findGrantBySecret`, which reads
|
|
15
|
+
* `file_grant`. It exists so that a denial can be ATTRIBUTED and EXPLAINED in
|
|
16
|
+
* the audit log, and the engine never uses its result to allow anything. It is
|
|
17
|
+
* named to make that obvious and it is the only such query in the file.
|
|
18
|
+
*
|
|
19
|
+
* ------------------------------------------------------------------------
|
|
20
|
+
* TRUST BOUNDARY. EVERY METHOD ON `PostgresStore` IS UNAUTHORIZED.
|
|
21
|
+
* ------------------------------------------------------------------------
|
|
22
|
+
* This class is the engine's DEPENDENCY surface (`AuthzDeps`), not an API.
|
|
23
|
+
* `getFile`, `getActorGrants`, `listAuthorizedFiles`, `listAudit`,
|
|
24
|
+
* `verifyAuditChain` and `consumeDownload` all take resource ids and no
|
|
25
|
+
* principal, because the engine has already established standing before it
|
|
26
|
+
* calls them -- that is the division of labour. They are the same shape as the
|
|
27
|
+
* defect fixed in `Filelayer.getFileRecord`, and they are safe only because
|
|
28
|
+
* they are not reachable from the developer's surface.
|
|
29
|
+
*
|
|
30
|
+
* The rule that keeps that true, stated so it can be enforced in review:
|
|
31
|
+
* `@filelayer/sdk` -- the only thing a customer touches in a hosted deployment
|
|
32
|
+
* -- MUST NOT re-export `PostgresStore`, `Filelayer.store`, or `store.db`. A
|
|
33
|
+
* developer holding `store.db` holds arbitrary SQL over every tenant in the
|
|
34
|
+
* hosted database, which is precisely the connection a hosted deployment exists
|
|
35
|
+
* to take away from them.
|
|
36
|
+
*/
|
|
37
|
+
import { createHash, randomBytes, scrypt as _scrypt, timingSafeEqual } from 'node:crypto';
|
|
38
|
+
import { promisify } from 'node:util';
|
|
39
|
+
import { membershipCells } from "./authz.js";
|
|
40
|
+
const scrypt = promisify(_scrypt);
|
|
41
|
+
const SCRYPT_KEYLEN = 32;
|
|
42
|
+
/**
|
|
43
|
+
* PGlite returns text[] as a JS array already; node-postgres does too. This
|
|
44
|
+
* guard exists because a raw text round-trip ('{read,write}') would otherwise
|
|
45
|
+
* silently produce a single-element array containing the literal braces, and a
|
|
46
|
+
* capability check against that string would deny everything -- fail closed,
|
|
47
|
+
* but confusingly.
|
|
48
|
+
*/
|
|
49
|
+
export function toCapabilities(v) {
|
|
50
|
+
if (Array.isArray(v))
|
|
51
|
+
return v;
|
|
52
|
+
return String(v)
|
|
53
|
+
.replace(/^\{|\}$/g, '')
|
|
54
|
+
.split(',')
|
|
55
|
+
.filter(Boolean);
|
|
56
|
+
}
|
|
57
|
+
function toGrantRow(r) {
|
|
58
|
+
return {
|
|
59
|
+
id: r.id,
|
|
60
|
+
fileId: r.file_id,
|
|
61
|
+
orgId: r.org_id,
|
|
62
|
+
parentGrantId: r.parent_grant_id ?? null,
|
|
63
|
+
subjectType: r.subject_type,
|
|
64
|
+
subjectOrgId: r.subject_org_id ?? null,
|
|
65
|
+
subjectMinRole: r.subject_min_role ?? null,
|
|
66
|
+
...(r.matched_role !== undefined && r.matched_role !== null
|
|
67
|
+
? { matchedRole: r.matched_role }
|
|
68
|
+
: {}),
|
|
69
|
+
capabilities: toCapabilities(r.capabilities),
|
|
70
|
+
passwordHash: r.password_hash,
|
|
71
|
+
expiresAt: r.expires_at ? new Date(r.expires_at) : null,
|
|
72
|
+
maxDownloads: r.max_downloads,
|
|
73
|
+
downloadCount: r.download_count,
|
|
74
|
+
revokedAt: r.revoked_at ? new Date(r.revoked_at) : null,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
// -----------------------------------------------------------------------------
|
|
78
|
+
// Store
|
|
79
|
+
// -----------------------------------------------------------------------------
|
|
80
|
+
/**
|
|
81
|
+
* The project every row belongs to when nobody named one. Mirrors the row
|
|
82
|
+
* inserted by schema.sql; see the PROJECT section there for why it exists.
|
|
83
|
+
*/
|
|
84
|
+
export const DEFAULT_PROJECT_ID = '00000000-0000-0000-0000-0000000f11e1';
|
|
85
|
+
export class PostgresStore {
|
|
86
|
+
db;
|
|
87
|
+
/** Null means unscoped. See StoreOptions. */
|
|
88
|
+
projectId;
|
|
89
|
+
constructor(db, opts = {}) {
|
|
90
|
+
this.db = db;
|
|
91
|
+
this.projectId = opts.projectId === undefined ? DEFAULT_PROJECT_ID : opts.projectId;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The same store, bound to a different connection -- in practice, to an open
|
|
95
|
+
* transaction.
|
|
96
|
+
*
|
|
97
|
+
* This is what puts the audit write in the same transaction as the mutation
|
|
98
|
+
* it records. The engine calls `deps.audit()`; if `deps` is a store bound to
|
|
99
|
+
* the transaction, the event and the mutation commit or roll back together,
|
|
100
|
+
* and `audit_append()`'s advisory lock is held across BOTH rather than across
|
|
101
|
+
* one autocommit statement. `projectId` rides along, so a transactional store
|
|
102
|
+
* cannot accidentally become an unscoped one.
|
|
103
|
+
*/
|
|
104
|
+
withDb(db) {
|
|
105
|
+
return new PostgresStore(db, { projectId: this.projectId });
|
|
106
|
+
}
|
|
107
|
+
now() {
|
|
108
|
+
return new Date();
|
|
109
|
+
}
|
|
110
|
+
// --- AuthzDeps ------------------------------------------------------------
|
|
111
|
+
async getFile(fileId) {
|
|
112
|
+
if (!isUuid(fileId))
|
|
113
|
+
return null; // a malformed id is "not found", not a 500
|
|
114
|
+
const { rows } = await this.db.query(`SELECT id, org_id, owner_id, state, visibility, expires_at, retain_until, deleted_at
|
|
115
|
+
FROM file WHERE id = $1 AND ($2::uuid IS NULL OR project_id = $2::uuid)`, [fileId, this.projectId]);
|
|
116
|
+
const r = rows[0];
|
|
117
|
+
if (!r)
|
|
118
|
+
return null;
|
|
119
|
+
return {
|
|
120
|
+
id: r.id,
|
|
121
|
+
orgId: r.org_id,
|
|
122
|
+
ownerId: r.owner_id,
|
|
123
|
+
// A soft-deleted row is 'deleted' regardless of the state column, so a
|
|
124
|
+
// half-applied delete cannot leave a readable file behind.
|
|
125
|
+
state: r.deleted_at ? 'deleted' : r.state,
|
|
126
|
+
visibility: r.visibility,
|
|
127
|
+
expiresAt: r.expires_at ? new Date(r.expires_at) : null,
|
|
128
|
+
retainUntil: r.retain_until ? new Date(r.retain_until) : null,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* "Is there an org here that this store may speak about?"
|
|
133
|
+
*
|
|
134
|
+
* A soft-deleted org is not one, and neither is an org whose PROJECT is
|
|
135
|
+
* soft-deleted -- deleting a customer must not leave their tenants
|
|
136
|
+
* addressable. Nor is an org in a different project.
|
|
137
|
+
*
|
|
138
|
+
* The return value decides which audit chain a denial is written to, so this
|
|
139
|
+
* predicate is also the thing that keeps a probe at another project's org id
|
|
140
|
+
* off that org's chain.
|
|
141
|
+
*/
|
|
142
|
+
async orgExists(orgId) {
|
|
143
|
+
if (!isUuid(orgId))
|
|
144
|
+
return false;
|
|
145
|
+
const { rows } = await this.db.query(`SELECT 1 FROM org o
|
|
146
|
+
JOIN project p ON p.id = o.project_id
|
|
147
|
+
WHERE o.id = $1
|
|
148
|
+
AND o.deleted_at IS NULL
|
|
149
|
+
AND p.deleted_at IS NULL
|
|
150
|
+
AND ($2::uuid IS NULL OR o.project_id = $2::uuid)`, [orgId, this.projectId]);
|
|
151
|
+
return rows.length > 0;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Membership confers nothing when the org, the project, or the ACTOR
|
|
155
|
+
* has been soft-deleted (P7).
|
|
156
|
+
*
|
|
157
|
+
* The actor clause is the one that was missing. `deleted_at` existed on
|
|
158
|
+
* `actor` and nothing read it, so a "deleted" user kept every role-derived
|
|
159
|
+
* capability they had -- which made the column decorative and made the answer
|
|
160
|
+
* to "what does deleting a user do?" depend on which method you asked.
|
|
161
|
+
*/
|
|
162
|
+
async getMembership(orgId, actorId) {
|
|
163
|
+
if (!isUuid(orgId) || !isUuid(actorId))
|
|
164
|
+
return null;
|
|
165
|
+
const { rows } = await this.db.query(`SELECT m.role FROM membership m
|
|
166
|
+
JOIN org o ON o.id = m.org_id
|
|
167
|
+
JOIN project p ON p.id = o.project_id
|
|
168
|
+
JOIN actor a ON a.id = m.actor_id
|
|
169
|
+
WHERE m.org_id = $1 AND m.actor_id = $2
|
|
170
|
+
AND o.deleted_at IS NULL
|
|
171
|
+
AND p.deleted_at IS NULL
|
|
172
|
+
AND a.deleted_at IS NULL
|
|
173
|
+
AND ($3::uuid IS NULL OR o.project_id = $3::uuid)`, [orgId, actorId, this.projectId]);
|
|
174
|
+
return rows[0]?.role ?? null;
|
|
175
|
+
}
|
|
176
|
+
async countOwners(orgId) {
|
|
177
|
+
if (!isUuid(orgId))
|
|
178
|
+
return 0;
|
|
179
|
+
const { rows } = await this.db.query(`SELECT count(*)::int AS c FROM membership WHERE org_id = $1 AND role = 'owner'`, [orgId]);
|
|
180
|
+
return Number(rows[0]?.c ?? 0);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* live_grant only.
|
|
184
|
+
*
|
|
185
|
+
* THE ORDER BY IS LOAD-BEARING, and its absence was a latent defect found
|
|
186
|
+
* while adding the group subject types. `resolveStanding` attributes an allow
|
|
187
|
+
* to the FIRST grant in this result that supplies the requested capability,
|
|
188
|
+
* and on the delegation path that grant becomes the new grant's
|
|
189
|
+
* `parent_grant_id` -- so it decides the ceiling the attenuation trigger will
|
|
190
|
+
* measure the child against. With no ORDER BY, Postgres returned heap order,
|
|
191
|
+
* which is not a defined order at all: it changes with page layout, with
|
|
192
|
+
* VACUUM, and (this is how it surfaced) with the width of the row. A
|
|
193
|
+
* principal holding several grants on one file could therefore have a
|
|
194
|
+
* `share()` call succeed or fail depending on physical storage.
|
|
195
|
+
*
|
|
196
|
+
* Oldest-first makes the choice deterministic and makes it the sensible one:
|
|
197
|
+
* the authority you were given first is the one you delegate from, and it is
|
|
198
|
+
* the same rule `getAnonymousGrant` already applies. See the note on the
|
|
199
|
+
* residual union-vs-parent gap in `authorizeShare`.
|
|
200
|
+
*/
|
|
201
|
+
async getActorGrants(fileId, actorId) {
|
|
202
|
+
if (!isUuid(fileId) || !isUuid(actorId))
|
|
203
|
+
return [];
|
|
204
|
+
const { rows } = await this.db.query(`SELECT * FROM live_grant
|
|
205
|
+
WHERE file_id = $1 AND subject_type = 'actor' AND subject_id = $2
|
|
206
|
+
ORDER BY created_at ASC, id ASC`, [fileId, actorId]);
|
|
207
|
+
return rows.map(toGrantRow);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* live_grant only. THE GROUP-GRANT PATH (RFC-001).
|
|
211
|
+
*
|
|
212
|
+
* ONE JOIN. Not a fan-out table, not a cached member list, not a
|
|
213
|
+
* materialized view -- a join against `membership`, evaluated on this
|
|
214
|
+
* request. That is the whole implementation of "membership changes must
|
|
215
|
+
* change access on the next request, without recomputation": there is
|
|
216
|
+
* nothing to recompute, because nothing was ever computed. Adding a member
|
|
217
|
+
* makes the join match; removing one makes it stop matching; no `file_grant`
|
|
218
|
+
* row is read, written, or even looked at during either operation. The test
|
|
219
|
+
* that asserts this counts grant rows before and after a join and a leave.
|
|
220
|
+
*
|
|
221
|
+
* The membership half carries the same three liveness clauses `getMembership`
|
|
222
|
+
* applies -- org, project and actor not soft-deleted -- because a group grant
|
|
223
|
+
* must not confer through a membership that confers nothing on its own. The
|
|
224
|
+
* SUBJECT ORG's own deletion is handled one level down, inside
|
|
225
|
+
* `grant_scope_is_live` (I2), so it binds `consume_download` and the
|
|
226
|
+
* attenuation trigger as well and not merely this query.
|
|
227
|
+
*
|
|
228
|
+
* `subject_min_role` is compared against the held role by tuple membership
|
|
229
|
+
* in the DERIVED cell list -- see `membershipCellSql` -- so the threshold is
|
|
230
|
+
* not restated in SQL here or in `listAuthorizedFiles`.
|
|
231
|
+
*/
|
|
232
|
+
async getGroupGrants(fileId, actorId) {
|
|
233
|
+
if (!isUuid(fileId) || !isUuid(actorId))
|
|
234
|
+
return [];
|
|
235
|
+
const { rows } = await this.db.query(`SELECT g.*, m.role AS matched_role
|
|
236
|
+
FROM live_grant g
|
|
237
|
+
JOIN membership m ON m.org_id = g.subject_org_id AND m.actor_id = $2::uuid
|
|
238
|
+
JOIN org so ON so.id = m.org_id
|
|
239
|
+
JOIN project sp ON sp.id = so.project_id
|
|
240
|
+
JOIN actor sa ON sa.id = m.actor_id
|
|
241
|
+
WHERE g.file_id = $1::uuid
|
|
242
|
+
AND g.subject_type IN ('org', 'role')
|
|
243
|
+
AND so.deleted_at IS NULL
|
|
244
|
+
AND sp.deleted_at IS NULL
|
|
245
|
+
AND sa.deleted_at IS NULL
|
|
246
|
+
AND ($3::uuid IS NULL OR so.project_id = $3::uuid)
|
|
247
|
+
AND ${membershipCellSql(membershipCells())}
|
|
248
|
+
ORDER BY g.created_at ASC, g.id ASC`, [fileId, actorId, this.projectId]);
|
|
249
|
+
return rows.map(toGrantRow);
|
|
250
|
+
}
|
|
251
|
+
/** live_grant only. Lookup is by hash; the plaintext never reaches the DB. */
|
|
252
|
+
async findLiveGrantBySecret(secretHash) {
|
|
253
|
+
const { rows } = await this.db.query(`SELECT * FROM live_grant
|
|
254
|
+
WHERE subject_type = 'link' AND secret_hash = $1
|
|
255
|
+
LIMIT 1`, [secretHash]);
|
|
256
|
+
return rows[0] ? toGrantRow(rows[0]) : null;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* ATTRIBUTION ONLY -- never an authorization input.
|
|
260
|
+
*
|
|
261
|
+
* Reads `file_grant`, so it sees revoked, expired, exhausted and
|
|
262
|
+
* ancestor-dead grants. The engine calls it exclusively on paths that have
|
|
263
|
+
* already denied, in order to name the grant in the audit event and record
|
|
264
|
+
* WHY the link stopped working. Any future use of this result to grant
|
|
265
|
+
* access is a P4 violation and should fail review.
|
|
266
|
+
*/
|
|
267
|
+
async findGrantBySecret(secretHash) {
|
|
268
|
+
const { rows } = await this.db.query(`SELECT * FROM file_grant
|
|
269
|
+
WHERE subject_type = 'link' AND secret_hash = $1
|
|
270
|
+
LIMIT 1`, [secretHash]);
|
|
271
|
+
return rows[0] ? toGrantRow(rows[0]) : null;
|
|
272
|
+
}
|
|
273
|
+
async isDescendantOf(ancestorId, grantId) {
|
|
274
|
+
if (!isUuid(ancestorId) || !isUuid(grantId))
|
|
275
|
+
return false;
|
|
276
|
+
const { rows } = await this.db.query(`SELECT 1 FROM grant_ancestry($1) WHERE id = $2`, [grantId, ancestorId]);
|
|
277
|
+
return rows.length > 0;
|
|
278
|
+
}
|
|
279
|
+
/** live_grant only. */
|
|
280
|
+
async getAnonymousGrant(fileId) {
|
|
281
|
+
if (!isUuid(fileId))
|
|
282
|
+
return null;
|
|
283
|
+
const { rows } = await this.db.query(`SELECT * FROM live_grant
|
|
284
|
+
WHERE file_id = $1 AND subject_type = 'anonymous'
|
|
285
|
+
ORDER BY created_at ASC
|
|
286
|
+
LIMIT 1`, [fileId]);
|
|
287
|
+
return rows[0] ? toGrantRow(rows[0]) : null;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* THE SET FORM OF THE DECISION.
|
|
291
|
+
*
|
|
292
|
+
* One query, one round trip, for the same predicate `authorize()` evaluates
|
|
293
|
+
* per file. Read it against `resolveStanding()` in authz.ts -- the three OR
|
|
294
|
+
* branches are the three sources of authority, in the same order, with the
|
|
295
|
+
* same conditions:
|
|
296
|
+
*
|
|
297
|
+
* 1. org role -> membership x the DERIVED role-cell list
|
|
298
|
+
* 2. actor grant -> live_grant, subject_type='actor', subject_id=caller
|
|
299
|
+
* 3. anonymous -> the EARLIEST live anonymous grant, read only
|
|
300
|
+
*
|
|
301
|
+
* ...and the lifecycle gate is the DERIVED lifecycle-cell list.
|
|
302
|
+
*
|
|
303
|
+
* The role and lifecycle tuples are not written here. They are generated by
|
|
304
|
+
* `listPredicate()` from `fileCapabilities()` and `lifecycleDenial()`, so
|
|
305
|
+
* changing the role model changes this query automatically. The tuples are
|
|
306
|
+
* still validated against the enum vocabulary before interpolation, because a
|
|
307
|
+
* generated string that reaches SQL unchecked is a generated string that can
|
|
308
|
+
* be made to reach SQL unchecked.
|
|
309
|
+
*
|
|
310
|
+
* `now` is passed in rather than read from the database so that the point
|
|
311
|
+
* check and the set query cannot disagree about what time it is. Grant
|
|
312
|
+
* liveness still uses the database clock, via `live_grant`, in BOTH paths.
|
|
313
|
+
*
|
|
314
|
+
* Ordering is (created_at, id) ascending -- total, because id is unique --
|
|
315
|
+
* and pagination is keyset, not OFFSET, so a concurrent insert cannot make a
|
|
316
|
+
* row skip a page.
|
|
317
|
+
*/
|
|
318
|
+
async listAuthorizedFiles(q) {
|
|
319
|
+
if (!isUuid(q.orgId))
|
|
320
|
+
return [];
|
|
321
|
+
if (q.actorId !== null && !isUuid(q.actorId))
|
|
322
|
+
return [];
|
|
323
|
+
const lifecycle = lifecycleSql(q.predicate);
|
|
324
|
+
const roles = roleCellSql(q.predicate);
|
|
325
|
+
if (lifecycle === null)
|
|
326
|
+
return []; // no state is usable for this capability
|
|
327
|
+
// The GROUP branch (RFC-001). Structurally identical to `getGroupGrants`:
|
|
328
|
+
// the same join, the same three membership-liveness clauses, the same
|
|
329
|
+
// derived threshold cells. It is written twice only in the sense that the
|
|
330
|
+
// whole union is -- which is exactly what the differential test in
|
|
331
|
+
// test/listing.test.ts exists to police, and why that test's corpus now
|
|
332
|
+
// contains org and role grants.
|
|
333
|
+
//
|
|
334
|
+
// No capability branch, mirroring `resolveStanding`: a group grant may
|
|
335
|
+
// carry any capability, so it is consulted for all of them.
|
|
336
|
+
const group = `OR EXISTS (
|
|
337
|
+
SELECT 1
|
|
338
|
+
FROM live_grant g
|
|
339
|
+
JOIN membership m ON m.org_id = g.subject_org_id AND m.actor_id = $2::uuid
|
|
340
|
+
JOIN org so ON so.id = m.org_id
|
|
341
|
+
JOIN project sp ON sp.id = so.project_id
|
|
342
|
+
JOIN actor sa ON sa.id = m.actor_id
|
|
343
|
+
WHERE g.file_id = f.id
|
|
344
|
+
AND g.subject_type IN ('org', 'role')
|
|
345
|
+
AND so.deleted_at IS NULL
|
|
346
|
+
AND sp.deleted_at IS NULL
|
|
347
|
+
AND sa.deleted_at IS NULL
|
|
348
|
+
AND ($6::uuid IS NULL OR so.project_id = $6::uuid)
|
|
349
|
+
AND ${membershipCellSql(q.predicate.membershipCells ?? [])}
|
|
350
|
+
AND $4::grant_capability = ANY (g.capabilities)
|
|
351
|
+
)`;
|
|
352
|
+
const anon = q.predicate.anonymousEligible
|
|
353
|
+
? `OR EXISTS (
|
|
354
|
+
SELECT 1 FROM (
|
|
355
|
+
SELECT g2.capabilities
|
|
356
|
+
FROM live_grant g2
|
|
357
|
+
WHERE g2.file_id = f.id AND g2.subject_type = 'anonymous'
|
|
358
|
+
ORDER BY g2.created_at ASC
|
|
359
|
+
LIMIT 1
|
|
360
|
+
) ag
|
|
361
|
+
WHERE $4::grant_capability = ANY (ag.capabilities)
|
|
362
|
+
)`
|
|
363
|
+
: '';
|
|
364
|
+
const params = [
|
|
365
|
+
q.orgId,
|
|
366
|
+
q.actorId,
|
|
367
|
+
q.now.toISOString(),
|
|
368
|
+
q.predicate.capability,
|
|
369
|
+
Math.max(1, Math.min(q.limit, LIST_MAX_LIMIT)),
|
|
370
|
+
this.projectId,
|
|
371
|
+
];
|
|
372
|
+
let cursorClause = '';
|
|
373
|
+
if (q.cursor) {
|
|
374
|
+
params.push(q.cursor.createdAt.toISOString(), q.cursor.id);
|
|
375
|
+
cursorClause = `AND (f.created_at, f.id) > ($7::timestamptz, $8::uuid)`;
|
|
376
|
+
}
|
|
377
|
+
const { rows } = await this.db.query(`SELECT f.id, f.org_id, f.owner_id, f.name, f.content_type, f.size_bytes,
|
|
378
|
+
f.storage_provider, f.storage_key, f.state, f.visibility,
|
|
379
|
+
f.expires_at, f.retain_until,
|
|
380
|
+
f.created_at, f.deleted_at
|
|
381
|
+
FROM file f
|
|
382
|
+
WHERE f.org_id = $1::uuid
|
|
383
|
+
AND ($6::uuid IS NULL OR f.project_id = $6::uuid)
|
|
384
|
+
AND ${lifecycle}
|
|
385
|
+
AND (
|
|
386
|
+
${roles === null
|
|
387
|
+
? 'false'
|
|
388
|
+
: `EXISTS (
|
|
389
|
+
SELECT 1
|
|
390
|
+
FROM membership m
|
|
391
|
+
JOIN org o ON o.id = m.org_id
|
|
392
|
+
JOIN project p ON p.id = o.project_id
|
|
393
|
+
JOIN actor a ON a.id = m.actor_id
|
|
394
|
+
WHERE m.org_id = f.org_id
|
|
395
|
+
AND m.actor_id = $2::uuid
|
|
396
|
+
AND o.deleted_at IS NULL
|
|
397
|
+
AND p.deleted_at IS NULL
|
|
398
|
+
AND a.deleted_at IS NULL
|
|
399
|
+
AND (m.role::text,
|
|
400
|
+
coalesce(f.owner_id = $2::uuid, false),
|
|
401
|
+
f.visibility::text) IN ${roles}
|
|
402
|
+
)`}
|
|
403
|
+
OR EXISTS (
|
|
404
|
+
SELECT 1 FROM live_grant g
|
|
405
|
+
WHERE g.file_id = f.id
|
|
406
|
+
AND g.subject_type = 'actor'
|
|
407
|
+
AND g.subject_id = $2::uuid
|
|
408
|
+
AND $4::grant_capability = ANY (g.capabilities)
|
|
409
|
+
)
|
|
410
|
+
${group}
|
|
411
|
+
${anon}
|
|
412
|
+
)
|
|
413
|
+
${cursorClause}
|
|
414
|
+
ORDER BY f.created_at ASC, f.id ASC
|
|
415
|
+
LIMIT $5`, params);
|
|
416
|
+
return rows.map(toListedFile);
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Link secrets are 256 bits of CSPRNG output, so they are not guessable and
|
|
420
|
+
* not enumerable; an unsalted SHA-256 is the correct primitive here (it must
|
|
421
|
+
* be deterministic to be indexable, and there is no low-entropy input to
|
|
422
|
+
* protect). Passwords are the opposite case and use scrypt below.
|
|
423
|
+
*/
|
|
424
|
+
async hashSecret(secret) {
|
|
425
|
+
return createHash('sha256').update(secret, 'utf8').digest('hex');
|
|
426
|
+
}
|
|
427
|
+
async hashPassword(password) {
|
|
428
|
+
const salt = randomBytes(16);
|
|
429
|
+
const key = await scrypt(password, salt, SCRYPT_KEYLEN);
|
|
430
|
+
return `scrypt$${salt.toString('base64')}$${key.toString('base64')}`;
|
|
431
|
+
}
|
|
432
|
+
async verifyPassword(password, hash) {
|
|
433
|
+
const parts = hash.split('$');
|
|
434
|
+
if (parts.length !== 3 || parts[0] !== 'scrypt')
|
|
435
|
+
return false;
|
|
436
|
+
const salt = Buffer.from(parts[1], 'base64');
|
|
437
|
+
const expected = Buffer.from(parts[2], 'base64');
|
|
438
|
+
const actual = await scrypt(password, salt, expected.length);
|
|
439
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
440
|
+
}
|
|
441
|
+
// --- Audit (P5) -----------------------------------------------------------
|
|
442
|
+
/**
|
|
443
|
+
* Hash chain. Each event commits to its predecessor in the same chain, where
|
|
444
|
+
* a chain is an org -- or the SYSTEM chain, `org_id IS NULL`, for decisions
|
|
445
|
+
* with no tenant to attribute them to.
|
|
446
|
+
*
|
|
447
|
+
* The digest now covers EVERY forensically relevant column, not the
|
|
448
|
+
* seven it used to cover. `reason`, `grant_id`, `ip`, `user_agent` and
|
|
449
|
+
* `context` are exactly the fields an incident responder relies on and
|
|
450
|
+
* exactly the fields an attacker would rewrite; leaving them outside the
|
|
451
|
+
* commitment made the chain decorative for the questions that matter.
|
|
452
|
+
*
|
|
453
|
+
* JSON encoding rather than string concatenation because concatenation is
|
|
454
|
+
* ambiguous: ('ab','c') and ('a','bc') would hash identically and a forger
|
|
455
|
+
* could shift field boundaries. `context` is canonicalised with sorted keys
|
|
456
|
+
* at every level, because jsonb does not preserve key order and the digest
|
|
457
|
+
* must survive the round trip through the database.
|
|
458
|
+
*
|
|
459
|
+
* CONCURRENCY -- fixed. This used to be a SELECT of the last hash
|
|
460
|
+
* followed by an INSERT, from here, in two statements -- atomic only under a
|
|
461
|
+
* single writer, and a chain FORK under two. It is now one call to
|
|
462
|
+
* `audit_append()`, which takes `pg_advisory_xact_lock` on the chain, reads
|
|
463
|
+
* the predecessor and inserts, all inside one statement and therefore one
|
|
464
|
+
* transaction. See the long comment on that function in schema.sql, including
|
|
465
|
+
* what PGlite can and cannot prove about it.
|
|
466
|
+
*
|
|
467
|
+
* The canonical encoding still lives here and only here: this method computes
|
|
468
|
+
* everything in the digest input EXCEPT the predecessor hash (which it cannot
|
|
469
|
+
* know without racing) and hands it over as `hash_tail`. The database
|
|
470
|
+
* prepends the predecessor it read under the lock. `verifyAuditChain` then
|
|
471
|
+
* recomputes the entire digest in TypeScript on read, so the SQL side and the
|
|
472
|
+
* TypeScript side are checked against each other by every chain verification
|
|
473
|
+
* in the suite.
|
|
474
|
+
*
|
|
475
|
+
* CHAIN FLOODING -- AND WHY IT IS NOT FIXED HERE.
|
|
476
|
+
*
|
|
477
|
+
* An unauthenticated caller who guesses an org UUID can cause denials to be
|
|
478
|
+
* appended to that tenant's chain. Under the per-chain lock above that is
|
|
479
|
+
* worse than storage growth: the tenant's own requests each take the same
|
|
480
|
+
* lock on the way through their audit write, so a flood is a latency attack
|
|
481
|
+
* on that tenant's whole request path, not merely noise in their log. (That
|
|
482
|
+
* interaction is new with the per-chain lock and is worth stating on its own.)
|
|
483
|
+
*
|
|
484
|
+
* It still does not belong in this function:
|
|
485
|
+
*
|
|
486
|
+
* - P5 says every decision is recorded, INCLUDING denials, because denials
|
|
487
|
+
* are the security-relevant events. Any in-engine mitigation is a rule for
|
|
488
|
+
* DROPPING audit events, and a log with a "we stop recording under load"
|
|
489
|
+
* clause is worthless in exactly the incident it exists for.
|
|
490
|
+
* - The engine cannot distinguish a flood from reconnaissance. They are the
|
|
491
|
+
* same request; only the rate differs, and rate is not observable from
|
|
492
|
+
* inside a single decision.
|
|
493
|
+
* - Admission control is the layer that can see it, and it belongs above the
|
|
494
|
+
* engine.
|
|
495
|
+
*
|
|
496
|
+
* WHAT IS FIXED HERE: the blast radius. `orgExists` is project-scoped, so a
|
|
497
|
+
* caller can only reach a tenant chain inside a project they are already
|
|
498
|
+
* authenticated for; probes at every other org id land on the system chain.
|
|
499
|
+
* The exposure goes from "any internet caller can degrade any tenant" to "an
|
|
500
|
+
* authenticated customer can degrade their own tenant" -- a quota question.
|
|
501
|
+
* That reduction is asserted in test/semantics.test.ts.
|
|
502
|
+
*
|
|
503
|
+
* WHAT REMAINS AN OPERATIONAL REQUIREMENT ON THE API LAYER. Stated as a
|
|
504
|
+
* requirement, not a hope, and repeated in SEMANTICS.md:
|
|
505
|
+
*
|
|
506
|
+
* R1. Every request carries a project credential; no project, no engine.
|
|
507
|
+
* R2. Rate limit per (project, source address) BEFORE the engine runs.
|
|
508
|
+
* R3. Cap per-project audit append rate and shed with 429 -- never by
|
|
509
|
+
* dropping a decision that was actually made.
|
|
510
|
+
* R4. Alert on SYSTEM-chain append rate. That chain is where unattributable
|
|
511
|
+
* probes go, so its rate is the enumeration signal.
|
|
512
|
+
*/
|
|
513
|
+
async audit(event) {
|
|
514
|
+
const occurredAt = this.now();
|
|
515
|
+
const ip = normalizeIp(event.ip);
|
|
516
|
+
const context = { ...(event.context ?? {}) };
|
|
517
|
+
// An address we cannot store as `inet` is kept verbatim in `context` rather
|
|
518
|
+
// than thrown away or, worse, passed to the cast: a malformed
|
|
519
|
+
// X-Forwarded-For must not be able to abort the audit write, because an
|
|
520
|
+
// audit write that fails takes the whole request with it.
|
|
521
|
+
if (event.ip !== undefined && event.ip !== null && ip === null) {
|
|
522
|
+
context['rawIp'] = String(event.ip).slice(0, 64);
|
|
523
|
+
}
|
|
524
|
+
const fields = {
|
|
525
|
+
// The predecessor is supplied by the database, under the chain lock.
|
|
526
|
+
prevHash: null,
|
|
527
|
+
orgId: event.orgId,
|
|
528
|
+
occurredAt,
|
|
529
|
+
action: event.action,
|
|
530
|
+
decision: event.decision,
|
|
531
|
+
reason: event.reason ?? null,
|
|
532
|
+
actorId: event.actorId,
|
|
533
|
+
fileId: event.fileId,
|
|
534
|
+
grantId: event.grantId ?? null,
|
|
535
|
+
ip,
|
|
536
|
+
userAgent: event.userAgent ?? null,
|
|
537
|
+
context,
|
|
538
|
+
};
|
|
539
|
+
await this.db.query(`SELECT id FROM audit_append($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12)`, [
|
|
540
|
+
event.orgId,
|
|
541
|
+
occurredAt.toISOString(),
|
|
542
|
+
event.action,
|
|
543
|
+
event.decision,
|
|
544
|
+
fields.reason,
|
|
545
|
+
event.actorId,
|
|
546
|
+
event.fileId,
|
|
547
|
+
fields.grantId,
|
|
548
|
+
fields.ip,
|
|
549
|
+
fields.userAgent,
|
|
550
|
+
JSON.stringify(fields.context),
|
|
551
|
+
auditHashTail(fields),
|
|
552
|
+
]);
|
|
553
|
+
}
|
|
554
|
+
async listAudit(orgId, filter = {}) {
|
|
555
|
+
const params = [orgId];
|
|
556
|
+
const clauses = ['org_id IS NOT DISTINCT FROM $1'];
|
|
557
|
+
if (filter.decision) {
|
|
558
|
+
params.push(filter.decision);
|
|
559
|
+
clauses.push(`decision = $${params.length}`);
|
|
560
|
+
}
|
|
561
|
+
if (filter.fileId) {
|
|
562
|
+
params.push(filter.fileId);
|
|
563
|
+
clauses.push(`file_id = $${params.length}`);
|
|
564
|
+
}
|
|
565
|
+
if (filter.actorId) {
|
|
566
|
+
params.push(filter.actorId);
|
|
567
|
+
clauses.push(`actor_id = $${params.length}`);
|
|
568
|
+
}
|
|
569
|
+
if (filter.action) {
|
|
570
|
+
params.push(filter.action);
|
|
571
|
+
clauses.push(`action = $${params.length}`);
|
|
572
|
+
}
|
|
573
|
+
params.push(Math.min(filter.limit ?? 500, 5000));
|
|
574
|
+
const { rows } = await this.db.query(`${AUDIT_COLUMNS}
|
|
575
|
+
WHERE ${clauses.join(' AND ')}
|
|
576
|
+
ORDER BY id ASC
|
|
577
|
+
LIMIT $${params.length}`, params);
|
|
578
|
+
return rows.map(mapAuditRow);
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Full chain replay. Returns the first inconsistency found, if any.
|
|
582
|
+
* Pass `null` to verify the system chain.
|
|
583
|
+
*/
|
|
584
|
+
async verifyAuditChain(orgId) {
|
|
585
|
+
const { rows } = await this.db.query(`${AUDIT_COLUMNS} WHERE org_id IS NOT DISTINCT FROM $1 ORDER BY id ASC`, [orgId]);
|
|
586
|
+
const events = rows.map(mapAuditRow);
|
|
587
|
+
let prev = null;
|
|
588
|
+
for (const e of events) {
|
|
589
|
+
if (e.prevHash !== prev) {
|
|
590
|
+
return {
|
|
591
|
+
valid: false,
|
|
592
|
+
checked: events.length,
|
|
593
|
+
brokenAt: e.id,
|
|
594
|
+
problem: 'prev_hash_mismatch',
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
const expected = auditHash({
|
|
598
|
+
prevHash: e.prevHash,
|
|
599
|
+
orgId: e.orgId,
|
|
600
|
+
occurredAt: e.occurredAt,
|
|
601
|
+
action: e.action,
|
|
602
|
+
decision: e.decision,
|
|
603
|
+
reason: e.reason,
|
|
604
|
+
actorId: e.actorId,
|
|
605
|
+
fileId: e.fileId,
|
|
606
|
+
grantId: e.grantId,
|
|
607
|
+
ip: e.ip,
|
|
608
|
+
userAgent: e.userAgent,
|
|
609
|
+
context: e.context,
|
|
610
|
+
});
|
|
611
|
+
if (expected !== e.hash) {
|
|
612
|
+
return { valid: false, checked: events.length, brokenAt: e.id, problem: 'hash_mismatch' };
|
|
613
|
+
}
|
|
614
|
+
prev = e.hash;
|
|
615
|
+
}
|
|
616
|
+
return { valid: true, checked: events.length };
|
|
617
|
+
}
|
|
618
|
+
// --- Counters (P6) --------------------------------------------------------
|
|
619
|
+
/**
|
|
620
|
+
* The conditional write *is* the reservation, and it charges the whole
|
|
621
|
+
* ancestor chain (see `consume_download` in schema.sql). Never read the
|
|
622
|
+
* counter and then decide -- see the "naive" comparison in the test suite for
|
|
623
|
+
* what that costs.
|
|
624
|
+
*
|
|
625
|
+
* The function now always returns exactly one row, and this reads
|
|
626
|
+
* `granted === true` rather than defaulting a missing value. Both halves of
|
|
627
|
+
* that had to change: an explicit `(false, 0)` row makes the SQL side
|
|
628
|
+
* unambiguous, and an explicit `=== true` makes the TypeScript side fail
|
|
629
|
+
* closed even if the SQL side is ever replaced with something that returns
|
|
630
|
+
* nothing at all.
|
|
631
|
+
*/
|
|
632
|
+
async consumeDownload(grantId) {
|
|
633
|
+
if (!isUuid(grantId))
|
|
634
|
+
return { granted: false, remaining: 0 };
|
|
635
|
+
const { rows } = await this.db.query(`SELECT granted, remaining FROM consume_download($1)`, [grantId]);
|
|
636
|
+
const r = rows[0];
|
|
637
|
+
if (!r || r.granted !== true)
|
|
638
|
+
return { granted: false, remaining: 0 };
|
|
639
|
+
return { granted: true, remaining: r.remaining === null ? null : Number(r.remaining) };
|
|
640
|
+
}
|
|
641
|
+
// --- Metering -------------------------------------------------------------
|
|
642
|
+
async recordUsage(orgId, kind, bytes = 0) {
|
|
643
|
+
const col = kind === 'authz' ? 'authz_checks' : kind === 'read' ? 'file_reads' : 'file_writes';
|
|
644
|
+
await this.db.query(`INSERT INTO usage_daily (org_id, day, ${col}, bytes_egressed)
|
|
645
|
+
VALUES ($1, current_date, 1, $2)
|
|
646
|
+
ON CONFLICT (org_id, day) DO UPDATE
|
|
647
|
+
SET ${col} = usage_daily.${col} + 1,
|
|
648
|
+
bytes_egressed = usage_daily.bytes_egressed + EXCLUDED.bytes_egressed`, [orgId, bytes]);
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Distinct actors who own a file on a given day. `usage_daily` counts events;
|
|
652
|
+
* this counts PEOPLE, which is the quantity authorization load actually
|
|
653
|
+
* tracks -- the graph walk behind every decision grows with the number of
|
|
654
|
+
* distinct holders, not with how many bytes they hold. Idempotent per
|
|
655
|
+
* (org, day, actor).
|
|
656
|
+
*/
|
|
657
|
+
async recordFileOwner(orgId, actorId) {
|
|
658
|
+
if (!isUuid(orgId) || !isUuid(actorId))
|
|
659
|
+
return;
|
|
660
|
+
await this.db.query(`INSERT INTO file_owning_user_daily (org_id, day, actor_id)
|
|
661
|
+
VALUES ($1, current_date, $2)
|
|
662
|
+
ON CONFLICT (org_id, day, actor_id) DO NOTHING`, [orgId, actorId]);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* A page is bounded so that one request cannot be turned into an unbounded
|
|
667
|
+
* scan, and so that the single audit event's `fileIds` array stays bounded too.
|
|
668
|
+
*/
|
|
669
|
+
export const LIST_MAX_LIMIT = 200;
|
|
670
|
+
export const LIST_DEFAULT_LIMIT = 50;
|
|
671
|
+
/** Enum labels are `[a-z_]+` by construction. Assert it rather than assume it. */
|
|
672
|
+
function enumLiteral(v) {
|
|
673
|
+
if (!/^[a-z_]+$/.test(v))
|
|
674
|
+
throw new Error(`unexpected enum literal: ${v}`);
|
|
675
|
+
return `'${v}'`;
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* `(role, is_owner, visibility) IN ((...),(...))`, generated from the derived
|
|
679
|
+
* cells. Returns null when the capability is unreachable by any role, so the
|
|
680
|
+
* caller can substitute a constant `false` -- `IN ()` is a syntax error, and a
|
|
681
|
+
* generator that emits a syntax error under a fail-CLOSED input is a generator
|
|
682
|
+
* that will one day be "fixed" by removing the branch.
|
|
683
|
+
*/
|
|
684
|
+
function roleCellSql(p) {
|
|
685
|
+
if (p.roleCells.length === 0)
|
|
686
|
+
return null;
|
|
687
|
+
return `(${p.roleCells
|
|
688
|
+
.map((c) => `(${enumLiteral(c.role)}, ${c.isOwner}, ${enumLiteral(c.visibility)})`)
|
|
689
|
+
.join(', ')})`;
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* `(floor, held role) IN ((...),(...))` -- the group-grant threshold, generated
|
|
693
|
+
* from the derived cells rather than written as `m.role >= g.subject_min_role`.
|
|
694
|
+
*
|
|
695
|
+
* WHY NOT JUST `>=`. Postgres would happily compare two `org_role` values by
|
|
696
|
+
* their DECLARATION ORDER in the enum, and it would be correct today. It would
|
|
697
|
+
* also be a second, independent statement of the role ordering, sitting in SQL,
|
|
698
|
+
* agreeing with `ROLE_RANK` in authz.ts only by coincidence -- and reordering
|
|
699
|
+
* the enum (or inserting a value into the middle of it, which
|
|
700
|
+
* `ALTER TYPE ... ADD VALUE BEFORE` permits) would silently change who can read
|
|
701
|
+
* what, with no test failing. Generating the pairs from `roleMeets()` keeps one
|
|
702
|
+
* definition of the ordering, in TypeScript, where the point check reads it.
|
|
703
|
+
*
|
|
704
|
+
* A `role` grant with no floor is not representable (the CHECK requires one)
|
|
705
|
+
* and an `org` grant's NULL floor reads as 'viewer', which is what the coalesce
|
|
706
|
+
* says.
|
|
707
|
+
*
|
|
708
|
+
* Returns `false` when the cell list is empty, for the same reason
|
|
709
|
+
* `roleCellSql` returns null: `IN ()` is a syntax error, and a generator that
|
|
710
|
+
* crashes on a fail-CLOSED input is a generator someone will "fix".
|
|
711
|
+
*/
|
|
712
|
+
export function membershipCellSql(cells) {
|
|
713
|
+
if (cells.length === 0)
|
|
714
|
+
return 'false';
|
|
715
|
+
const tuples = cells
|
|
716
|
+
.map((c) => `(${enumLiteral(c.minRole)}, ${enumLiteral(c.role)})`)
|
|
717
|
+
.join(', ');
|
|
718
|
+
return `(coalesce(g.subject_min_role::text, 'viewer'), m.role::text) IN (${tuples})`;
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* The lifecycle gate. `state` is the EFFECTIVE state -- a soft-deleted row is
|
|
722
|
+
* 'deleted' whatever the state column says, exactly as `getFile()` maps it, so
|
|
723
|
+
* a half-applied delete cannot leave a listable file behind.
|
|
724
|
+
*/
|
|
725
|
+
function lifecycleSql(p) {
|
|
726
|
+
if (p.lifecycleCells.length === 0)
|
|
727
|
+
return null;
|
|
728
|
+
const tuples = p.lifecycleCells
|
|
729
|
+
.map((c) => `(${enumLiteral(c.state)}, ${c.expired}, ${c.retained})`)
|
|
730
|
+
.join(', ');
|
|
731
|
+
return `(
|
|
732
|
+
CASE WHEN f.deleted_at IS NOT NULL THEN 'deleted' ELSE f.state::text END,
|
|
733
|
+
(f.expires_at IS NOT NULL AND f.expires_at <= $3::timestamptz),
|
|
734
|
+
(f.retain_until IS NOT NULL AND f.retain_until > $3::timestamptz)
|
|
735
|
+
) IN (${tuples})`;
|
|
736
|
+
}
|
|
737
|
+
function toListedFile(r) {
|
|
738
|
+
return {
|
|
739
|
+
id: r['id'],
|
|
740
|
+
orgId: r['org_id'],
|
|
741
|
+
ownerId: r['owner_id'] ?? null,
|
|
742
|
+
name: r['name'],
|
|
743
|
+
contentType: r['content_type'],
|
|
744
|
+
sizeBytes: r['size_bytes'] == null ? null : Number(r['size_bytes']),
|
|
745
|
+
storageProvider: r['storage_provider'],
|
|
746
|
+
storageKey: r['storage_key'],
|
|
747
|
+
state: r['deleted_at'] ? 'deleted' : r['state'],
|
|
748
|
+
visibility: r['visibility'],
|
|
749
|
+
expiresAt: r['expires_at'] ? new Date(r['expires_at']) : null,
|
|
750
|
+
retainUntil: r['retain_until'] ? new Date(r['retain_until']) : null,
|
|
751
|
+
createdAt: new Date(r['created_at']),
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
const AUDIT_COLUMNS = `SELECT id, org_id, occurred_at, action, decision, reason, actor_id,
|
|
755
|
+
file_id, grant_id, host(ip) AS ip, user_agent, context, prev_hash, hash
|
|
756
|
+
FROM audit_event`;
|
|
757
|
+
/**
|
|
758
|
+
* Canonical JSON: object keys sorted at every depth, so a value that has been
|
|
759
|
+
* through jsonb (which does not preserve insertion order) hashes identically to
|
|
760
|
+
* the value that was written.
|
|
761
|
+
*/
|
|
762
|
+
export function canonicalJson(v) {
|
|
763
|
+
if (Array.isArray(v))
|
|
764
|
+
return v.map(canonicalJson);
|
|
765
|
+
if (v && typeof v === 'object') {
|
|
766
|
+
const src = v;
|
|
767
|
+
const out = {};
|
|
768
|
+
for (const k of Object.keys(src).sort())
|
|
769
|
+
out[k] = canonicalJson(src[k]);
|
|
770
|
+
return out;
|
|
771
|
+
}
|
|
772
|
+
return v === undefined ? null : v;
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Everything in the digest input EXCEPT the predecessor hash, as the tail of a
|
|
776
|
+
* JSON array -- i.e. `orgId,...,context]`, with no leading bracket.
|
|
777
|
+
*
|
|
778
|
+
* This split is what lets `audit_append()` (schema.sql) take the chain lock,
|
|
779
|
+
* read the predecessor and compute the digest in ONE statement without any of
|
|
780
|
+
* the canonical encoding being duplicated in SQL. The database performs one
|
|
781
|
+
* concatenation:
|
|
782
|
+
*
|
|
783
|
+
* '[' || to_json(prev_hash) || ',' || tail
|
|
784
|
+
*
|
|
785
|
+
* `prevHash` is the first element of the array for exactly this reason, and
|
|
786
|
+
* moving it would silently break the chain -- which is why the ordering is
|
|
787
|
+
* asserted by a test rather than left to a comment.
|
|
788
|
+
*/
|
|
789
|
+
export function auditHashTail(input) {
|
|
790
|
+
return JSON.stringify([
|
|
791
|
+
input.orgId,
|
|
792
|
+
input.occurredAt.toISOString(),
|
|
793
|
+
input.action,
|
|
794
|
+
input.decision,
|
|
795
|
+
input.reason ?? null,
|
|
796
|
+
input.actorId,
|
|
797
|
+
input.fileId,
|
|
798
|
+
input.grantId ?? null,
|
|
799
|
+
input.ip ?? null,
|
|
800
|
+
input.userAgent ?? null,
|
|
801
|
+
canonicalJson(input.context ?? {}),
|
|
802
|
+
]).slice(1); // drop the leading '['; the database supplies it with prev_hash
|
|
803
|
+
}
|
|
804
|
+
export function auditHash(input) {
|
|
805
|
+
const canonical = `[${JSON.stringify(input.prevHash)},${auditHashTail(input)}`;
|
|
806
|
+
return createHash('sha256').update(canonical, 'utf8').digest('hex');
|
|
807
|
+
}
|
|
808
|
+
function mapAuditRow(r) {
|
|
809
|
+
const rawContext = r['context'];
|
|
810
|
+
return {
|
|
811
|
+
id: Number(r['id']),
|
|
812
|
+
orgId: r['org_id'] ?? null,
|
|
813
|
+
occurredAt: new Date(r['occurred_at']),
|
|
814
|
+
action: r['action'],
|
|
815
|
+
decision: r['decision'],
|
|
816
|
+
reason: r['reason'] ?? null,
|
|
817
|
+
actorId: r['actor_id'] ?? null,
|
|
818
|
+
fileId: r['file_id'] ?? null,
|
|
819
|
+
grantId: r['grant_id'] ?? null,
|
|
820
|
+
ip: r['ip'] ?? null,
|
|
821
|
+
userAgent: r['user_agent'] ?? null,
|
|
822
|
+
context: typeof rawContext === 'string'
|
|
823
|
+
? JSON.parse(rawContext)
|
|
824
|
+
: (rawContext ?? {}),
|
|
825
|
+
prevHash: r['prev_hash'] ?? null,
|
|
826
|
+
hash: r['hash'],
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
830
|
+
export function isUuid(v) {
|
|
831
|
+
return typeof v === 'string' && UUID_RE.test(v);
|
|
832
|
+
}
|
|
833
|
+
const IPV4_RE = /^\d{1,3}(\.\d{1,3}){3}$/;
|
|
834
|
+
const IPV6_RE = /^[0-9a-f:]{2,45}$/i;
|
|
835
|
+
/**
|
|
836
|
+
* Accept only what `inet` will take back out unchanged.
|
|
837
|
+
*
|
|
838
|
+
* Two reasons this is not paranoia. First, `host(inet)` is what the chain
|
|
839
|
+
* verifier reads, so any value Postgres would normalise (a CIDR suffix, for
|
|
840
|
+
* instance) would silently break tamper-evidence for every subsequent event.
|
|
841
|
+
* Second, an unparseable address would raise on INSERT, and since the audit
|
|
842
|
+
* write is on the critical path of every request, that turns a malformed
|
|
843
|
+
* `X-Forwarded-For` header into an outage.
|
|
844
|
+
*/
|
|
845
|
+
export function normalizeIp(v) {
|
|
846
|
+
if (typeof v !== 'string')
|
|
847
|
+
return null;
|
|
848
|
+
const s = v.trim();
|
|
849
|
+
if (s.length === 0 || s.length > 45 || s.includes('/'))
|
|
850
|
+
return null;
|
|
851
|
+
if (IPV4_RE.test(s)) {
|
|
852
|
+
return s.split('.').every((o) => Number(o) <= 255 && String(Number(o)) === o) ? s : null;
|
|
853
|
+
}
|
|
854
|
+
if (!s.includes(':') || !IPV6_RE.test(s.replace(/\.\d{1,3}/g, '')))
|
|
855
|
+
return null;
|
|
856
|
+
// ::ffff:1.2.3.4 and friends: the tail must still be a legal dotted quad.
|
|
857
|
+
const tail = s.slice(s.lastIndexOf(':') + 1);
|
|
858
|
+
if (tail.includes('.') && !IPV4_RE.test(tail))
|
|
859
|
+
return null;
|
|
860
|
+
return s;
|
|
861
|
+
}
|
|
862
|
+
//# sourceMappingURL=store.js.map
|