@coffre/db 0.14.2 → 0.14.4

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 (40) hide show
  1. package/dist/connect.d.ts +1 -1
  2. package/dist/{database-Ds1Y79h6.d.ts → database-CFhTnnzM.d.ts} +1 -1
  3. package/dist/dialect.d.ts +4 -3
  4. package/dist/dialect.js +1 -0
  5. package/dist/grants.d.ts +1 -1
  6. package/dist/hyperdrive.d.ts +1 -1
  7. package/dist/index.d.ts +2 -2
  8. package/dist/log.d.ts +1 -1
  9. package/dist/migrate.d.ts +21 -5
  10. package/dist/migrate.js +49 -9
  11. package/dist/migrations/postgres/0019_backup_role.sql +63 -0
  12. package/dist/migrations/postgres/0020_vault_releases.sql +36 -0
  13. package/dist/migrations/postgres/meta/0019_snapshot.json +3295 -0
  14. package/dist/migrations/postgres/meta/0020_snapshot.json +3334 -0
  15. package/dist/migrations/postgres/meta/_journal.json +14 -0
  16. package/dist/migrations/sqlite/0017_vault_releases.sql +26 -0
  17. package/dist/migrations/sqlite/meta/0017_snapshot.json +3037 -0
  18. package/dist/migrations/sqlite/meta/_journal.json +7 -0
  19. package/dist/portable.d.ts +2 -2
  20. package/dist/schema-sqlite.d.ts +75 -1
  21. package/dist/schema-sqlite.js +21 -1
  22. package/dist/schema-version.d.ts +1 -1
  23. package/dist/schema-version.js +21 -0
  24. package/dist/{schema-DcVkJhCf.d.ts → schema-z8GcN_Xu.d.ts} +72 -2
  25. package/dist/schema.d.ts +2 -2
  26. package/dist/schema.js +23 -2
  27. package/package.json +2 -2
  28. package/src/dialect.ts +3 -2
  29. package/src/migrate-main.ts +6 -3
  30. package/src/migrate.ts +49 -10
  31. package/src/migrations/postgres/0019_backup_role.sql +63 -0
  32. package/src/migrations/postgres/0020_vault_releases.sql +36 -0
  33. package/src/migrations/postgres/meta/0019_snapshot.json +3295 -0
  34. package/src/migrations/postgres/meta/0020_snapshot.json +3334 -0
  35. package/src/migrations/postgres/meta/_journal.json +14 -0
  36. package/src/migrations/sqlite/0017_vault_releases.sql +26 -0
  37. package/src/migrations/sqlite/meta/0017_snapshot.json +3037 -0
  38. package/src/migrations/sqlite/meta/_journal.json +7 -0
  39. package/src/schema.sqlite.ts +22 -0
  40. package/src/schema.ts +24 -0
@@ -120,6 +120,13 @@
120
120
  "when": 1792835446455,
121
121
  "tag": "0016_capped_alerts",
122
122
  "breakpoints": true
123
+ },
124
+ {
125
+ "idx": 17,
126
+ "version": "6",
127
+ "when": 1793017781451,
128
+ "tag": "0017_vault_releases",
129
+ "breakpoints": true
123
130
  }
124
131
  ]
125
132
  }
@@ -1,5 +1,5 @@
1
- import { g as schema_d_exports } from "./schema-DcVkJhCf.js";
2
- import { t as Database } from "./database-Ds1Y79h6.js";
1
+ import { g as schema_d_exports } from "./schema-z8GcN_Xu.js";
2
+ import { t as Database } from "./database-CFhTnnzM.js";
3
3
  import { t as schema_sqlite_d_exports } from "./schema-sqlite.js";
4
4
  import { LibSQLDatabase } from "drizzle-orm/libsql";
5
5
  //#region src/portable.d.ts
@@ -1,5 +1,5 @@
1
1
  declare namespace schema_sqlite_d_exports {
2
- export { auditChainHead, auditLog, cappedAlerts, consumedTokens, credentials, deviceAuthorizations, dismissedKeys, enrollments, environments, environmentsRelations, identities, mcpApprovals, mcpConnections, oauthClients, projects, secretReferences, secretVersions, secrets, secretsRelations, serviceBindings, vaultGrants, vaultMembers };
2
+ export { auditChainHead, auditLog, cappedAlerts, consumedTokens, credentials, deviceAuthorizations, dismissedKeys, enrollments, environments, environmentsRelations, identities, mcpApprovals, mcpConnections, oauthClients, projects, secretReferences, secretVersions, secrets, secretsRelations, serviceBindings, vaultGrants, vaultMembers, vaultReleases };
3
3
  }
4
4
  export declare const projects: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
5
5
  name: "projects";
@@ -1292,6 +1292,80 @@ export declare const vaultGrants: import("drizzle-orm/sqlite-core").SQLiteTableW
1292
1292
  };
1293
1293
  dialect: "sqlite";
1294
1294
  }>;
