@coffre/db 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +19 -3
  3. package/dist/commits-BuW-NL3n.js +30 -0
  4. package/dist/connect.d.ts +26 -0
  5. package/dist/connect.js +156 -0
  6. package/dist/database-CWpo5aF0.d.ts +39 -0
  7. package/dist/dialect.d.ts +88 -0
  8. package/dist/dialect.js +128 -0
  9. package/dist/hyperdrive.d.ts +32 -0
  10. package/dist/hyperdrive.js +54 -0
  11. package/dist/index.d.ts +3 -0
  12. package/dist/index.js +34 -0
  13. package/dist/log.d.ts +72 -0
  14. package/dist/log.js +133 -0
  15. package/dist/migrate.d.ts +10 -0
  16. package/dist/migrate.js +130 -0
  17. package/dist/migrations/postgres/0000_baseline.sql +505 -0
  18. package/dist/migrations/postgres/meta/0000_snapshot.json +1939 -0
  19. package/dist/migrations/postgres/meta/_journal.json +13 -0
  20. package/dist/migrations/sqlite/0000_baseline.sql +293 -0
  21. package/dist/migrations/sqlite/meta/0000_snapshot.json +1811 -0
  22. package/dist/migrations/sqlite/meta/_journal.json +13 -0
  23. package/dist/portable.d.ts +33 -0
  24. package/dist/portable.js +12 -0
  25. package/dist/postgres-Bmar8y0q.js +29 -0
  26. package/dist/relations-DqgxtlKu.js +58 -0
  27. package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
  28. package/dist/schema-Cd4WOnB5.d.ts +2503 -0
  29. package/dist/schema-sqlite.d.ts +2587 -0
  30. package/dist/schema-sqlite.js +401 -0
  31. package/dist/schema-version.d.ts +11 -0
  32. package/dist/schema-version.js +16 -0
  33. package/dist/schema.d.ts +2 -0
  34. package/dist/schema.js +448 -0
  35. package/package.json +92 -5
  36. package/src/baseline/postgres.sql +241 -0
  37. package/src/baseline/sqlite.sql +27 -0
  38. package/src/baseline.ts +104 -0
  39. package/src/commits.ts +47 -0
  40. package/src/connect.ts +171 -0
  41. package/src/database.ts +63 -0
  42. package/src/dialect.ts +174 -0
  43. package/src/drizzle.postgres.config.ts +7 -0
  44. package/src/drizzle.sqlite.config.ts +7 -0
  45. package/src/hyperdrive.ts +63 -0
  46. package/src/log.ts +177 -0
  47. package/src/migrate-main.ts +8 -0
  48. package/src/migrate.ts +207 -0
  49. package/src/migrations/postgres/0000_baseline.sql +505 -0
  50. package/src/migrations/postgres/meta/0000_snapshot.json +1939 -0
  51. package/src/migrations/postgres/meta/_journal.json +13 -0
  52. package/src/migrations/sqlite/0000_baseline.sql +293 -0
  53. package/src/migrations/sqlite/meta/0000_snapshot.json +1811 -0
  54. package/src/migrations/sqlite/meta/_journal.json +13 -0
  55. package/src/portable.ts +54 -0
  56. package/src/postgres.ts +35 -0
  57. package/src/relations.ts +53 -0
  58. package/src/schema-version.ts +13 -0
  59. package/src/schema.sqlite.ts +511 -0
  60. package/src/schema.ts +548 -0
  61. package/src/verify-runtime.ts +122 -0
