@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.
- package/LICENSE +21 -0
- package/README.md +19 -3
- package/dist/commits-BuW-NL3n.js +30 -0
- package/dist/connect.d.ts +26 -0
- package/dist/connect.js +156 -0
- package/dist/database-CWpo5aF0.d.ts +39 -0
- package/dist/dialect.d.ts +88 -0
- package/dist/dialect.js +128 -0
- package/dist/hyperdrive.d.ts +32 -0
- package/dist/hyperdrive.js +54 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +34 -0
- package/dist/log.d.ts +72 -0
- package/dist/log.js +133 -0
- package/dist/migrate.d.ts +10 -0
- package/dist/migrate.js +130 -0
- package/dist/migrations/postgres/0000_baseline.sql +505 -0
- package/dist/migrations/postgres/meta/0000_snapshot.json +1939 -0
- package/dist/migrations/postgres/meta/_journal.json +13 -0
- package/dist/migrations/sqlite/0000_baseline.sql +293 -0
- package/dist/migrations/sqlite/meta/0000_snapshot.json +1811 -0
- package/dist/migrations/sqlite/meta/_journal.json +13 -0
- package/dist/portable.d.ts +33 -0
- package/dist/portable.js +12 -0
- package/dist/postgres-Bmar8y0q.js +29 -0
- package/dist/relations-DqgxtlKu.js +58 -0
- package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
- package/dist/schema-Cd4WOnB5.d.ts +2503 -0
- package/dist/schema-sqlite.d.ts +2587 -0
- package/dist/schema-sqlite.js +401 -0
- package/dist/schema-version.d.ts +11 -0
- package/dist/schema-version.js +16 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +448 -0
- package/package.json +92 -5
- package/src/baseline/postgres.sql +241 -0
- package/src/baseline/sqlite.sql +27 -0
- package/src/baseline.ts +104 -0
- package/src/commits.ts +47 -0
- package/src/connect.ts +171 -0
- package/src/database.ts +63 -0
- package/src/dialect.ts +174 -0
- package/src/drizzle.postgres.config.ts +7 -0
- package/src/drizzle.sqlite.config.ts +7 -0
- package/src/hyperdrive.ts +63 -0
- package/src/log.ts +177 -0
- package/src/migrate-main.ts +8 -0
- package/src/migrate.ts +207 -0
- package/src/migrations/postgres/0000_baseline.sql +505 -0
- package/src/migrations/postgres/meta/0000_snapshot.json +1939 -0
- package/src/migrations/postgres/meta/_journal.json +13 -0
- package/src/migrations/sqlite/0000_baseline.sql +293 -0
- package/src/migrations/sqlite/meta/0000_snapshot.json +1811 -0
- package/src/migrations/sqlite/meta/_journal.json +13 -0
- package/src/portable.ts +54 -0
- package/src/postgres.ts +35 -0
- package/src/relations.ts +53 -0
- package/src/schema-version.ts +13 -0
- package/src/schema.sqlite.ts +511 -0
- package/src/schema.ts +548 -0
- 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
|
+
}
|