1295
+ /**
1296
+ * Each key the vault released to a principal, and when last: what its bulk
1297
+ * limit counts (R28), the distinct keys released within a window, without
1298
+ * reading the log. The database keeps it from the log itself, in the
1299
+ * transaction of each vault `secret.read` allowed: a trigger on
1300
+ * `audit_log` upserts a row for each key the entry names, as the count
1301
+ * read them from the entries before (an entry that names none, which only
1302
+ * a forged one is, counts as a key of its own, `entry:<seq>`). No login
1303
+ * writes it: the vault and the backup read it. A row a window old counts
1304
+ * for nothing, so nothing prunes them: there are at most as many as
1305
+ * principals times keys, and a count reads one principal's.
1306
+ */
1307
+ export declare const vaultReleases: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
1308
+ name: "vault_releases";
1309
+ schema: undefined;
1310
+ columns: {
1311
+ principal: import("drizzle-orm/sqlite-core").SQLiteColumn<{
1312
+ name: "principal";
1313
+ tableName: "vault_releases";
1314
+ dataType: "string";
1315
+ columnType: "SQLiteText";
1316
+ data: string;
1317
+ driverParam: string;
1318
+ notNull: true;
1319
+ hasDefault: false;
1320
+ isPrimaryKey: false;
1321
+ isAutoincrement: false;
1322
+ hasRuntimeDefault: false;
1323
+ enumValues: [string, ...string[]];
1324
+ baseColumn: never;
1325
+ identity: undefined;
1326
+ generated: undefined;
1327
+ }, {}, {
1328
+ length: undefined;
1329
+ }>;
1330
+ key: import("drizzle-orm/sqlite-core").SQLiteColumn<{
1331
+ name: "key";
1332
+ tableName: "vault_releases";
1333
+ dataType: "string";
1334
+ columnType: "SQLiteText";
1335
+ data: string;
1336
+ driverParam: string;
1337
+ notNull: true;
1338
+ hasDefault: false;
1339
+ isPrimaryKey: false;
1340
+ isAutoincrement: false;
1341
+ hasRuntimeDefault: false;
1342
+ enumValues: [string, ...string[]];
1343
+ baseColumn: never;
1344
+ identity: undefined;
1345
+ generated: undefined;
1346
+ }, {}, {
1347
+ length: undefined;
1348
+ }>;
1349
+ releasedAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
1350
+ name: "released_at";
1351
+ tableName: "vault_releases";
1352
+ dataType: "number";
1353
+ columnType: "SQLiteInteger";
1354
+ data: number;
1355
+ driverParam: number;
1356
+ notNull: true;
1357
+ hasDefault: false;
1358
+ isPrimaryKey: false;
1359
+ isAutoincrement: false;
1360
+ hasRuntimeDefault: false;
1361
+ enumValues: undefined;
1362
+ baseColumn: never;
1363
+ identity: undefined;
1364
+ generated: undefined;
1365
+ }, {}, {}>;
1366
+ };
1367
+ dialect: "sqlite";
1368
+ }>;
1295
1369
  export declare const auditLog: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
1296
1370
  name: "audit_log";
1297
1371
  schema: undefined;
@@ -214,6 +214,26 @@ const vaultGrants = sqliteTable("vault_grants", {
214
214
  foreignColumns: [environments.id]
215
215
  }).onDelete("restrict")
216
216
  ]);