package/src/schema.ts ADDED
@@ -0,0 +1,548 @@
1
+ import { assignableToEnvironment, ROLE_NAMES } from '@coffre/core/access';
2
+ import { sql } from 'drizzle-orm';
3
+ import {
4
+ bigint,
5
+ boolean,
6
+ check,
7
+ customType,
8
+ foreignKey,
9
+ index,
10
+ integer,
11
+ pgTable,
12
+ primaryKey,
13
+ text,
14
+ timestamp,
15
+ unique,
16
+ uniqueIndex,
17
+ uuid,
18
+ type AnyPgColumn,
19
+ } from 'drizzle-orm/pg-core';
20
+
21
+ import { ACTIVE_SUBJECT, relationsOf } from './relations.ts';
22
+
23
+ const bytea = customType<{ data: Buffer }>({
24
+ dataType: () => 'bytea',
25
+ });
26
+
27
+ const createdAt = () => timestamp('created_at', { withTimezone: true }).notNull().defaultNow();
28
+
29
+ export const projects = pgTable(
30
+ 'projects',
31
+ {
32
+ id: uuid().primaryKey().defaultRandom(),
33
+ slug: text().notNull(),
34
+ name: text().notNull(),
35
+ createdAt: createdAt(),
36
+ archivedAt: timestamp('archived_at', { withTimezone: true }),
37
+ },
38
+ (table) => [
39
+ unique('projects_slug_key').on(table.slug),
40
+ check('projects_slug_check', sql`${table.slug} ~ '^[a-z0-9][a-z0-9-]{0,62}$'`),
41
+ ],
42
+ );
43
+
44
+ export const environments = pgTable(
45
+ 'environments',
46
+ {
47
+ id: uuid().primaryKey().defaultRandom(),
48
+ projectId: uuid('project_id').notNull(),
49
+ slug: text().notNull(),
50
+ name: text().notNull(),
51
+ createdAt: createdAt(),
52
+ archivedAt: timestamp('archived_at', { withTimezone: true }),
53
+ },
54
+ (table) => [
55
+ unique('environments_project_id_slug_key').on(table.projectId, table.slug),
56
+ unique('environments_project_scoped').on(table.id, table.projectId),
57
+ check('environments_slug_check', sql`${table.slug} ~ '^[a-z0-9][a-z0-9-]{0,62}$'`),
58
+ foreignKey({
59
+ name: 'environments_project_id_fkey',
60
+ columns: [table.projectId],
61
+ foreignColumns: [projects.id],
62
+ }).onDelete('restrict'),
63
+ ],
64
+ );
65
+
66
+ export const secrets = pgTable(
67
+ 'secrets',
68
+ {
69
+ id: uuid().primaryKey().defaultRandom(),
70
+ projectId: uuid('project_id').notNull(),
71
+ environmentId: uuid('environment_id').notNull(),
72
+ key: text().notNull(),
73
+ createdAt: createdAt(),
74
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
75
+ currentVersionId: uuid('current_version_id').references(
76
+ (): AnyPgColumn => secretVersions.id,
77
+ { onDelete: 'no action' },
78
+ ),
79
+ // The number of the current version, 0 before the first. Versions only
80
+ // append, so it is also the highest: the next one is this plus one.
81
+ currentVersion: integer('current_version').notNull().default(0),
82
+ archivedAt: timestamp('archived_at', { withTimezone: true }),
83
+ },
84
+ (table) => [
85
+ unique('secrets_project_id_environment_id_key_key').on(
86
+ table.projectId,
87
+ table.environmentId,
88
+ table.key,
89
+ ),
90
+ check('secrets_key_check', sql`${table.key} ~ '^[A-Za-z_][A-Za-z0-9_]{0,127}$'`),
91
+ foreignKey({
92
+ name: 'secrets_project_id_fkey',
93
+ columns: [table.projectId],
94
+ foreignColumns: [projects.id],
95
+ }).onDelete('restrict'),
96
+ foreignKey({
97
+ name: 'secrets_environment_id_fkey',
98
+ columns: [table.environmentId],
99
+ foreignColumns: [environments.id],
100
+ }).onDelete('restrict'),
101
+ foreignKey({
102
+ name: 'secrets_environment_in_project',
103
+ columns: [table.environmentId, table.projectId],
104
+ foreignColumns: [environments.id, environments.projectId],
105
+ }),
106
+ index('secrets_lookup_idx').on(table.projectId, table.environmentId, table.key),
107
+ ],
108
+ );
109
+
110
+ export const secretVersions = pgTable(
111
+ 'secret_versions',
112
+ {
113
+ id: uuid().primaryKey().defaultRandom(),
114
+ secretId: uuid('secret_id').notNull(),
115
+ version: integer().notNull(),
116
+ envelopeVersion: integer('envelope_version').notNull(),
117
+ ciphertext: bytea().notNull(),
118
+ iv: bytea().notNull(),
119
+ authTag: bytea('auth_tag').notNull(),
120
+ wrappedDek: bytea('wrapped_dek').notNull(),
121
+ kekProvider: text('kek_provider').notNull(),
122
+ kekId: text('kek_id').notNull(),
123
+ kekVersion: text('kek_version').notNull(),
124
+ createdAt: createdAt(),
125
+ createdBy: text('created_by').notNull(),
126
+ },
127
+ (table) => [
128
+ unique('secret_versions_secret_id_version_key').on(table.secretId, table.version),
129
+ check('secret_versions_version_check', sql`${table.version} > 0`),
130
+ check('secret_versions_iv_check', sql`octet_length(${table.iv}) = 12`),
131
+ check('secret_versions_auth_tag_check', sql`octet_length(${table.authTag}) = 16`),
132
+ foreignKey({
133
+ name: 'secret_versions_secret_id_fkey',
134
+ columns: [table.secretId],
135
+ foreignColumns: [secrets.id],
136
+ }).onDelete('restrict'),
137
+ index('secret_versions_secret_idx').on(table.secretId, table.version.desc()),
138
+ ],
139
+ );
140
+
141
+ /** The role catalogue, as SQL: every role, and those an environment may be granted. */
142
+ const ROLES = sql.raw(ROLE_NAMES.map((role) => `'${role}'`).join(', '));
143
+ const ENVIRONMENT_ROLES = sql.raw(ROLE_NAMES.filter(assignableToEnvironment).map((role) => `'${role}'`).join(', '));
144
+
145
+ /**
146
+ * The vault's member directory: everyone it has admitted, written by the
147
+ * vault alone, read by both. A principal with no row is no member.
148
+ * Principals are the strings coffre uses at its edges: `user:<email>`,
149
+ * `token:<id>`, `sync:<id>`. Times are milliseconds since the epoch, each
150
+ * the time of the vault's log entry that set it, so the log replays to
151
+ * these rows exactly.
152
+ */
153
+ export const vaultMembers = pgTable(
154
+ 'vault_members',
155
+ {
156
+ principal: text().primaryKey(),
157
+ status: text().notNull(),
158
+ // An instance owner, who manages every project and member. Root admins
159
+ // come from the vault's configuration, never from a row.
160
+ owner: boolean().notNull().default(false),
161
+ // Advanced by each removal. A session, token or linked account issued
162
+ // under an older generation is dead, whatever its own row says.
163
+ generation: integer().notNull().default(0),
164
+ createdAt: bigint('created_at', { mode: 'number' }).notNull(),
165
+ createdBy: text('created_by').notNull(),
166
+ statusChangedAt: bigint('status_changed_at', { mode: 'number' }).notNull(),
167
+ statusChangedBy: text('status_changed_by').notNull(),
168
+ // The vault's last access entry about the member: a row put back from
169
+ // before a later change names an older one than the log has.
170
+ accessSeq: bigint('access_seq', { mode: 'bigint' }).notNull(),
171
+ // The vault's MAC over the row and the member's grants, lapsed ones too:
172
+ // a grant added, edited or deleted outside the vault fails it.
173
+ mac: bytea().notNull(),
174
+ },
175
+ (table) => [
176
+ check('vault_members_mac_check', sql`octet_length(${table.mac}) = 32`),
177
+ check('vault_members_principal_check', sql`${table.principal} ~ '^(user|token|sync):[^[:space:]:][^[:space:]]*$'`),
178
+ // A person is their email address, lowercased, as in sign-in.
179
+ check('vault_members_user_lowercase', sql`${table.principal} NOT LIKE 'user:%' OR ${table.principal} = lower(${table.principal})`),
180
+ check('vault_members_status_check', sql`${table.status} IN ('active', 'removed')`),
181
+ check('vault_members_owner_check', sql`NOT ${table.owner} OR (${table.status} = 'active' AND ${table.principal} LIKE 'user:%')`),
182
+ check('vault_members_generation_check', sql`${table.generation} >= 0`),
183
+ ],
184
+ );
185
+
186
+ /**
187
+ * One role per member per place: a project, or one of its environments,
188
+ * never both. A revoked grant is deleted; an expired one stays until its
189
+ * place is granted again, so the members page can say it lapsed.
190
+ */
191
+ export const vaultGrants = pgTable(
192
+ 'vault_grants',
193
+ {
194
+ principal: text().notNull(),
195
+ projectId: uuid('project_id'),
196
+ environmentId: uuid('environment_id'),
197
+ role: text().notNull(),
198
+ expiresAt: bigint('expires_at', { mode: 'number' }),
199
+ grantedAt: bigint('granted_at', { mode: 'number' }).notNull(),
200
+ grantedBy: text('granted_by').notNull(),
201
+ },
202
+ (table) => [
203
+ check('vault_grants_one_place', sql`(${table.projectId} IS NULL) <> (${table.environmentId} IS NULL)`),
204
+ check('vault_grants_role_check', sql`${table.role} IN (${ROLES})`),
205
+ check('vault_grants_environment_role_check', sql`${table.environmentId} IS NULL OR ${table.role} IN (${ENVIRONMENT_ROLES})`),
206
+ unique('vault_grants_on_project').on(table.principal, table.projectId),
207
+ unique('vault_grants_on_environment').on(table.principal, table.environmentId),
208
+ foreignKey({
209
+ name: 'vault_grants_principal_fkey',
210
+ columns: [table.principal],
211
+ foreignColumns: [vaultMembers.principal],
212
+ }).onDelete('restrict'),
213
+ foreignKey({
214
+ name: 'vault_grants_project_id_fkey',
215
+ columns: [table.projectId],
216
+ foreignColumns: [projects.id],
217
+ }).onDelete('restrict'),
218
+ foreignKey({
219
+ name: 'vault_grants_environment_id_fkey',
220
+ columns: [table.environmentId],
221
+ foreignColumns: [environments.id],
222
+ }).onDelete('restrict'),
223
+ ],
224
+ );
225
+
226
+ export const auditLog = pgTable(
227
+ 'audit_log',
228
+ {
229
+ seq: bigint({ mode: 'bigint' }).primaryKey(),
230
+ // Which component wrote the entry, under which of its keys; see
231
+ // @coffre/core/audit for what its MAC and hash cover.
232
+ author: text().notNull(),
233
+ keyId: text('key_id').notNull(),
234
+ // Milliseconds since the epoch, from the database's clock, read after the
235
+ // append took the head's lock.
236
+ occurredAt: bigint('occurred_at', { mode: 'number' }).notNull(),
237
+ // Who acted: `user:<email>`, `token:<id>`, `sync:<id>` or `system:<name>`.
238
+ actor: text().notNull(),
239
+ action: text().notNull(),
240
+ decision: text().notNull(),
241
+ code: text(),
242
+ // The member an access change is about.
243
+ subjectPrincipal: text('subject_principal'),
244
+ projectId: uuid('project_id'),
245
+ environmentId: uuid('environment_id'),
246
+ secretId: uuid('secret_id'),
247
+ secretVersionId: uuid('secret_version_id'),
248
+ // One id for everything one action did: a reveal's keys, a write's versions.
249
+ operationId: uuid('operation_id'),
250
+ requestId: text('request_id'),
251
+ sourceIp: text('source_ip'),
252
+ // An earlier entry this one follows from, such as the wrap behind a write.
253
+ relatedSeq: bigint('related_seq', { mode: 'bigint' }),
254
+ metadata: text().notNull().default('{}'),
255
+ prevHash: bytea('prev_hash').notNull(),
256
+ mac: bytea().notNull(),
257
+ hash: bytea().notNull(),
258
+ },
259
+ (table) => [
260
+ check('audit_log_author_check', sql`${table.author} IN ('app', 'vault')`),
261
+ check('audit_log_actor_check', sql`${table.actor} ~ '^(user|token|sync|system):.+$'`),
262
+ check('audit_log_decision_check', sql`${table.decision} IN ('allow', 'deny')`),
263
+ check('audit_log_metadata_check', sql`${table.metadata}::jsonb IS NOT NULL`),
264
+ check('audit_log_prev_hash_check', sql`octet_length(${table.prevHash}) = 32`),
265
+ check('audit_log_mac_check', sql`octet_length(${table.mac}) = 32`),
266
+ check('audit_log_hash_check', sql`octet_length(${table.hash}) = 32`),
267
+ foreignKey({
268
+ name: 'audit_log_project_id_fkey',
269
+ columns: [table.projectId],
270
+ foreignColumns: [projects.id],
271
+ }).onDelete('restrict'),
272
+ foreignKey({
273
+ name: 'audit_log_environment_id_fkey',
274
+ columns: [table.environmentId],
275
+ foreignColumns: [environments.id],
276
+ }).onDelete('restrict'),
277
+ foreignKey({
278
+ name: 'audit_log_secret_id_fkey',
279
+ columns: [table.secretId],
280
+ foreignColumns: [secrets.id],
281
+ }).onDelete('restrict'),
282
+ foreignKey({
283
+ name: 'audit_log_secret_version_id_fkey',
284
+ columns: [table.secretVersionId],
285
+ foreignColumns: [secretVersions.id],
286
+ }).onDelete('restrict'),
287
+ foreignKey({
288
+ name: 'audit_log_related_seq_fkey',
289
+ columns: [table.relatedSeq],
290
+ foreignColumns: [table.seq],
291
+ }).onDelete('restrict'),
292
+ // Pages read backwards by seq, within a place or an actor.
293
+ index('audit_log_project_idx').on(table.projectId, table.seq),
294
+ index('audit_log_environment_idx').on(table.environmentId, table.seq),
295
+ index('audit_log_secret_idx').on(table.secretId, table.seq),
296
+ index('audit_log_actor_idx').on(table.actor, table.seq),
297
+ index('audit_log_operation_idx').on(table.operationId, table.seq),
298
+ index('audit_log_action_idx').on(table.author, table.action, table.seq),
299
+ // The bulk limit: one reader's recent releases.
300
+ index('audit_log_releases_idx').on(table.author, table.actor, table.action, table.decision, table.occurredAt),
301
+ // What the vault logged about one member, in order.
302
+ index('audit_log_subject_idx').on(table.author, table.subjectPrincipal, table.seq),
303
+ ],
304
+ );
305
+
306
+ export const auditChainHead = pgTable(
307
+ 'audit_chain_head',
308
+ {
309
+ onlyRow: boolean('only_row').primaryKey().default(true),
310
+ nextSeq: bigint('next_seq', { mode: 'bigint' }).notNull().default(sql`0`),
311
+ headHash: bytea('head_hash').notNull(),
312
+ },
313
+ (table) => [
314
+ check('audit_chain_head_only_row_check', sql`${table.onlyRow}`),
315
+ check('audit_chain_head_next_seq_check', sql`${table.nextSeq} >= 0`),
316
+ check('audit_chain_head_head_hash_check', sql`octet_length(${table.headHash}) = 32`),
317
+ ],
318
+ );
319
+
320
+ /**
321
+ * An account at a sign-in provider, bound to one principal.
322
+ *
323
+ * Looked up by (provider, subject), never by email. An email address is
324
+ * recycled when someone leaves; the provider's subject is not, so binding to
325
+ * it is what stops a new holder of an old address from inheriting its access.
326
+ * The email is kept for display only.
327
+ */
328
+ export const identities = pgTable(
329
+ 'identities',
330
+ {
331
+ id: uuid().primaryKey().defaultRandom(),
332
+ provider: text().notNull(),
333
+ authMac: bytea('auth_mac').notNull(),
334
+ subject: text().notNull(),
335
+ issuerHash: text('issuer_hash').notNull(),
336
+ generation: integer().notNull(),
337
+ // The member it signs in as, `user:<email>`, under the generation it was bound in.
338
+ principal: text().notNull(),
339
+ email: text(),
340
+ createdAt: createdAt(),
341
+ createdBy: text('created_by').notNull(),
342
+ lastSignInAt: timestamp('last_sign_in_at', { withTimezone: true }),
343
+ revokedAt: timestamp('revoked_at', { withTimezone: true }),
344
+ revokedBy: text('revoked_by'),
345
+ activeSubject: text('active_subject').generatedAlwaysAs(ACTIVE_SUBJECT),
346
+ },
347
+ (table) => [
348
+ check('identities_principal_check', sql`${table.principal} LIKE 'user:%'`),
349
+ check('identities_provider_check', sql`${table.provider} ~ '^[a-z0-9][a-z0-9-]{0,31}$'`),
350
+ check('identities_auth_mac_check', sql`octet_length(${table.authMac}) = 32`),
351
+ unique('identities_member_generation_key').on(table.id, table.principal, table.generation),
352
+ foreignKey({
353
+ name: 'identities_principal_fkey',
354
+ columns: [table.principal],
355
+ foreignColumns: [vaultMembers.principal],
356
+ }).onDelete('restrict'),
357
+ // An account is bound to one person at a time: the subject counts only
358
+ // while the identity is not revoked (see ACTIVE_SUBJECT).
359
+ uniqueIndex('identities_active_subject').on(table.provider, table.issuerHash, table.activeSubject),
360
+ index('identities_principal_idx').on(table.principal, table.generation),
361
+ ],
362
+ );
363
+
364
+ /**
365
+ * Bearer credentials coffre issues itself: browser sessions, CLI sessions and
366
+ * service tokens.
367
+ *
368
+ * One table, so that revoking everything a principal holds is one statement.
369
+ * Only a SHA-256 of each token is stored. Tokens carry 256 bits of entropy, so
370
+ * a fast hash is enough; a database leak yields nothing that authenticates.
371
+ */
372
+ export const credentials = pgTable(
373
+ 'credentials',
374
+ {
375
+ id: uuid().primaryKey().defaultRandom(),
376
+ kind: text().notNull(),
377
+ authMac: bytea('auth_mac').notNull(),
378
+ tokenHash: bytea('token_hash').notNull(),
379
+ tokenHint: text('token_hint').notNull(),
380
+ generation: integer().notNull(),
381
+ // The member it acts as, `user:<email>` or `token:<id>`, under the generation it was issued in.
382
+ principal: text().notNull(),
383
+ identityId: uuid('identity_id'),
384
+ label: text(),
385
+ createdAt: createdAt(),
386
+ createdBy: text('created_by').notNull(),
387
+ expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
388
+ lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
389
+ lastUsedIp: text('last_used_ip'),
390
+ revokedAt: timestamp('revoked_at', { withTimezone: true }),
391
+ revokedBy: text('revoked_by'),
392
+ },
393
+ (table) => [
394
+ unique('credentials_token_hash_key').on(table.tokenHash),
395
+ check('credentials_kind_check', sql`${table.kind} IN ('browser', 'cli', 'service')`),
396
+ check('credentials_principal_check', sql`${table.principal} LIKE 'user:%' OR ${table.principal} LIKE 'token:%'`),
397
+ check('credentials_kind_matches_principal', sql`(${table.kind} = 'service') = (${table.principal} LIKE 'token:%')`),
398
+ check('credentials_token_hash_check', sql`octet_length(${table.tokenHash}) = 32`),
399
+ check('credentials_auth_mac_check', sql`octet_length(${table.authMac}) = 32`),
400
+ foreignKey({
401
+ name: 'credentials_principal_fkey',
402
+ columns: [table.principal],
403
+ foreignColumns: [vaultMembers.principal],
404
+ }).onDelete('restrict'),
405
+ // A session's account is its own member's, bound in the same generation.
406
+ foreignKey({
407
+ name: 'credentials_identity_id_fkey',
408
+ columns: [table.identityId, table.principal, table.generation],
409
+ foreignColumns: [identities.id, identities.principal, identities.generation],
410
+ }).onDelete('restrict'),
411
+ index('credentials_principal_idx').on(table.principal, table.generation),
412
+ ],
413
+ );
414
+
415
+ /**
416
+ * A CLI asking to be signed in from a browser (RFC 8628, device flow).
417
+ *
418
+ * The CLI holds the device code and polls with it; a signed-in person approves
419
+ * the short user code in their browser. It works the same on a laptop and on
420
+ * a server reached over SSH, which a localhost redirect does not.
421
+ */
422
+ export const deviceAuthorizations = pgTable(
423
+ 'device_authorizations',
424
+ {
425
+ id: uuid().primaryKey().defaultRandom(),
426
+ deviceCodeHash: bytea('device_code_hash').notNull(),
427
+ authMac: bytea('auth_mac').notNull(),
428
+ userCode: text('user_code').notNull(),
429
+ clientLabel: text('client_label'),
430
+ clientIp: text('client_ip'),
431
+ createdAt: createdAt(),
432
+ expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
433
+ decidedAt: timestamp('decided_at', { withTimezone: true }),
434
+ decision: text(),
435
+ generation: integer().notNull().default(0),
436
+ // Who approved it, `user:<email>`, once approved.
437
+ principal: text(),
438
+ consumedAt: timestamp('consumed_at', { withTimezone: true }),
439
+ },
440
+ (table) => [
441
+ unique('device_authorizations_device_code_hash_key').on(table.deviceCodeHash),
442
+ unique('device_authorizations_user_code_key').on(table.userCode),
443
+ check('device_authorizations_auth_mac_check', sql`octet_length(${table.authMac}) = 32`),
444
+ check(
445
+ 'device_authorizations_decision_check',
446
+ sql`${table.decision} IS NULL OR ${table.decision} IN ('approved', 'denied')`,
447
+ ),
448
+ check(
449
+ 'device_authorizations_approval_names_principal',
450
+ sql`(${table.decision} IS NULL AND ${table.decidedAt} IS NULL AND ${table.principal} IS NULL AND ${table.generation} = 0 AND ${table.consumedAt} IS NULL)
451
+ OR (${table.decision} IS NOT NULL AND ${table.decidedAt} IS NOT NULL AND (
452
+ (${table.decision} = 'denied' AND ${table.principal} IS NULL AND ${table.generation} = 0 AND ${table.consumedAt} IS NULL)
453
+ OR (${table.decision} = 'approved' AND ${table.principal} IS NOT NULL AND ${table.principal} LIKE 'user:%')
454
+ ))`,
455
+ ),
456
+ foreignKey({
457
+ name: 'device_authorizations_principal_fkey',
458
+ columns: [table.principal],
459
+ foreignColumns: [vaultMembers.principal],
460
+ }).onDelete('restrict'),
461
+ ],
462
+ );
463
+
464
+ /**
465
+ * An environment kept in step with a third-party service: GitHub Actions
466
+ * secrets, Vercel or Railway variables, Worker secrets.
467
+ *
468
+ * The destination's API token is itself a coffre secret, referenced by id, so
469
+ * it is encrypted, versioned and audited like everything else and never sits
470
+ * in this table.
471
+ */
472
+ export const syncs = pgTable(
473
+ 'syncs',
474
+ {
475
+ id: uuid().primaryKey().defaultRandom(),
476
+ projectId: uuid('project_id').notNull(),
477
+ environmentId: uuid('environment_id').notNull(),
478
+ provider: text().notNull(),
479
+ config: text().notNull(),
480
+ credentialSecretId: uuid('credential_secret_id').notNull(),
481
+ createdAt: createdAt(),
482
+ createdBy: text('created_by').notNull(),
483
+ pausedAt: timestamp('paused_at', { withTimezone: true }),
484
+ archivedAt: timestamp('archived_at', { withTimezone: true }),
485
+ leaseUntil: timestamp('lease_until', { withTimezone: true }),
486
+ lastRunAt: timestamp('last_run_at', { withTimezone: true }),
487
+ lastStatus: text('last_status'),
488
+ lastError: text('last_error'),
489
+ },
490
+ (table) => [
491
+ check('syncs_config_check', sql`${table.config}::jsonb IS NOT NULL`),
492
+ check(
493
+ 'syncs_last_status_check',
494
+ sql`${table.lastStatus} IS NULL OR ${table.lastStatus} IN ('ok', 'partial', 'failed')`,
495
+ ),
496
+ foreignKey({
497
+ name: 'syncs_environment_in_project',
498
+ columns: [table.environmentId, table.projectId],
499
+ foreignColumns: [environments.id, environments.projectId],
500
+ }).onDelete('restrict'),
501
+ foreignKey({
502
+ name: 'syncs_credential_secret_id_fkey',
503
+ columns: [table.credentialSecretId],
504
+ foreignColumns: [secrets.id],
505
+ }).onDelete('restrict'),
506
+ index('syncs_environment_idx').on(table.environmentId),
507
+ ],
508
+ );
509
+
510
+ /**
511
+ * What a sync last pushed, one row per key.
512
+ *
513
+ * Most destinations are write-only, so coffre cannot diff against them. It
514
+ * diffs against this instead: a key is stale when its secret has moved past
515
+ * the version recorded here. It is also the list of keys coffre may delete at
516
+ * the destination; a key it never pushed is never removed.
517
+ */
518
+ export const syncKeys = pgTable(
519
+ 'sync_keys',
520
+ {
521
+ syncId: uuid('sync_id').notNull(),
522
+ key: text().notNull(),
523
+ secretVersionId: uuid('secret_version_id'),
524
+ pushedAt: timestamp('pushed_at', { withTimezone: true }).notNull().defaultNow(),
525
+ removedAt: timestamp('removed_at', { withTimezone: true }),
526
+ },
527
+ (table) => [
528
+ primaryKey({ name: 'sync_keys_pkey', columns: [table.syncId, table.key] }),
529
+ foreignKey({
530
+ name: 'sync_keys_sync_id_fkey',
531
+ columns: [table.syncId],
532
+ foreignColumns: [syncs.id],
533
+ }).onDelete('restrict'),
534
+ foreignKey({
535
+ name: 'sync_keys_secret_version_id_fkey',
536
+ columns: [table.secretVersionId],
537
+ foreignColumns: [secretVersions.id],
538
+ }).onDelete('restrict'),
539
+ ],
540
+ );
541
+
542
+ // For Drizzle's relational queries; see relations.ts.
543
+ export const {
544
+ environmentsRelations,
545
+ secretsRelations,
546
+ syncsRelations,
547
+ syncKeysRelations,
548
+ } = relationsOf({ projects, environments, secrets, secretVersions, syncs, syncKeys });
@@ -0,0 +1,122 @@
1
+ import assert from 'node:assert/strict';
2
+ import pg from 'pg';
3
+
4
+ import { postgresConnection } from './postgres.ts';
5
+
6
+ function requiredEnvironment(name: string): string {
7
+ const value = process.env[name]?.trim();
8
+ if (!value) throw new Error(`${name} is required`);
9
+ return value;
10
+ }
11
+
12
+ const expectedRole = requiredEnvironment('COFFRE_RUNTIME_ROLE');
13
+ const pool = new pg.Pool({
14
+ application_name: 'coffre-runtime-verification',
15
+ ...postgresConnection(requiredEnvironment('DATABASE_URL')),
16
+ max: 1,
17
+ });
18
+
19
+ try {
20
+ const identity = await pool.query<{
21
+ current_user: string;
22
+ memberships: string[];
23
+ safe_membership: boolean;
24
+ unsafe_attributes: boolean;
25
+ }>(
26
+ `SELECT current_user,
27
+ (
28
+ role.rolsuper OR role.rolcreatedb OR role.rolcreaterole
29
+ OR role.rolreplication OR role.rolbypassrls
30
+ ) AS unsafe_attributes,
31
+ ARRAY(
32
+ SELECT granted.rolname
33
+ FROM pg_auth_members membership
34
+ JOIN pg_roles granted ON granted.oid = membership.roleid
35
+ WHERE membership.member = role.oid
36
+ ORDER BY granted.rolname
37
+ )::text[] AS memberships,
38
+ (
39
+ SELECT NOT membership.admin_option
40
+ AND membership.inherit_option
41
+ AND NOT membership.set_option
42
+ FROM pg_auth_members membership
43
+ JOIN pg_roles granted ON granted.oid = membership.roleid
44
+ WHERE membership.member = role.oid
45
+ AND granted.rolname = 'coffre_app'
46
+ ) AS safe_membership
47
+ FROM pg_roles role
48
+ WHERE role.rolname = current_user`,
49
+ );
50
+ assert.deepEqual(identity.rows, [{
51
+ current_user: expectedRole,
52
+ memberships: ['coffre_app'],
53
+ safe_membership: true,
54
+ unsafe_attributes: false,
55
+ }]);
56
+
57
+ const ownership = await pool.query<{ object_count: number }>(
58
+ `SELECT count(*)::int AS object_count
59
+ FROM pg_shdepend dependency
60
+ JOIN pg_roles role ON role.oid = dependency.refobjid
61
+ WHERE dependency.refclassid = 'pg_authid'::regclass
62
+ AND dependency.deptype = 'o'
63
+ AND role.rolname = current_user`,
64
+ );
65
+ assert.equal(ownership.rows[0]?.object_count, 0, 'runtime role owns database objects');
66
+
67
+ const privileges = await pool.query<{
68
+ audit_delete: boolean;
69
+ audit_truncate: boolean;
70
+ audit_update: boolean;
71
+ database_create: boolean;
72
+ database_temporary: boolean;
73
+ head_next_seq_update: boolean;
74
+ head_hash_update: boolean;
75
+ migrations_select: boolean;
76
+ schema_create: boolean;
77
+ }>(
78
+ `SELECT has_database_privilege(current_user, current_database(), 'CREATE') AS database_create,
79
+ has_database_privilege(current_user, current_database(), 'TEMPORARY') AS database_temporary,
80
+ has_schema_privilege(current_user, 'public', 'CREATE') AS schema_create,
81
+ has_table_privilege(current_user, 'public.audit_log', 'UPDATE') AS audit_update,
82
+ has_table_privilege(current_user, 'public.audit_log', 'DELETE') AS audit_delete,
83
+ has_table_privilege(current_user, 'public.audit_log', 'TRUNCATE') AS audit_truncate,
84
+ has_column_privilege(
85
+ current_user, 'public.audit_chain_head', 'next_seq', 'UPDATE'
86
+ ) AS head_next_seq_update,
87
+ has_column_privilege(
88
+ current_user, 'public.audit_chain_head', 'head_hash', 'UPDATE'
89
+ ) AS head_hash_update,
90
+ has_table_privilege(
91
+ current_user, 'drizzle.__drizzle_migrations', 'SELECT'
92
+ ) AS migrations_select`,
93
+ );
94
+ assert.deepEqual(privileges.rows, [{
95
+ audit_delete: false,
96
+ audit_truncate: false,
97
+ audit_update: false,
98
+ database_create: false,
99
+ database_temporary: false,
100
+ head_next_seq_update: true,
101
+ head_hash_update: true,
102
+ migrations_select: true,
103
+ schema_create: false,
104
+ }]);
105
+
106
+ const client = await pool.connect();
107
+ try {
108
+ await client.query('BEGIN');
109
+ // What every append does to the log's head, rolled back.
110
+ const head = await client.query(
111
+ `UPDATE audit_chain_head SET next_seq = next_seq, head_hash = head_hash WHERE only_row`,
112
+ );
113
+ assert.equal(head.rowCount, 1, 'runtime head update did not affect the singleton');
114
+ } finally {
115
+ await client.query('ROLLBACK');
116
+ client.release();
117
+ }
118
+
119
+ console.log(`runtime database role ${expectedRole} passed privilege verification`);
120
+ } finally {
121
+ await pool.end();
122
+ }