217
+ /**
218
+ * Each key the vault released to a principal, and when last: what its bulk
219
+ * limit counts (R28), the distinct keys released within a window, without
220
+ * reading the log. The database keeps it from the log itself, in the
221
+ * transaction of each vault `secret.read` allowed: a trigger on
222
+ * `audit_log` upserts a row for each key the entry names, as the count
223
+ * read them from the entries before (an entry that names none, which only
224
+ * a forged one is, counts as a key of its own, `entry:<seq>`). No login
225
+ * writes it: the vault and the backup read it. A row a window old counts
226
+ * for nothing, so nothing prunes them: there are at most as many as
227
+ * principals times keys, and a count reads one principal's.
228
+ */
229
+ const vaultReleases = sqliteTable("vault_releases", {
230
+ principal: text().notNull(),
231
+ key: text().notNull(),
232
+ releasedAt: integer("released_at", { mode: "number" }).notNull()
233
+ }, (table) => [primaryKey({
234
+ name: "vault_releases_pkey",
235
+ columns: [table.principal, table.key]
236
+ })]);
217
237
  const auditLog = sqliteTable("audit_log", {
218
238
  seq: int64("seq").primaryKey(),
219
239
  author: text().notNull(),
@@ -616,4 +636,4 @@ const { environmentsRelations, secretsRelations } = relationsOf(asPostgres({
616
636
  secretVersions
617
637
  }));
618
638
  //#endregion
619
- export { auditChainHead, auditLog, cappedAlerts, consumedTokens, credentials, deviceAuthorizations, dismissedKeys, enrollments, environments, environmentsRelations, identities, mcpApprovals, mcpConnections, oauthClients, projects, secretReferences, secretVersions, secrets, secretsRelations, serviceBindings, vaultGrants, vaultMembers };
639
+ export { auditChainHead, auditLog, cappedAlerts, consumedTokens, credentials, deviceAuthorizations, dismissedKeys, enrollments, environments, environmentsRelations, identities, mcpApprovals, mcpConnections, oauthClients, projects, secretReferences, secretVersions, secrets, secretsRelations, serviceBindings, vaultGrants, vaultMembers, vaultReleases };
@@ -1,4 +1,4 @@
1
- import { i as Queryable } from "./database-Ds1Y79h6.js";
1
+ import { i as Queryable } from "./database-CFhTnnzM.js";
2
2
  import { Engine } from "./dialect.js";
3
3
  //#region src/schema-version.d.ts
4
4
  /**
@@ -141,6 +141,20 @@ const KNOWN_MIGRATIONS = {
141
141
  "when": 1792871149580,
142
142
  "tag": "0018_rotate_app_key",
143
143
  "breakpoints": true
144
+ },
145
+ {
146
+ "idx": 19,
147
+ "version": "7",
148
+ "when": 1792937576477,
149
+ "tag": "0019_backup_role",
150
+ "breakpoints": true
151
+ },
152
+ {
153
+ "idx": 20,
154
+ "version": "7",
155
+ "when": 1793017780802,
156
+ "tag": "0020_vault_releases",
157
+ "breakpoints": true
144
158
  }
145
159
  ].map((entry) => entry.tag),
146
160
  sqlite: [
@@ -262,6 +276,13 @@ const KNOWN_MIGRATIONS = {
262
276
  "when": 1792835446455,
263
277
  "tag": "0016_capped_alerts",
264
278
  "breakpoints": true
279
+ },
280
+ {
281
+ "idx": 17,
282
+ "version": "6",
283
+ "when": 1793017781451,
284
+ "tag": "0017_vault_releases",
285
+ "breakpoints": true
265
286
  }
266
287
  ].map((entry) => entry.tag)
267
288
  };
@@ -1,5 +1,5 @@
1
1
  declare namespace schema_d_exports {
2
- export { auditChainHead, auditLog, cappedAlerts, consumedTokens, credentials, deviceAuthorizations, dismissedKeys, enrollments, environments, environmentsRelations, identities, mcpApprovals, mcpConnections, oauthClients, projects, secretReferences, secretVersions, secrets, secretsRelations, serviceBindings, vaultGrants, vaultMembers };
2
+ export { auditChainHead, auditLog, cappedAlerts, consumedTokens, credentials, deviceAuthorizations, dismissedKeys, enrollments, environments, environmentsRelations, identities, mcpApprovals, mcpConnections, oauthClients, projects, secretReferences, secretVersions, secrets, secretsRelations, serviceBindings, vaultGrants, vaultMembers, vaultReleases };
3
3
  }
4
4
  declare const projects: import("drizzle-orm/pg-core").PgTableWithColumns<{
5
5
  name: "projects";
@@ -1238,6 +1238,76 @@ declare const vaultGrants: import("drizzle-orm/pg-core").PgTableWithColumns<{
1238
1238
  };
1239
1239
  dialect: "pg";
1240
1240
  }>;
1241
+ /**
1242
+ * Each key the vault released to a principal, and when last: what its bulk
1243
+ * limit counts (R28), the distinct keys released within a window, without
1244
+ * reading the log. The database keeps it from the log itself, in the
1245
+ * transaction of each vault `secret.read` allowed: a trigger on
1246
+ * `audit_log` upserts a row for each key the entry names, as the count
1247
+ * read them from the entries before (an entry that names none, which only
1248
+ * a forged one is, counts as a key of its own, `entry:<seq>`). No login
1249
+ * writes it: the vault and the backup read it. A row a window old counts
1250
+ * for nothing, so nothing prunes them: there are at most as many as
1251
+ * principals times keys, and a count reads one principal's.
1252
+ */
1253
+ declare const vaultReleases: import("drizzle-orm/pg-core").PgTableWithColumns<{
1254
+ name: "vault_releases";
1255
+ schema: undefined;
1256
+ columns: {
1257
+ principal: import("drizzle-orm/pg-core").PgColumn<{
1258
+ name: "principal";
1259
+ tableName: "vault_releases";
1260
+ dataType: "string";
1261
+ columnType: "PgText";
1262
+ data: string;
1263
+ driverParam: string;
1264
+ notNull: true;
1265
+ hasDefault: false;
1266
+ isPrimaryKey: false;
1267
+ isAutoincrement: false;
1268
+ hasRuntimeDefault: false;
1269
+ enumValues: [string, ...string[]];
1270
+ baseColumn: never;
1271
+ identity: undefined;
1272
+ generated: undefined;
1273
+ }, {}, {}>;
1274
+ key: import("drizzle-orm/pg-core").PgColumn<{
1275
+ name: "key";
1276
+ tableName: "vault_releases";
1277
+ dataType: "string";
1278
+ columnType: "PgText";
1279
+ data: string;
1280
+ driverParam: string;
1281
+ notNull: true;
1282
+ hasDefault: false;
1283
+ isPrimaryKey: false;
1284
+ isAutoincrement: false;
1285
+ hasRuntimeDefault: false;
1286
+ enumValues: [string, ...string[]];
1287
+ baseColumn: never;
1288
+ identity: undefined;
1289
+ generated: undefined;
1290
+ }, {}, {}>;
1291
+ releasedAt: import("drizzle-orm/pg-core").PgColumn<{
1292
+ name: "released_at";
1293
+ tableName: "vault_releases";
1294
+ dataType: "number";
1295
+ columnType: "PgBigInt53";
1296
+ data: number;
1297
+ driverParam: string | number;
1298
+ notNull: true;
1299
+ hasDefault: false;
1300
+ isPrimaryKey: false;
1301
+ isAutoincrement: false;
1302
+ hasRuntimeDefault: false;
1303
+ enumValues: undefined;
1304
+ baseColumn: never;
1305
+ identity: undefined;
1306
+ generated: undefined;
1307
+ }, {}, {}>;
1308
+ };
1309
+ dialect: "pg";
1310
+ }>;
1241
1311
  declare const auditLog: import("drizzle-orm/pg-core").PgTableWithColumns<{
1242
1312
  name: "audit_log";
1243
1313
  schema: undefined;
@@ -4125,4 +4195,4 @@ declare const environmentsRelations: import("drizzle-orm").Relations<"environmen
4125
4195
  environment: import("drizzle-orm").One<"environments", true>;
4126
4196
  }>;
4127
4197
  //#endregion
4128
- export { vaultMembers as C, vaultGrants as S, secretReferences as _, credentials as a, secretsRelations as b, enrollments as c, identities as d, mcpApprovals as f, schema_d_exports as g, projects as h, consumedTokens as i, environments as l, oauthClients as m, auditLog as n, deviceAuthorizations as o, mcpConnections as p, cappedAlerts as r, dismissedKeys as s, auditChainHead as t, environmentsRelations as u, secretVersions as v, serviceBindings as x, secrets as y };
4198
+ export { vaultMembers as C, vaultGrants as S, secretReferences as _, credentials as a, secretsRelations as b, enrollments as c, identities as d, mcpApprovals as f, schema_d_exports as g, projects as h, consumedTokens as i, environments as l, oauthClients as m, auditLog as n, deviceAuthorizations as o, mcpConnections as p, cappedAlerts as r, dismissedKeys as s, auditChainHead as t, environmentsRelations as u, secretVersions as v, vaultReleases as w, serviceBindings as x, secrets as y };
package/dist/schema.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { C as vaultMembers, S as vaultGrants, _ as secretReferences, a as credentials, b as secretsRelations, c as enrollments, d as identities, f as mcpApprovals, h as projects, i as consumedTokens, l as environments, m as oauthClients, n as auditLog, o as deviceAuthorizations, p as mcpConnections, r as cappedAlerts, s as dismissedKeys, t as auditChainHead, u as environmentsRelations, v as secretVersions, x as serviceBindings, y as secrets } from "./schema-DcVkJhCf.js";
2
- export { auditChainHead, auditLog, cappedAlerts, consumedTokens, credentials, deviceAuthorizations, dismissedKeys, enrollments, environments, environmentsRelations, identities, mcpApprovals, mcpConnections, oauthClients, projects, secretReferences, secretVersions, secrets, secretsRelations, serviceBindings, vaultGrants, vaultMembers };
1
+ import { C as vaultMembers, S as vaultGrants, _ as secretReferences, a as credentials, b as secretsRelations, c as enrollments, d as identities, f as mcpApprovals, h as projects, i as consumedTokens, l as environments, m as oauthClients, n as auditLog, o as deviceAuthorizations, p as mcpConnections, r as cappedAlerts, s as dismissedKeys, t as auditChainHead, u as environmentsRelations, v as secretVersions, w as vaultReleases, x as serviceBindings, y as secrets } from "./schema-z8GcN_Xu.js";
2
+ export { auditChainHead, auditLog, cappedAlerts, consumedTokens, credentials, deviceAuthorizations, dismissedKeys, enrollments, environments, environmentsRelations, identities, mcpApprovals, mcpConnections, oauthClients, projects, secretReferences, secretVersions, secrets, secretsRelations, serviceBindings, vaultGrants, vaultMembers, vaultReleases };
package/dist/schema.js CHANGED
@@ -25,7 +25,8 @@ var schema_exports = /* @__PURE__ */ __exportAll({
25
25
  secretsRelations: () => secretsRelations,
26
26
  serviceBindings: () => serviceBindings,
27
27
  vaultGrants: () => vaultGrants,
28
- vaultMembers: () => vaultMembers
28
+ vaultMembers: () => vaultMembers,
29
+ vaultReleases: () => vaultReleases
29
30
  });
30
31
  const bytea = customType({ dataType: () => "bytea" });
31
32
  const createdAt = () => timestamp("created_at", { withTimezone: true }).notNull().defaultNow();
@@ -240,6 +241,26 @@ const vaultGrants = pgTable("vault_grants", {
240
241
  foreignColumns: [environments.id]
241
242
  }).onDelete("restrict")
242
243
  ]);
244
+ /**
245
+ * Each key the vault released to a principal, and when last: what its bulk
246
+ * limit counts (R28), the distinct keys released within a window, without
247
+ * reading the log. The database keeps it from the log itself, in the
248
+ * transaction of each vault `secret.read` allowed: a trigger on
249
+ * `audit_log` upserts a row for each key the entry names, as the count
250
+ * read them from the entries before (an entry that names none, which only
251
+ * a forged one is, counts as a key of its own, `entry:<seq>`). No login
252
+ * writes it: the vault and the backup read it. A row a window old counts
253
+ * for nothing, so nothing prunes them: there are at most as many as
254
+ * principals times keys, and a count reads one principal's.
255
+ */
256
+ const vaultReleases = pgTable("vault_releases", {
257
+ principal: text().notNull(),
258
+ key: text().notNull(),
259
+ releasedAt: bigint("released_at", { mode: "number" }).notNull()
260
+ }, (table) => [primaryKey({
261
+ name: "vault_releases_pkey",
262
+ columns: [table.principal, table.key]
263
+ })]);
243
264
  const auditLog = pgTable("audit_log", {
244
265
  seq: bigint({ mode: "bigint" }).primaryKey(),
245
266
  author: text().notNull(),
@@ -698,4 +719,4 @@ const { environmentsRelations, secretsRelations } = relationsOf({
698
719
  secretVersions
699
720
  });
700
721
  //#endregion
701
- export { auditChainHead, auditLog, cappedAlerts, consumedTokens, credentials, deviceAuthorizations, dismissedKeys, enrollments, environments, environmentsRelations, identities, mcpApprovals, mcpConnections, oauthClients, projects, secretReferences, secretVersions, secrets, secretsRelations, serviceBindings, schema_exports as t, vaultGrants, vaultMembers };
722
+ export { auditChainHead, auditLog, cappedAlerts, consumedTokens, credentials, deviceAuthorizations, dismissedKeys, enrollments, environments, environmentsRelations, identities, mcpApprovals, mcpConnections, oauthClients, projects, secretReferences, secretVersions, secrets, secretsRelations, serviceBindings, schema_exports as t, vaultGrants, vaultMembers, vaultReleases };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/db",
3
- "version": "0.14.2",
3
+ "version": "0.14.4",
4
4
  "description": "coffre's database: the schema, its migrations and the connections, on Postgres or SQLite.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -79,7 +79,7 @@
79
79
  "drizzle-orm": "0.45.2",
80
80
  "pg": "8.16.3",
81
81
  "pg-connection-string": "2.14.0",
82
- "@coffre/core": "0.14.2"
82
+ "@coffre/core": "0.14.4"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@types/node": "26.1.1",
package/src/dialect.ts CHANGED
@@ -39,11 +39,12 @@ export function engineOf(db: Queryable): Engine {
39
39
  *
40
40
  * `no key update` is for rows others reference by foreign key: it keeps out
41
41
  * every other writer of the row, but not an insert that points at it.
42
+ * `share` keeps out every writer, but not another `share`.
42
43
  */
43
- export function forUpdate<Query extends { for: (strength: 'update' | 'no key update') => unknown }>(
44
+ export function forUpdate<Query extends { for: (strength: 'update' | 'no key update' | 'share') => unknown }>(
44
45
  db: Queryable,
45
46
  query: Query,
46
- strength: 'update' | 'no key update' = 'update',
47
+ strength: 'update' | 'no key update' | 'share' = 'update',
47
48
  ): ReturnType<Query['for']> {
48
49
  if (engineOf(db) === 'sqlite') return query as ReturnType<Query['for']>;
49
50
  return query.for(strength) as ReturnType<Query['for']>;
@@ -1,8 +1,11 @@
1
- // `pnpm db:migrate`: the workspace's own migrator, for dev
2
- // and the tests. A deployment runs `coffre-server migrate`.
1
+ // `pnpm db:migrate [--backup-login <role>]`: the workspace's own migrator,
2
+ // for dev and the tests. A deployment runs `coffre-server migrate`.
3
+ import { parseArgs } from 'node:util';
4
+
3
5
  import { migrateDatabase } from './migrate.ts';
4
6
 
5
7
  const url = process.env.DATABASE_URL?.trim();
6
8
  if (!url) throw new Error('DATABASE_URL is required');
7
- await migrateDatabase(url);
9
+ const { values } = parseArgs({ options: { 'backup-login': { type: 'string' } }, strict: true });
10
+ await migrateDatabase(url, { backupLogin: values['backup-login'] });
8
11
  console.log('database schema is up to date');
package/src/migrate.ts CHANGED
@@ -24,12 +24,33 @@ const MIGRATION_LOCK_KEY = '7165058122361679213';
24
24
  export async function restrictDatabase(client: Pick<PoolClient, 'query'>): Promise<void> {
25
25
  await client.query(`DO $$ BEGIN
26
26
  EXECUTE format(
27
- 'REVOKE CREATE, TEMPORARY ON DATABASE %I FROM PUBLIC, coffre_app, coffre_runtime, coffre_vault, coffre_vault_runtime',
27
+ 'REVOKE CREATE, TEMPORARY ON DATABASE %I FROM PUBLIC, coffre_app, coffre_runtime, coffre_vault, coffre_vault_runtime, coffre_backup',
28
28
  current_database()
29
29
  );
30
30
  END $$`);
31
31
  }
32
32
 
33
+ /** Why `login` cannot be the backup login, before anything runs, or null when it may. */
34
+ export function backupLoginProblem(login: string): string | null {
35
+ if (login === '') return 'the backup login is empty: name the role to grant coffre_backup';
36
+ if (['coffre_backup', 'coffre_app', 'coffre_vault', 'coffre_runtime', 'coffre_vault_runtime'].includes(login)) {
37
+ return `the backup login is ${login}, one of coffre's own roles: name the login that backs up`;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ /**
43
+ * The migrations are applied, by this run or an earlier one, and their
44
+ * ledger checked, but reasserting the privileges after them failed: unlike
45
+ * a migration's failure, this one may have changed the database.
46
+ */
47
+ export class AfterMigrations extends Error {
48
+ constructor(error: unknown) {
49
+ super(`the database is at the migrated schema, but reasserting its privileges failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
50
+ this.name = 'AfterMigrations';
51
+ }
52
+ }
53
+
33
54
  interface JournalEntry {
34
55
  idx: number;
35
56
  when: number;
@@ -55,8 +76,8 @@ interface Migrator {
55
76
  lock(): Promise<void>;
56
77
  unlock(): Promise<void>;
57
78
  migrate(migrationsFolder: string): Promise<void>;
58
- /** Reassert privileges a one-database backup does not carry. */
59
- restrict(): Promise<void>;
79
+ /** Reassert privileges a one-database backup does not carry, and grant `backupLogin` the backup group (0019_backup_role.sql). */
80
+ restrict(backupLogin: string | undefined): Promise<void>;
60
81
  /** Why this login cannot change the objects migrations change, `managed` among them, or null when it can. */
61
82
  notOwner(managed: Managed): Promise<NotOwner | null>;
62
83
  close(): Promise<void>;
@@ -264,7 +285,15 @@ async function postgresMigrator(url: string): Promise<Migrator> {
264
285
  await client.query('SELECT pg_advisory_unlock($1::bigint)', [MIGRATION_LOCK_KEY]);
265
286
  },
266
287
  migrate: (migrationsFolder) => migrate(drizzle(client), { migrationsFolder }),
267
- restrict: () => restrictDatabase(client),
288
+ async restrict(backupLogin) {
289
+ await restrictDatabase(client);
290
+ if (backupLogin !== undefined) {
291
+ // Compared as text: as a name, Postgres would cut it to 63 bytes, and grant whichever role bears the cut.
292
+ const { rowCount } = await client.query('SELECT 1 FROM pg_roles WHERE rolname::text = $1', [backupLogin]);
293
+ if (rowCount === 0) throw new Error(`role ${client.escapeIdentifier(backupLogin)} does not exist: make it, coffre never does`);
294
+ await client.query(`GRANT coffre_backup TO ${client.escapeIdentifier(backupLogin)} WITH ADMIN FALSE, INHERIT TRUE, SET FALSE`);
295
+ }
296
+ },
268
297
  async notOwner({ relations, functions }) {
269
298
  const { rows } = await client.query<{ owner: string; member: boolean; login: string; grants_inherit: boolean; inherits: boolean }>(FOREIGN_OWNERS, [relations, functions]);
270
299
  if (rows.length === 0) return null;
@@ -342,17 +371,23 @@ export async function migrationStatus(url: string): Promise<{ applied: string[];
342
371
  }
343
372
 
344
373
  /**
345
- * Apply missing migrations, check their history and reassert database
346
- * privileges, as a login that can change coffre's objects: another is
347
- * refused, NotOwner, before anything changes. `onPlan` hears what there is to apply before it is, under
374
+ * Apply missing migrations, check their history, and reassert database
375
+ * privileges, on every run, even with nothing to apply, as a login that can
376
+ * change coffre's objects: another is refused, NotOwner, before anything
377
+ * changes. `onPlan` hears what there is to apply before it is, under
348
378
  * the migration lock, and may stop the run by throwing: all of it then goes
349
- * in one call, with no word of each.
379
+ * in one call, with no word of each. `backupLogin` names an existing role to
380
+ * grant the backup group, `coffre_backup`, on Postgres; it is never made, and
381
+ * never revoked when a later run names none.
350
382
  */
351
383
  export async function migrateDatabase(
352
384
  url: string,
353
- onPlan?: (plan: MigrationPlan) => void | Promise<void>,
385
+ { onPlan, backupLogin }: { onPlan?: (plan: MigrationPlan) => void | Promise<void>; backupLogin?: string } = {},
354
386
  ): Promise<void> {
355
387
  const engine = engineOfUrl(url);
388
+ const problem = backupLogin === undefined ? null : backupLoginProblem(backupLogin);
389
+ if (problem !== null) throw new Error(problem);
390
+ if (engine === 'sqlite' && backupLogin !== undefined) throw new Error('SQLite has no roles, so no backup login');
356
391
  const expected = await expectedMigrations(engine);
357
392
  const migrator = await { postgres: postgresMigrator, sqlite: sqliteMigrator }[engine](url);
358
393
  let locked = false;
@@ -369,7 +404,11 @@ export async function migrateDatabase(
369
404
  await onPlan?.({ applied: done, total: expected.length, pending: expected.slice(done).map((entry) => entry.tag) });
370
405
  await migrator.migrate(migrationsFolder(engine));
371
406
  verifyHistory(expected, await migrator.applied(), true);
372
- await migrator.restrict();
407
+ try {
408
+ await migrator.restrict(backupLogin);
409
+ } catch (error) {
410
+ throw new AfterMigrations(error);
411
+ }
373
412
  } finally {
374
413
  try {
375
414
  if (locked) await migrator.unlock();
@@ -0,0 +1,63 @@
1
+ -- Backups (W2356): the group coffre_backup reads every row and writes none, so a nightly pg_dump need not hold the owner's password. No login is made or granted here: `coffre migrate --backup-login <role>` grants the group to the role it names. Reading every table is not enough: the audit log's row-level security shows its rows only to the roles a policy names, so pg_dump as a role that reads every table (pg_read_all_data) is refused the log, or with --enable-row-security dumps no entry. It only adds a role, read grants and a read policy: the release before never meets them.
2
+ DO $$
3
+ BEGIN
4
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'coffre_backup') THEN
5
+ -- No migration since the baseline made a role: a login that migrates as a member of the owner inherits its privileges, never its CREATEROLE.
6
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = current_user AND (rolsuper OR rolcreaterole)) THEN
7
+ RAISE EXCEPTION '% may not create roles, and this migration makes coffre_backup: migrate as a login with CREATEROLE, or have one run CREATE ROLE coffre_backup NOLOGIN; GRANT coffre_backup TO % WITH ADMIN TRUE, INHERIT FALSE, SET FALSE first', current_user, current_user;
8
+ END IF;
9
+ -- Roles are the server's, and the migration lock each database's: another database's 0019 may make it meanwhile.
10
+ BEGIN
11
+ CREATE ROLE coffre_backup NOLOGIN INHERIT NOCREATEDB NOCREATEROLE;
12
+ EXCEPTION WHEN duplicate_object OR unique_violation THEN
13
+ NULL;
14
+ END;
15
+ END IF;
16
+ -- Managed owners cannot ALTER the superuser-only attributes, even to turn them off.
17
+ IF EXISTS (
18
+ SELECT 1 FROM pg_roles WHERE rolname = 'coffre_backup'
19
+ AND (rolsuper OR rolreplication OR rolbypassrls)
20
+ ) THEN
21
+ RAISE EXCEPTION 'coffre_backup has unsafe role attributes: SUPERUSER, REPLICATION and BYPASSRLS must be off';
22
+ END IF;
23
+ -- It would pass on what it is a member of to its members.
24
+ IF EXISTS (SELECT 1 FROM pg_auth_members WHERE member = 'coffre_backup'::regrole) THEN
25
+ RAISE EXCEPTION 'coffre_backup is a member of another role, whose privileges its members would inherit: revoke its memberships';
26
+ END IF;
27
+ -- Revoking cannot take an owner's rights away: its members would write what it owns.
28
+ IF EXISTS (
29
+ SELECT 1 FROM pg_shdepend
30
+ WHERE refclassid = 'pg_authid'::regclass AND refobjid = 'coffre_backup'::regrole AND deptype = 'o'
31
+ ) THEN
32
+ RAISE EXCEPTION 'coffre_backup owns database objects, which its members could change: reassign them';
33
+ END IF;
34
+ -- Only when it must: altering a role takes CREATEROLE too.
35
+ IF EXISTS (
36
+ SELECT 1 FROM pg_roles WHERE rolname = 'coffre_backup'
37
+ AND (rolcanlogin OR NOT rolinherit OR rolcreatedb OR rolcreaterole)
38
+ ) THEN
39
+ ALTER ROLE coffre_backup NOLOGIN INHERIT NOCREATEDB NOCREATEROLE;
40
+ END IF;
41
+ END
42
+ $$;
43
+ --> statement-breakpoint
44
+ -- coffre's own, by name, as the baseline grants: public may hold a database's other tables (FOREIGN_OWNERS in migrate.ts), which are not coffre's to grant, whichever login migrates, a superuser included. A group made before this keeps no other privilege on them. A later migration that makes a table grants it SELECT too, as it grants coffre_app, rather than default privileges, which would give it every table the login makes, coffre's or not, and none another login makes.
45
+ REVOKE ALL ON SCHEMA public, drizzle FROM coffre_backup;
46
+ --> statement-breakpoint
47
+ GRANT USAGE ON SCHEMA public, drizzle TO coffre_backup;
48
+ --> statement-breakpoint
49
+ REVOKE ALL ON audit_chain_head, audit_log, capped_alerts, consumed_tokens, credentials, device_authorizations, dismissed_keys, enrollments, environments, identities, mcp_approvals, mcp_connections, oauth_clients, projects, secret_references, secret_versions, secrets, service_bindings, vault_grants, vault_members FROM coffre_backup;
50
+ --> statement-breakpoint
51
+ REVOKE ALL ON ALL TABLES IN SCHEMA drizzle FROM coffre_backup;
52
+ --> statement-breakpoint
53
+ REVOKE ALL ON ALL SEQUENCES IN SCHEMA drizzle FROM coffre_backup;
54
+ --> statement-breakpoint
55
+ REVOKE ALL ON FUNCTION audit_log_append_only(), secret_versions_erase_only(), prune_consumed_tokens(integer) FROM coffre_backup;
56
+ --> statement-breakpoint
57
+ GRANT SELECT ON audit_chain_head, audit_log, capped_alerts, consumed_tokens, credentials, device_authorizations, dismissed_keys, enrollments, environments, identities, mcp_approvals, mcp_connections, oauth_clients, projects, secret_references, secret_versions, secrets, service_bindings, vault_grants, vault_members TO coffre_backup;
58
+ --> statement-breakpoint
59
+ GRANT SELECT ON ALL TABLES IN SCHEMA drizzle TO coffre_backup;
60
+ --> statement-breakpoint
61
+ GRANT SELECT ON ALL SEQUENCES IN SCHEMA drizzle TO coffre_backup;
62
+ --> statement-breakpoint
63
+ CREATE POLICY audit_log_backup_read ON audit_log FOR SELECT TO coffre_backup USING (true);
@@ -0,0 +1,36 @@
1
+ -- Releases (W199): each key the vault released to a principal, and when last, so that its bulk limit counts the keys released within a window from a row per key, not from every read the principal ever made. The database keeps it, from each vault `secret.read` allowed, in the append's own transaction; the release before neither reads it nor knows it is kept, and its appends keep it too.
2
+ CREATE TABLE "vault_releases" (
3
+ "principal" text NOT NULL,
4
+ "key" text NOT NULL,
5
+ "released_at" bigint NOT NULL,
6
+ CONSTRAINT "vault_releases_pkey" PRIMARY KEY("principal","key")
7
+ );
8
+ --> statement-breakpoint
9
+ -- Room in each page for the next version of its rows: a read moves its keys' times on in place (a HOT update), so the
10
+ -- table and its key stay a row a key, however often each is read: the page prunes the versions before as it is read.
11
+ ALTER TABLE vault_releases SET (fillfactor = 50);--> statement-breakpoint
12
+ -- Each key the entry names, as the count read them before: the secret, or each item of a read's list at one place, or, for an entry that names none, which only a forged one is, the entry itself. As the owner, so that no login may write the table: the vault's appends reach it only through the log.
13
+ CREATE FUNCTION vault_releases_keep() RETURNS trigger
14
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $$
15
+ BEGIN
16
+ INSERT INTO public.vault_releases AS kept (principal, key, released_at)
17
+ SELECT DISTINCT NEW.actor, coalesce(NEW.secret_id::text, CASE jsonb_typeof(one -> 'secretId') WHEN 'string' THEN one ->> 'secretId' END, 'entry:' || NEW.seq), NEW.occurred_at
18
+ FROM (SELECT) AS entry
19
+ LEFT JOIN LATERAL jsonb_path_query(CASE WHEN NEW.secret_id IS NULL THEN NEW.metadata::jsonb END, 'strict $.secrets ? (@.type() == "array")[*]', '{}', true) AS one ON true
20
+ ON CONFLICT (principal, key) DO UPDATE SET released_at = greatest(kept.released_at, excluded.released_at);
21
+ RETURN NULL;
22
+ END
23
+ $$;--> statement-breakpoint
24
+ -- A trigger runs its function whoever may execute it: no login may call it otherwise.
25
+ REVOKE ALL ON FUNCTION vault_releases_keep() FROM PUBLIC;--> statement-breakpoint
26
+ CREATE TRIGGER audit_log_releases AFTER INSERT ON audit_log FOR EACH ROW
27
+ WHEN (NEW.author = 'vault' AND NEW.action = 'secret.read' AND NEW.decision = 'allow')
28
+ EXECUTE FUNCTION vault_releases_keep();--> statement-breakpoint
29
+ -- Then the releases already in the log, in the same transaction: an append waits from the trigger to the commit, and none is missed.
30
+ INSERT INTO vault_releases (principal, key, released_at)
31
+ SELECT e.actor, coalesce(e.secret_id::text, CASE jsonb_typeof(one -> 'secretId') WHEN 'string' THEN one ->> 'secretId' END, 'entry:' || e.seq), max(e.occurred_at)
32
+ FROM audit_log e
33
+ LEFT JOIN LATERAL jsonb_path_query(CASE WHEN e.secret_id IS NULL THEN e.metadata::jsonb END, 'strict $.secrets ? (@.type() == "array")[*]', '{}', true) AS one ON true
34
+ WHERE e.author = 'vault' AND e.action = 'secret.read' AND e.decision = 'allow'
35
+ GROUP BY 1, 2;--> statement-breakpoint
36
+ GRANT SELECT ON vault_releases TO coffre_vault, coffre_backup;