@coffre/vault 0.14.4 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/dist/cloudflare.d.ts +343 -9
- package/dist/cloudflare.js +802 -6
- package/dist/{index-CIq4i_KT.d.ts → index-DQ-FKVF3.d.ts} +4 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/log-entry-BYWqj3O5.d.ts +364 -0
- package/dist/node.d.ts +182 -7
- package/dist/node.js +358 -16
- package/dist/{src-NM5NsYFs.js → src-DheG92EM.js} +9 -2
- package/dist/vault-kmtojYnW.js +14623 -0
- package/package.json +4 -5
- package/src/bulk.ts +89 -0
- package/src/changes.ts +478 -0
- package/src/checkpointer-node.ts +342 -0
- package/src/checkpointer-workers.ts +514 -0
- package/src/checkpointer.ts +1165 -0
- package/src/cloudflare.ts +207 -13
- package/src/config.ts +15 -3
- package/src/credentials.postgres.ts +78 -0
- package/src/credentials.sqlite.ts +240 -0
- package/src/credentials.ts +630 -0
- package/src/cutover.ts +275 -0
- package/src/decide.ts +437 -2
- package/src/inputs.ts +499 -0
- package/src/local.ts +20 -4
- package/src/log-checkpointer.ts +116 -0
- package/src/log-entry.ts +113 -0
- package/src/log-replay.ts +188 -0
- package/src/log-store.ts +695 -0
- package/src/node.ts +73 -9
- package/src/objects.ts +141 -0
- package/src/reads.ts +502 -0
- package/src/retention.ts +229 -0
- package/src/snapshot.postgres.ts +39 -9
- package/src/snapshot.sqlite.ts +28 -3
- package/src/snapshot.ts +72 -12
- package/src/store.ts +862 -37
- package/src/use.postgres.ts +111 -0
- package/src/use.sqlite.ts +542 -0
- package/src/use.ts +1060 -0
- package/src/vault.ts +1513 -240
- package/src/verifier.ts +136 -0
- package/src/witness-workers.ts +163 -0
- package/dist/vault-BBdXsny3.js +0 -5077
package/src/store.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { EVERYWHERE, ownsInstance, type InstanceRole, type Scope } from '@coffre/core/access';
|
|
2
2
|
import type { Author, StoredEntry } from '@coffre/core/audit';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { deletedWithProject, isTombstone, TOMBSTONE } from '@coffre/core/schemas';
|
|
4
|
+
import { ACCESS_ACTIONS, type BindingColumns, type ConnectionColumns, type CredentialColumns, type EnvironmentColumns, type SecretRef, type WrappedKey } from '@coffre/core/vault';
|
|
5
|
+
import { prepared, tablesOf, type Database, type Queryable, type Transaction } from '@coffre/db';
|
|
6
|
+
import { changedRows, clockMillis, engineOf, forUpdate, rowsOf, tombstone, truth } from '@coffre/db/dialect';
|
|
6
7
|
import { readGrants, roleColumns, storedRole, type GrantRow } from '@coffre/db/grants';
|
|
7
8
|
import { entryColumns, withinBytes } from '@coffre/db/log';
|
|
9
|
+
|
|
10
|
+
import { MAX_BULK_WINDOW_MINUTES } from './config.ts';
|
|
11
|
+
import { objectKey, type EnvironmentInput, type Input, type Kind, type Ref } from './inputs.ts';
|
|
12
|
+
import { CUTOVER_OBJECT } from './log-entry.ts';
|
|
13
|
+
import { configObject, environmentObject, memberStanding, principalObject, secretObject } from './objects.ts';
|
|
8
14
|
import { and, asc, count, desc, eq, gt, gte, inArray, isNotNull, isNull, like, lt, lte, or, sql, type SQL, type SQLWrapper } from 'drizzle-orm';
|
|
9
15
|
|
|
10
16
|
/**
|
|
@@ -67,6 +73,33 @@ export async function boundLockWaits(tx: Transaction, ms: number, statements = f
|
|
|
67
73
|
: sql.raw(`SET LOCAL lock_timeout = ${bound}`));
|
|
68
74
|
}
|
|
69
75
|
|
|
76
|
+
/**
|
|
77
|
+
* How long a release is kept: the longest bulk limit window
|
|
78
|
+
* (`MAX_BULK_WINDOW_MINUTES`, a day), and an hour, a margin for the
|
|
79
|
+
* database's clock stepping back between a prune and a later count, which
|
|
80
|
+
* is seconds. Migration 0021's `prune_vault_releases` holds the same, a day
|
|
81
|
+
* and an hour, in its own SQL: a longer window takes a migration too.
|
|
82
|
+
*/
|
|
83
|
+
export const RELEASES_KEPT_MS = MAX_BULK_WINDOW_MINUTES * 60_000 + 3_600_000;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Delete up to `atMost` rows of `vault_releases` released over
|
|
87
|
+
* `RELEASES_KEPT_MS` before the database's clock: what no bulk count can
|
|
88
|
+
* read again. On Postgres the vault's login deletes no row itself, and asks
|
|
89
|
+
* `prune_vault_releases`, which deletes only those (migration 0021). How
|
|
90
|
+
* many went.
|
|
91
|
+
*/
|
|
92
|
+
export async function pruneReleases(db: Queryable, atMost: number): Promise<number> {
|
|
93
|
+
if (engineOf(db) === 'postgres') {
|
|
94
|
+
const [row] = await rowsOf<{ pruned: number }>(db, sql`SELECT prune_vault_releases(${atMost}::int) AS pruned`);
|
|
95
|
+
return row!.pruned;
|
|
96
|
+
}
|
|
97
|
+
const { vaultReleases } = tablesOf(db);
|
|
98
|
+
const old = db.select({ principal: vaultReleases.principal, key: vaultReleases.key }).from(vaultReleases)
|
|
99
|
+
.where(lt(vaultReleases.releasedAt, sql`${clockMillis(db)} - ${RELEASES_KEPT_MS}`)).limit(atMost);
|
|
100
|
+
return changedRows(await db.delete(vaultReleases).where(sql`(${vaultReleases.principal}, ${vaultReleases.key}) IN ${old}`));
|
|
101
|
+
}
|
|
102
|
+
|
|
70
103
|
/** The database's clock, now. */
|
|
71
104
|
export async function now(db: Queryable): Promise<number> {
|
|
72
105
|
const [{ at }] = await clock(db);
|
|
@@ -153,7 +186,9 @@ export async function instanceOwners(db: Queryable): Promise<Member[]> {
|
|
|
153
186
|
* removal off for ever. So each row comes with an advisory lock on its
|
|
154
187
|
* principal, which the lock manager queues in order: a read asked after a
|
|
155
188
|
* writer waits behind it. The same statement takes it, then the row, one
|
|
156
|
-
* principal after another.
|
|
189
|
+
* principal after another. 0.15's `change_input` (0022_log_015) takes the
|
|
190
|
+
* same two in one statement, before the log's head, and
|
|
191
|
+
* scripts/test-schema.sh holds the pair to that order: keep them together.
|
|
157
192
|
*/
|
|
158
193
|
export async function lockMembers(tx: Transaction, principals: readonly string[], shared = false): Promise<Map<string, Member>> {
|
|
159
194
|
const { vaultMembers } = tablesOf(tx);
|
|
@@ -223,12 +258,6 @@ function at(db: Queryable, place: Place) {
|
|
|
223
258
|
);
|
|
224
259
|
}
|
|
225
260
|
|
|
226
|
-
/**
|
|
227
|
-
* Each of these environments that exists, by id, with its project, its
|
|
228
|
-
* slug, what grants on one slug in every project match, and whether it was
|
|
229
|
-
* deleted, alone or with its project: then no key under it opens, or is
|
|
230
|
-
* wrapped, whatever grant would cover it.
|
|
231
|
-
*/
|
|
232
261
|
/** An environment as a decision reads it. */
|
|
233
262
|
export type EnvironmentRow = {
|
|
234
263
|
projectId: string;
|
|
@@ -237,30 +266,143 @@ export type EnvironmentRow = {
|
|
|
237
266
|
parentId: string | null;
|
|
238
267
|
deleted: boolean;
|
|
239
268
|
archived: boolean;
|
|
269
|
+
/** Read when asked (`seals`): what 0.15's log holds the row to. */
|
|
270
|
+
sealed?: EnvironmentSeal;
|
|
240
271
|
};
|
|
241
272
|
|
|
273
|
+
/**
|
|
274
|
+
* What 0.15's log holds an environment's row to (T159): the row's object
|
|
275
|
+
* (`environmentObject`), its seal, and its object's newest change's seal,
|
|
276
|
+
* null for none. The vault checks them before it decides on the row.
|
|
277
|
+
*/
|
|
278
|
+
export type EnvironmentSeal = { object: EnvironmentInput; seal: Buffer | null; newest: Buffer | null };
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Each of these environments that exists, by id, with its project, its
|
|
282
|
+
* slug, what grants on one slug in every project match, and whether it was
|
|
283
|
+
* deleted, alone or with its project: then no key under it opens, or is
|
|
284
|
+
* wrapped, whatever grant would cover it; `all`, every one. With `seals`,
|
|
285
|
+
* what 0.15's log holds each to as well, in the same statement on Postgres.
|
|
286
|
+
*/
|
|
242
287
|
export async function environmentsById(
|
|
243
288
|
db: Queryable,
|
|
244
|
-
ids: readonly string[],
|
|
289
|
+
ids: readonly string[] | 'all',
|
|
290
|
+
{ seals = false }: { seals?: boolean } = {},
|
|
245
291
|
): Promise<Map<string, EnvironmentRow>> {
|
|
246
|
-
if (ids.length === 0) return new Map();
|
|
292
|
+
if (ids !== 'all' && ids.length === 0) return new Map();
|
|
293
|
+
// With seals, on SQLite the rows' newest seals in a statement after, and anywhere those of ids with no row: one snapshot.
|
|
294
|
+
return seals ? oneSnapshot(db, (on) => environmentRows(on, ids, true)) : environmentRows(db, ids, false);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function environmentRows(db: Queryable, ids: readonly string[] | 'all', seals: boolean): Promise<Map<string, EnvironmentRow>> {
|
|
247
298
|
const { environments, projects } = tablesOf(db);
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
299
|
+
const postgres = engineOf(db) === 'postgres';
|
|
300
|
+
const columns = {
|
|
301
|
+
id: environments.id,
|
|
302
|
+
projectId: environments.projectId,
|
|
303
|
+
slug: environments.slug,
|
|
304
|
+
// A personal config's owner, and the environment it is a config of, whose read it takes.
|
|
305
|
+
owner: environments.owner,
|
|
306
|
+
parentId: environments.parentId,
|
|
307
|
+
deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug))!),
|
|
308
|
+
// Only what names a fork asks it: nothing is decided on it.
|
|
309
|
+
archived: truth(or(isNotNull(environments.archivedAt), isNotNull(projects.archivedAt))!),
|
|
310
|
+
};
|
|
311
|
+
const sealColumns = {
|
|
312
|
+
protected: environments.protected,
|
|
313
|
+
ownArchived: truth(isNotNull(environments.archivedAt)),
|
|
314
|
+
projectArchived: truth(isNotNull(projects.archivedAt)),
|
|
315
|
+
seal: environments.seal,
|
|
316
|
+
// One probe of its object's changes, newest first (`log_changes_object_idx`, which holds the seal).
|
|
317
|
+
...(postgres ? { newest: sql<Buffer | Uint8Array | null>`(SELECT l.seal FROM log_changes l WHERE l.object = log_object('environment', ${environments.id}::text) ORDER BY l.number DESC LIMIT 1)` } : {}),
|
|
318
|
+
};
|
|
319
|
+
const rows = (await db
|
|
320
|
+
.select(seals ? { ...columns, ...sealColumns } : columns)
|
|
260
321
|
.from(environments)
|
|
261
322
|
.innerJoin(projects, eq(projects.id, environments.projectId))
|
|
262
|
-
.where(inArray(environments.id, [...new Set(ids)]));
|
|
263
|
-
|
|
323
|
+
.where(ids === 'all' ? undefined : inArray(environments.id, [...new Set(ids)]))) as (Omit<EnvironmentRow, 'sealed'> & { id: string } & Partial<SealedColumns>)[];
|
|
324
|
+
// SQLite has no `log_object`: the newest changes in a statement of their own, by the keys the vault computes.
|
|
325
|
+
const newest = seals && !postgres ? await newestSeals(db, rows.map((row) => ({ kind: 'environment', id: row.id }))) : null;
|
|
326
|
+
// An id asked with no row, which the log holds a change of: deleted outright around it (review r5). A statement more,
|
|
327
|
+
// only when a row is missing.
|
|
328
|
+
const rowless = seals && ids !== 'all' ? [...new Set(ids)].filter((id) => !rows.some((row) => row.id === id)) : [];
|
|
329
|
+
const gone = await loggedEnvironments(db, rowless);
|
|
330
|
+
return new Map<string, EnvironmentRow>([...[...gone].map(([id, last]) => [id, goneEnvironment(id, last)] as const), ...rows.map(({ id, protected: guarded, ownArchived, projectArchived, seal, newest: last, ...environment }) => [id, {
|
|
331
|
+
...environment,
|
|
332
|
+
...(seals ? {
|
|
333
|
+
sealed: environmentSeal(
|
|
334
|
+
{ id, projectId: environment.projectId, slug: environment.slug, owner: environment.owner, parentId: environment.parentId, protected: guarded == null ? null : Boolean(guarded), archived: Boolean(ownArchived), projectArchived: Boolean(projectArchived) },
|
|
335
|
+
seal ?? null,
|
|
336
|
+
newest === null ? last ?? null : newest.get(objectKey({ kind: 'environment', id })) ?? null,
|
|
337
|
+
),
|
|
338
|
+
} : {}),
|
|
339
|
+
}] as const)]);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* A place the log holds a change of, which no row answers to (deleted outright around the log, or its project's): read
|
|
344
|
+
* as a row whose seal is none, which no check passes (`#placeFault`), so that every decision on it fails closed as on
|
|
345
|
+
* any other row the log does not hold, a protection on it kept (review r5). Its other columns name nothing.
|
|
346
|
+
*/
|
|
347
|
+
export function goneEnvironment(id: string, newest: Buffer | null): EnvironmentRow {
|
|
348
|
+
const columns = { id, projectId: '', slug: '', owner: null, parentId: null, protected: null, archived: false, projectArchived: false };
|
|
349
|
+
return { projectId: '', slug: '', owner: null, parentId: null, deleted: false, archived: false, sealed: { object: environmentObject(columns), seal: null, newest } };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Of these environment ids, those the log holds a change of: by its newest seal, null for one that left none. One probe
|
|
354
|
+
* of each object's changes.
|
|
355
|
+
*/
|
|
356
|
+
async function loggedEnvironments(db: Queryable, ids: readonly string[]): Promise<Map<string, Buffer | null>> {
|
|
357
|
+
if (ids.length === 0) return new Map();
|
|
358
|
+
if (engineOf(db) !== 'postgres') {
|
|
359
|
+
const found = await newestSeals(db, ids.map((id) => ({ kind: 'environment', id })));
|
|
360
|
+
return new Map(ids.flatMap((id) => (found.has(objectKey({ kind: 'environment', id })) ? [[id, found.get(objectKey({ kind: 'environment', id })) ?? null] as const] : [])));
|
|
361
|
+
}
|
|
362
|
+
// Still no row as of this statement: one made since the read before is no place deleted (review r6).
|
|
363
|
+
const rows = await rowsOf<{ id: string; seal: Buffer | Uint8Array | null }>(db, sql`SELECT g.id::text AS id, l.seal FROM unnest(${sql.param([...ids])}::uuid[]) AS g (id)
|
|
364
|
+
CROSS JOIN LATERAL (SELECT l.seal FROM log_changes l WHERE l.object = log_object('environment', g.id::text) ORDER BY l.number DESC LIMIT 1) l
|
|
365
|
+
WHERE NOT EXISTS (SELECT 1 FROM environments e JOIN projects p ON p.id = e.project_id WHERE e.id = g.id)`);
|
|
366
|
+
return new Map(rows.map((row) => [row.id, bytes(row.seal)]));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* `read`, on SQLite, where a read of seals takes two statements, in one transaction, one snapshot of the file, so that
|
|
371
|
+
* no change commits between them (review r6): its own, or the one `db` is already. On Postgres as it is: each such read
|
|
372
|
+
* is one statement, and the one more for ids with no row checks again that none answers to them.
|
|
373
|
+
*/
|
|
374
|
+
export function oneSnapshot<T>(db: Queryable, read: (on: Queryable) => Promise<T>): Promise<T> {
|
|
375
|
+
return engineOf(db) === 'postgres' || 'rollback' in db ? read(db) : (db as Database).transaction((tx) => read(tx));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** The columns `environmentsById` reads with `seals`, as the drivers answer them. */
|
|
379
|
+
type SealedColumns = { protected: boolean | number | null; ownArchived: boolean | number; projectArchived: boolean | number; seal: Buffer | Uint8Array | null; newest: Buffer | Uint8Array | null };
|
|
380
|
+
|
|
381
|
+
/** An environment's row as 0.15's log holds it to (`EnvironmentSeal`), from its columns and the two seals as read. */
|
|
382
|
+
export function environmentSeal(columns: EnvironmentColumns, seal: Buffer | Uint8Array | null, newest: Buffer | Uint8Array | null): EnvironmentSeal {
|
|
383
|
+
return { object: environmentObject(columns), seal: bytes(seal), newest: bytes(newest) };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The seal each object's newest change left, by its key in the log
|
|
388
|
+
* (`objectKey`), null for one that left none; an object with no change is
|
|
389
|
+
* not in the map. One probe of each object's changes, newest first.
|
|
390
|
+
*/
|
|
391
|
+
export async function newestSeals(db: Queryable, refs: readonly Ref[]): Promise<Map<string, Buffer | null>> {
|
|
392
|
+
if (refs.length === 0) return new Map();
|
|
393
|
+
const rows = await rowsOf<{ object: string; seal: Buffer | Uint8Array | null }>(db, newestSealsQuery(refs));
|
|
394
|
+
return new Map(rows.map((row) => [row.object, bytes(row.seal)]));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* `newestSeals`' statement, on SQLite: from the keys asked, one JSON list however many (a compound SELECT stops at 500
|
|
399
|
+
* terms, review r2), each its newest change's number by one probe of `log_changes_object_idx` (its `max`), then that
|
|
400
|
+
* row by its number. The same work however long each object's history (R48).
|
|
401
|
+
*/
|
|
402
|
+
export function newestSealsQuery(refs: readonly Ref[]): SQL {
|
|
403
|
+
const keys = JSON.stringify([...new Set(refs.map((ref) => objectKey(ref)))]);
|
|
404
|
+
return sql`SELECT c.object, c.seal FROM json_each(${keys}) k
|
|
405
|
+
JOIN log_changes c ON c.object = k.value AND c.number = (SELECT max(d.number) FROM log_changes d WHERE d.object = k.value)`;
|
|
264
406
|
}
|
|
265
407
|
|
|
266
408
|
export async function deleteGrant(tx: Transaction, principal: string, place: Place): Promise<void> {
|
|
@@ -304,25 +446,20 @@ export async function places(
|
|
|
304
446
|
db: Queryable,
|
|
305
447
|
projectIds: readonly string[],
|
|
306
448
|
environmentIds: readonly string[],
|
|
307
|
-
|
|
308
|
-
|
|
449
|
+
{ seals = false }: { seals?: boolean } = {},
|
|
450
|
+
): Promise<{ projects: Set<string>; environments: Map<string, EnvironmentRow>; deleted: Set<string> }> {
|
|
451
|
+
const { projects } = tablesOf(db);
|
|
309
452
|
const [foundProjects, foundEnvironments] = await Promise.all([
|
|
310
453
|
projectIds.length === 0
|
|
311
454
|
? []
|
|
312
455
|
: db.select({ id: projects.id, deleted: truth(tombstone(projects.slug)) }).from(projects).where(inArray(projects.id, [...projectIds])),
|
|
313
|
-
environmentIds
|
|
314
|
-
? []
|
|
315
|
-
: db
|
|
316
|
-
.select({ id: environments.id, projectId: environments.projectId, slug: environments.slug, deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug))!) })
|
|
317
|
-
.from(environments)
|
|
318
|
-
.innerJoin(projects, eq(projects.id, environments.projectId))
|
|
319
|
-
.where(inArray(environments.id, [...environmentIds])),
|
|
456
|
+
environmentsById(db, environmentIds, { seals }),
|
|
320
457
|
]);
|
|
321
458
|
return {
|
|
322
459
|
projects: new Set(foundProjects.map((row) => row.id)),
|
|
323
|
-
environments:
|
|
460
|
+
environments: foundEnvironments,
|
|
324
461
|
// The projects and environments among them that were deleted, alone or with their project.
|
|
325
|
-
deleted: new Set([...foundProjects, ...foundEnvironments].filter((row) => row.deleted).map((
|
|
462
|
+
deleted: new Set([...foundProjects.filter((row) => row.deleted).map((row) => row.id), ...[...foundEnvironments].filter(([, row]) => row.deleted).map(([id]) => id)]),
|
|
326
463
|
};
|
|
327
464
|
}
|
|
328
465
|
|
|
@@ -447,6 +584,19 @@ export async function endedReferences(db: Queryable, seqs: readonly bigint[]): P
|
|
|
447
584
|
return new Set(rows.map((row) => row.relatedSeq!));
|
|
448
585
|
}
|
|
449
586
|
|
|
587
|
+
/**
|
|
588
|
+
* The vault's allowed `reference.end` entries naming these `reference.create`
|
|
589
|
+
* entries, whole, for a caller to hold to their MACs: a break weighs only
|
|
590
|
+
* authentic ends (a forged one must not stop a genuine break), where a read
|
|
591
|
+
* takes any (`endedReferences`).
|
|
592
|
+
*/
|
|
593
|
+
export async function referenceEnds(db: Queryable, seqs: readonly bigint[]): Promise<StoredEntry[]> {
|
|
594
|
+
if (seqs.length === 0) return [];
|
|
595
|
+
const { auditLog } = tablesOf(db);
|
|
596
|
+
return stored(await db.select(entryColumns(db)).from(auditLog)
|
|
597
|
+
.where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, 'reference.end'), eq(auditLog.decision, 'allow'), inArray(auditLog.relatedSeq, [...new Set(seqs)]))));
|
|
598
|
+
}
|
|
599
|
+
|
|
450
600
|
/** The project each environment is in: a holder's ids, checked against each other. */
|
|
451
601
|
/** Whether a project of this id is there, and not deleted. */
|
|
452
602
|
export async function projectLive(db: Queryable, id: string): Promise<boolean> {
|
|
@@ -773,3 +923,678 @@ function heldAs(secretVersions: ReturnType<typeof tablesOf>['secretVersions'], c
|
|
|
773
923
|
return sql`SELECT ${column} FROM secret_versions same WHERE same.secret_id = ${secretVersions.secretId}
|
|
774
924
|
AND same.iv = ${secretVersions.iv} AND same.auth_tag = ${secretVersions.authTag} ORDER BY same.version DESC LIMIT 1`;
|
|
775
925
|
}
|
|
926
|
+
|
|
927
|
+
// --- 0.15's log: objects and changes ----------------------------------------------
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* An object of 0.15's log as its rows hold it (inputs.ts), and the seal on
|
|
931
|
+
* them; `row` null when it has none. The roles' and the policy's rows are
|
|
932
|
+
* their objects as the change that made them wrote them.
|
|
933
|
+
*
|
|
934
|
+
* Each object is read from its rows as the change path writes them
|
|
935
|
+
* (objects.ts): an environment is deleted when its own slug is a tombstone,
|
|
936
|
+
* not its project's (a project's deletion writes its environments' rows,
|
|
937
|
+
* plan PR 4b), unprotected until a change or the cutover says otherwise,
|
|
938
|
+
* and holds its project's archival apart from its own; a key's source is
|
|
939
|
+
* its newest reference row's until that row has ended.
|
|
940
|
+
*/
|
|
941
|
+
export type HeldObject = { row: Input | null; seal: Buffer | null };
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* 0.15's log as stored, whole: its changes and its uses, each with the
|
|
945
|
+
* columns its replay reads (`replayStored`), bytes as the driver gives them.
|
|
946
|
+
*/
|
|
947
|
+
export async function loggedRows(db: Queryable): Promise<{
|
|
948
|
+
changes: { number: string | number; at: string | number; object: string; payload: string; seal: Uint8Array | null }[];
|
|
949
|
+
uses: { number: string | number; at: string | number; credential: string; secret: string; purpose: number | string; place: string | null }[];
|
|
950
|
+
}> {
|
|
951
|
+
return {
|
|
952
|
+
changes: await rowsOf(db, sql`SELECT number, at, object, payload, seal FROM log_changes`),
|
|
953
|
+
uses: await rowsOf(db, sql`SELECT number, at, credential, secret, purpose, place FROM log_uses`),
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* The kinds whose objects the change path reads: the vault's own, an
|
|
959
|
+
* environment's, a key's (its reference's end), the roles' and the policy's.
|
|
960
|
+
*/
|
|
961
|
+
export type ReadKind = Exclude<Kind, 'credential' | 'connection' | 'binding'>;
|
|
962
|
+
|
|
963
|
+
/** `archived`: a key's rows read even while it is archived, as its unarchive would make them (an end's check, T134). */
|
|
964
|
+
export async function readObject(db: Queryable, ref: Ref & { kind: ReadKind }, { archived = false } = {}): Promise<HeldObject> {
|
|
965
|
+
const { vaultMembers, environments, projects, logConfig } = tablesOf(db);
|
|
966
|
+
switch (ref.kind) {
|
|
967
|
+
case 'principal': {
|
|
968
|
+
const [row] = await db
|
|
969
|
+
.select({ status: vaultMembers.status, generation: vaultMembers.generation, owner: vaultMembers.owner, role: vaultMembers.role, scope: vaultMembers.scope, seal: vaultMembers.seal })
|
|
970
|
+
.from(vaultMembers)
|
|
971
|
+
.where(eq(vaultMembers.principal, ref.id));
|
|
972
|
+
if (row === undefined) return { row: null, seal: null };
|
|
973
|
+
return {
|
|
974
|
+
row: principalObject({ principal: ref.id, status: row.status, generation: row.generation, ...memberStanding(row) }, await readGrants(db, { principal: ref.id })),
|
|
975
|
+
seal: row.seal ?? null,
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
case 'environment': {
|
|
979
|
+
const [row] = await db
|
|
980
|
+
.select({
|
|
981
|
+
projectId: environments.projectId,
|
|
982
|
+
slug: environments.slug,
|
|
983
|
+
owner: environments.owner,
|
|
984
|
+
parentId: environments.parentId,
|
|
985
|
+
protected: environments.protected,
|
|
986
|
+
archivedAt: environments.archivedAt,
|
|
987
|
+
projectArchived: truth(isNotNull(projects.archivedAt)),
|
|
988
|
+
seal: environments.seal,
|
|
989
|
+
})
|
|
990
|
+
.from(environments)
|
|
991
|
+
.leftJoin(projects, eq(projects.id, environments.projectId))
|
|
992
|
+
.where(eq(environments.id, ref.id));
|
|
993
|
+
if (row === undefined) return { row: null, seal: null };
|
|
994
|
+
const { projectId, slug, owner, parentId, archivedAt } = row;
|
|
995
|
+
return {
|
|
996
|
+
row: environmentObject({ id: ref.id, projectId, slug, owner, parentId, protected: row.protected, archived: archivedAt !== null, projectArchived: Boolean(row.projectArchived) }),
|
|
997
|
+
seal: row.seal ?? null,
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
case 'secret': {
|
|
1001
|
+
// Unarchived (unless asked): an archived key is no object (`secret.delete`). Its version's plaintext, for a plain one's digest.
|
|
1002
|
+
const [row] = await rowsOf<{
|
|
1003
|
+
project_id: string; environment_id: string; seal: Buffer | Uint8Array | null; version_id: string | null; source: string | null; reference: string | null;
|
|
1004
|
+
sensitive: boolean | number | null; plaintext: string | null; kek_provider: string | null; kek_id: string | null; kek_version: string | null; wrapped_dek: Buffer | Uint8Array | null;
|
|
1005
|
+
}>(db, sql`SELECT s.project_id, s.environment_id, s.seal, s.current_version_id AS version_id, ${newestSource(sql`s.id`)} AS source,
|
|
1006
|
+
${newestReference(sql`s.id`)} AS reference,
|
|
1007
|
+
v.sensitive, v.plaintext, v.kek_provider, v.kek_id, v.kek_version, v.wrapped_dek
|
|
1008
|
+
FROM secrets s LEFT JOIN secret_versions v ON v.id = s.current_version_id WHERE s.id = ${ref.id} ${archived ? sql`` : sql`AND s.archived_at IS NULL`}`);
|
|
1009
|
+
if (row === undefined) return { row: null, seal: null };
|
|
1010
|
+
const version = row.version_id === null || row.wrapped_dek === null ? null : {
|
|
1011
|
+
id: row.version_id, sensitive: Boolean(row.sensitive), plaintext: row.plaintext,
|
|
1012
|
+
kekProvider: row.kek_provider!, kekId: row.kek_id!, kekVersion: row.kek_version!, wrappedDek: Buffer.from(row.wrapped_dek).toString('base64'),
|
|
1013
|
+
};
|
|
1014
|
+
return {
|
|
1015
|
+
row: secretObject({
|
|
1016
|
+
id: ref.id, projectId: row.project_id, environmentId: row.environment_id,
|
|
1017
|
+
source: row.version_id === null ? row.source : null, reference: row.version_id === null ? row.reference : null, version,
|
|
1018
|
+
}),
|
|
1019
|
+
seal: row.seal === null ? null : Buffer.from(row.seal),
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
case 'roles': case 'policy': {
|
|
1023
|
+
const [row] = await db.select({ value: logConfig.value, seal: logConfig.seal }).from(logConfig).where(eq(logConfig.name, ref.kind));
|
|
1024
|
+
if (row === undefined) return { row: null, seal: null };
|
|
1025
|
+
// Whatever it holds: a value that is not the object fails its seal.
|
|
1026
|
+
return { row: configObject(row.value), seal: row.seal ?? null };
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/** The object's newest change in the log: whether it has one, and the seal it left (HotPathOne's NewestIn). */
|
|
1032
|
+
export async function newestChange(db: Queryable, ref: Ref): Promise<{ seal: Buffer | null } | null> {
|
|
1033
|
+
const { logChanges } = tablesOf(db);
|
|
1034
|
+
const [row] = await db
|
|
1035
|
+
.select({ seal: logChanges.seal })
|
|
1036
|
+
.from(logChanges)
|
|
1037
|
+
.where(eq(logChanges.object, objectKey(ref)))
|
|
1038
|
+
.orderBy(desc(logChanges.number))
|
|
1039
|
+
.limit(1);
|
|
1040
|
+
return row === undefined ? null : { seal: row.seal ?? null };
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/** `newestSealedChanges`' statement, which its cost test explains. */
|
|
1044
|
+
export function newestSealedQuery(refs: readonly Ref[]): SQL {
|
|
1045
|
+
const newest = sql.join(refs.map((ref) => sql`(SELECT d.number FROM log_changes d WHERE d.object = ${objectKey(ref)} AND d.seal IS NOT NULL ORDER BY d.number DESC LIMIT 1)`), sql`, `);
|
|
1046
|
+
return sql`SELECT c.number, c.at, c.object, c.payload, c.seal FROM log_changes c WHERE c.number IN (${newest})`;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Each object's newest change that left a seal, by its key in the log: what
|
|
1051
|
+
* an unarchive restores (an archived key's newest change is its
|
|
1052
|
+
* `secret.delete`, which left none). One probe of each object's changes,
|
|
1053
|
+
* newest first (`log_changes_object_idx`), then its row by number: the
|
|
1054
|
+
* same work however long its history.
|
|
1055
|
+
*/
|
|
1056
|
+
export async function newestSealedChanges(db: Queryable, refs: readonly Ref[]): Promise<Map<string, { number: bigint; at: number; object: string; payload: string; seal: Buffer }>> {
|
|
1057
|
+
if (refs.length === 0) return new Map();
|
|
1058
|
+
const found = await rowsOf<{ number: string | number; at: string | number; object: string; payload: string; seal: Buffer | Uint8Array }>(db, newestSealedQuery(refs));
|
|
1059
|
+
return new Map(found.map((row) => [row.object, { number: BigInt(row.number), at: Number(row.at), object: row.object, payload: row.payload, seal: Buffer.from(row.seal) }]));
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Whether the indexes that keep request ids unique stand: a
|
|
1064
|
+
* second entry under one request is a replay the database refuses, so a
|
|
1065
|
+
* walk need hold only its own stretch to it (`RequestIdChecked`).
|
|
1066
|
+
*/
|
|
1067
|
+
export async function requestsUnique(db: Queryable): Promise<boolean> {
|
|
1068
|
+
// Each index by its definition, so that one of the same name over other columns does not pass: a use's request is
|
|
1069
|
+
// unique by its time and id, a change's by its id.
|
|
1070
|
+
const defined = (text: string) => text.replace(/[`"\s]/g, '').toLowerCase();
|
|
1071
|
+
const wanted = (uses: string | undefined, changes: string | undefined) =>
|
|
1072
|
+
uses !== undefined && changes !== undefined && /^createuniqueindex.*on(only)?(public\.)?log_uses(usingbtree)?\(at,request_id\)$/.test(defined(uses))
|
|
1073
|
+
&& /^createuniqueindex.*on(only)?(public\.)?log_changes(usingbtree)?\(request_id\)$/.test(defined(changes));
|
|
1074
|
+
if (engineOf(db) === 'sqlite') {
|
|
1075
|
+
const rows = await rowsOf<{ name: string; sql: string | null }>(db, sql`SELECT name, sql FROM sqlite_master
|
|
1076
|
+
WHERE type = 'index' AND name IN ('log_uses_request_idx', 'log_changes_request_id_key')`);
|
|
1077
|
+
const of = (name: string) => rows.find((row) => row.name === name)?.sql ?? undefined;
|
|
1078
|
+
return wanted(of('log_uses_request_idx'), of('log_changes_request_id_key'));
|
|
1079
|
+
}
|
|
1080
|
+
const rows = await rowsOf<{ name: string; def: string }>(db, sql`SELECT c.relname AS name, pg_get_indexdef(i.indexrelid) AS def FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
|
|
1081
|
+
WHERE i.indexrelid IN (to_regclass('public.log_uses_request_idx'), to_regclass('public.log_changes_request_id_key')) AND i.indisunique AND i.indisvalid`);
|
|
1082
|
+
const of = (name: string) => rows.find((row) => row.name === name)?.def;
|
|
1083
|
+
return wanted(of('log_uses_request_idx'), of('log_changes_request_id_key'));
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* A key's source as its object holds it: its newest reference row's, unless
|
|
1088
|
+
* that row has ended (`ended_at`, 0029): a reference broken, replaced or
|
|
1089
|
+
* cut. Null for a key with no reference row. `secretId`: the key's id, as
|
|
1090
|
+
* its query names it.
|
|
1091
|
+
*/
|
|
1092
|
+
function newestSource(secretId: SQL): SQL<string | null> {
|
|
1093
|
+
return sql<string | null>`(SELECT CASE WHEN y.ended_at IS NULL THEN y.source_secret_id END FROM secret_references y WHERE y.secret_id = ${secretId} ORDER BY y.created_seq DESC LIMIT 1)`;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/** ... and that row's id, which the object holds beside its source. */
|
|
1097
|
+
function newestReference(secretId: SQL): SQL<string | null> {
|
|
1098
|
+
return sql<string | null>`(SELECT CASE WHEN y.ended_at IS NULL THEN y.id END FROM secret_references y WHERE y.secret_id = ${secretId} ORDER BY y.created_seq DESC LIMIT 1)`;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* The rows' ends of references whose end changes no object, by their ids
|
|
1103
|
+
* (`end_reference`, 0029), written beside 0.14's entry: a holder archived,
|
|
1104
|
+
* holding a value, standing on another reference, or 0.14's alone (no
|
|
1105
|
+
* seal, no change). A standing one of a logged holder is refused: its end
|
|
1106
|
+
* is its holder's change.
|
|
1107
|
+
*/
|
|
1108
|
+
export async function endReferenceRows(tx: Transaction, ids: readonly string[], at: number): Promise<void> {
|
|
1109
|
+
if (ids.length === 0) return;
|
|
1110
|
+
if (!Number.isSafeInteger(at) || at < 0) throw new Error('a reference ends at a time, whole ms since the epoch');
|
|
1111
|
+
if (engineOf(tx) === 'postgres') {
|
|
1112
|
+
await rowsOf(tx, sql`SELECT end_reference(r.id, ${at}::bigint) FROM unnest(${sql.param([...ids])}::uuid[]) AS r(id)`);
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
// One writer at a time on SQLite: no lock to take.
|
|
1116
|
+
const listed = sql.join(ids.map((id) => sql`${id}`), sql`, `);
|
|
1117
|
+
const standing = await rowsOf<{ id: string; holder: string; seal: Buffer | Uint8Array | null }>(tx, sql`SELECT y.id, x.id AS holder, x.seal FROM secret_references y JOIN secrets x ON x.id = y.secret_id
|
|
1118
|
+
WHERE y.id IN (${listed}) AND y.ended_at IS NULL AND x.archived_at IS NULL AND x.current_version_id IS NULL
|
|
1119
|
+
AND y.id = (SELECT z.id FROM secret_references z WHERE z.secret_id = x.id ORDER BY z.created_seq DESC LIMIT 1)`);
|
|
1120
|
+
for (const row of standing) {
|
|
1121
|
+
if (row.seal !== null || (await newestChange(tx, { kind: 'secret', id: row.holder })) !== null) {
|
|
1122
|
+
// Postgres's code for it, `invalid_parameter_value`, as `end_reference` raises it.
|
|
1123
|
+
throw Object.assign(new Error(`reference ${row.id} is its holder's standing one: its end is a change of the holder`), { code: '22023' });
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
await rowsOf(tx, sql`UPDATE secret_references SET ended_at = ${at} WHERE id IN (${listed}) AND ended_at IS NULL`);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* A change as `change_input` takes it: the object, the request it is (a
|
|
1131
|
+
* retry finds its entry), when and by whom, what it does (`payload`, the
|
|
1132
|
+
* op as logged), the object it leaves where the function writes it
|
|
1133
|
+
* (`value`), the seal it was read at and the one it leaves (none: a key
|
|
1134
|
+
* deleted), and the entry's MAC.
|
|
1135
|
+
*/
|
|
1136
|
+
export type ChangeCall = {
|
|
1137
|
+
ref: Ref;
|
|
1138
|
+
requestId: string;
|
|
1139
|
+
at: number;
|
|
1140
|
+
actor: string;
|
|
1141
|
+
payload: string;
|
|
1142
|
+
value: string | null;
|
|
1143
|
+
sealFrom: Buffer | null;
|
|
1144
|
+
seal: Buffer | null;
|
|
1145
|
+
mac: Buffer;
|
|
1146
|
+
};
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Each change, in order, in one round trip, each `change_input` (0023):
|
|
1150
|
+
* its entry's number, or null where the row's seal was no longer the one
|
|
1151
|
+
* read, and nothing was done. A row that is not its object's newest
|
|
1152
|
+
* change's fails the call (`isStale`), and its transaction with it.
|
|
1153
|
+
*
|
|
1154
|
+
* On Postgres, the calls are rows of one `VALUES`, scanned in order. On
|
|
1155
|
+
* SQLite, which has no functions, `changeOnSqlite` does what it does, in
|
|
1156
|
+
* the call's transaction, which holds the database's one write lock: the
|
|
1157
|
+
* fence there is implicit (plan, "Postgres serves; SQLite is for tests").
|
|
1158
|
+
*/
|
|
1159
|
+
export async function changeInputs(tx: Transaction, calls: readonly ChangeCall[]): Promise<(bigint | null)[]> {
|
|
1160
|
+
if (calls.length === 0) return [];
|
|
1161
|
+
if (engineOf(tx) === 'sqlite') {
|
|
1162
|
+
const numbers: (bigint | null)[] = [];
|
|
1163
|
+
for (const call of calls) numbers.push(await changeOnSqlite(tx, call));
|
|
1164
|
+
return numbers;
|
|
1165
|
+
}
|
|
1166
|
+
const rows = sql.join(calls.map((call, i) => sql`(${i}::int, ${call.ref.kind}::text, ${call.ref.id}::text, ${call.requestId}::text, ${call.at}::bigint, ${call.actor}::text, ${call.payload}::text, ${call.value}::text, ${call.sealFrom}::bytea, ${call.seal}::bytea, ${call.mac}::bytea)`), sql`, `);
|
|
1167
|
+
// In order: one decision's changes to one object chain, each from the seal the one before leaves. A volatile
|
|
1168
|
+
// function in the select list runs after the sort (Postgres 9.6 on), so each call runs in `ord`'s order.
|
|
1169
|
+
const numbers = await rowsOf<{ ord: number; number: string | number | null }>(tx, sql`
|
|
1170
|
+
SELECT c.ord, change_input(c.kind, c.id, c.request, c.at, c.actor, c.payload, c.value, c.seal_from, c.seal, c.mac) AS number
|
|
1171
|
+
FROM (VALUES ${rows}) AS c(ord, kind, id, request, at, actor, payload, value, seal_from, seal, mac)
|
|
1172
|
+
ORDER BY c.ord`);
|
|
1173
|
+
if (numbers.some(({ ord }, i) => Number(ord) !== i)) throw new Error('change_input answered out of order');
|
|
1174
|
+
return numbers.map(({ number }) => (number === null ? null : BigInt(number)));
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/** Postgres's `data_corrupted`, which `change_input` raises on a row that is not its object's newest change's. */
|
|
1178
|
+
const STALE = 'XX001';
|
|
1179
|
+
|
|
1180
|
+
/** Whether `error` is `change_input`'s refusal of a stale row (ChangeChecksSeal). */
|
|
1181
|
+
export function isStale(error: unknown): boolean {
|
|
1182
|
+
for (let cause = error; typeof cause === 'object' && cause !== null; cause = (cause as { cause?: unknown }).cause) {
|
|
1183
|
+
if (cause instanceof StaleRow || (cause as { code?: unknown }).code === STALE) return true;
|
|
1184
|
+
}
|
|
1185
|
+
return false;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
class StaleRow extends Error {}
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* The ops `change_input` takes, by kind (0023, 0029, 0030): the cutover's genesis on any. A key's: its reference's
|
|
1192
|
+
* end, and an archived holder's object deleted again after its end.
|
|
1193
|
+
*/
|
|
1194
|
+
const KNOWN_OPS: Record<Kind, readonly string[]> = {
|
|
1195
|
+
principal: ['member.add', 'member.role', 'member.remove', 'member.restore', 'access.grant', 'access.revoke'],
|
|
1196
|
+
environment: ['environment.protect', 'environment.rename', 'environment.archive'],
|
|
1197
|
+
roles: ['role.define'],
|
|
1198
|
+
policy: ['bulk.limit'],
|
|
1199
|
+
credential: [],
|
|
1200
|
+
// The app's changes alone make and end a connection (`app_change`): the change path seals 0.14's at the cutover.
|
|
1201
|
+
connection: [],
|
|
1202
|
+
// The vault's own statements change a binding (`change_bindings`), never the change path.
|
|
1203
|
+
binding: [],
|
|
1204
|
+
secret: ['secret.write', 'secret.delete'],
|
|
1205
|
+
};
|
|
1206
|
+
|
|
1207
|
+
/** `change_input`, step for step, on SQLite. */
|
|
1208
|
+
async function changeOnSqlite(tx: Transaction, call: ChangeCall): Promise<bigint | null> {
|
|
1209
|
+
const { logChanges, logUses, vaultMembers, environments, projects, logConfig, credentials, mcpConnections, serviceBindings, secrets, secretReferences } = tablesOf(tx);
|
|
1210
|
+
const { ref } = call;
|
|
1211
|
+
const target = objectKey(ref);
|
|
1212
|
+
const [prior] = await tx.select({ number: logChanges.number, object: logChanges.object, payload: logChanges.payload, seal: logChanges.seal }).from(logChanges).where(eq(logChanges.requestId, call.requestId));
|
|
1213
|
+
if (prior !== undefined) {
|
|
1214
|
+
if (prior.object !== target || prior.payload !== call.payload || !sameBytes(prior.seal ?? null, call.seal)) {
|
|
1215
|
+
throw Object.assign(new Error(`request ${call.requestId} names another change`), { code: 'SQLITE_CONSTRAINT_UNIQUE' });
|
|
1216
|
+
}
|
|
1217
|
+
return BigInt(prior.number);
|
|
1218
|
+
}
|
|
1219
|
+
const op = (JSON.parse(call.payload) as { op?: unknown }).op;
|
|
1220
|
+
if (ref.kind === 'binding') throw new Error('a binding changes by the vault\'s own statements alone (`change_bindings`)');
|
|
1221
|
+
if (typeof op !== 'string' || (op !== 'genesis' && !KNOWN_OPS[ref.kind].includes(op))) throw new Error(`this schema knows no change ${String(op)} to a ${ref.kind}`);
|
|
1222
|
+
const named = (JSON.parse(call.payload) as { ref?: { kind?: unknown; id?: unknown } }).ref;
|
|
1223
|
+
if ((op !== 'genesis' || named !== undefined) && (named?.kind !== ref.kind || named.id !== ref.id)) throw new Error(`a change names the object it is to: ${ref.kind}, ${ref.id}`);
|
|
1224
|
+
const value = call.value === null ? null : (JSON.parse(call.value) as Record<string, unknown>);
|
|
1225
|
+
const valued = ref.kind === 'environment' ? op !== 'genesis' && value === null
|
|
1226
|
+
: ref.kind === 'secret' ? (op === 'secret.write') === (value === null)
|
|
1227
|
+
: ref.kind === 'roles' || ref.kind === 'policy' ? (op === 'genesis') === (value !== null) : value !== null;
|
|
1228
|
+
if (valued) throw new Error(`a ${ref.kind} change ${op} carries the object it leaves exactly when change_input writes it`);
|
|
1229
|
+
// ChangeChecksSeal.
|
|
1230
|
+
const held = await heldSeal(tx, ref);
|
|
1231
|
+
const newest = await newestChange(tx, ref);
|
|
1232
|
+
if (!sameBytes(held, newest?.seal ?? null)) throw new StaleRow(`the ${ref.kind} object's row is not its newest change's: stale`);
|
|
1233
|
+
// ChangeCAS.
|
|
1234
|
+
const from = (column: SQLWrapper) => (call.sealFrom === null ? isNull(column) : eq(column as never, call.sealFrom));
|
|
1235
|
+
let done: number;
|
|
1236
|
+
switch (ref.kind) {
|
|
1237
|
+
case 'principal':
|
|
1238
|
+
done = changedRows(await tx.update(vaultMembers).set({ seal: call.seal }).where(and(eq(vaultMembers.principal, ref.id), from(vaultMembers.seal))));
|
|
1239
|
+
break;
|
|
1240
|
+
case 'credential':
|
|
1241
|
+
done = changedRows(await tx.update(credentials).set({ seal: call.seal }).where(and(eq(credentials.id, ref.id), from(credentials.seal))))
|
|
1242
|
+
|| changedRows(await tx.update(serviceBindings).set({ seal: call.seal }).where(and(eq(serviceBindings.id, ref.id), from(serviceBindings.seal))));
|
|
1243
|
+
break;
|
|
1244
|
+
case 'connection':
|
|
1245
|
+
done = changedRows(await tx.update(mcpConnections).set({ seal: call.seal }).where(and(eq(mcpConnections.id, ref.id), from(mcpConnections.seal))));
|
|
1246
|
+
break;
|
|
1247
|
+
case 'secret': {
|
|
1248
|
+
if (value === null) {
|
|
1249
|
+
done = changedRows(await tx.update(secrets).set({ seal: call.seal }).where(and(eq(secrets.id, ref.id), from(secrets.seal))));
|
|
1250
|
+
break;
|
|
1251
|
+
}
|
|
1252
|
+
// A reference's end (0029): the holder, with no version of its own, left with no source. Live, from its seal, its
|
|
1253
|
+
// newest reference row ended, which must stand; archived, from none, its row ended by its id already
|
|
1254
|
+
// (`end_reference`), and its object deleted again after (0030).
|
|
1255
|
+
const [row] = await tx.select({ projectId: secrets.projectId, environmentId: secrets.environmentId, archivedAt: secrets.archivedAt }).from(secrets)
|
|
1256
|
+
.where(and(eq(secrets.id, ref.id), from(secrets.seal), isNull(secrets.currentVersionId)));
|
|
1257
|
+
const ends = row !== undefined && (row.archivedAt === null) === (call.sealFrom !== null)
|
|
1258
|
+
&& value.kind === 'secret' && value.id === ref.id && row.projectId === value.projectId && row.environmentId === value.environmentId
|
|
1259
|
+
&& value.version === '' && value.nature === 'secret' && value.key === null && value.digest === null && value.source === null
|
|
1260
|
+
&& value.reference === null;
|
|
1261
|
+
if (!ends) {
|
|
1262
|
+
done = 0;
|
|
1263
|
+
break;
|
|
1264
|
+
}
|
|
1265
|
+
done = changedRows(await tx.update(secrets).set({ seal: call.seal }).where(eq(secrets.id, ref.id)));
|
|
1266
|
+
if (row.archivedAt !== null) break;
|
|
1267
|
+
const [newest] = await tx.select({ id: secretReferences.id, endedAt: secretReferences.endedAt }).from(secretReferences)
|
|
1268
|
+
.where(eq(secretReferences.secretId, ref.id)).orderBy(desc(secretReferences.createdSeq)).limit(1);
|
|
1269
|
+
if (newest === undefined || newest.endedAt !== null) throw new StaleRow("the secret object's reference has no standing row: stale");
|
|
1270
|
+
await tx.update(secretReferences).set({ endedAt: new Date(call.at) }).where(eq(secretReferences.id, newest.id));
|
|
1271
|
+
break;
|
|
1272
|
+
}
|
|
1273
|
+
case 'environment': {
|
|
1274
|
+
if (value === null) {
|
|
1275
|
+
done = changedRows(await tx.update(environments).set({ seal: call.seal }).where(and(eq(environments.id, ref.id), from(environments.seal))));
|
|
1276
|
+
break;
|
|
1277
|
+
}
|
|
1278
|
+
const [row] = await tx.select({
|
|
1279
|
+
projectId: environments.projectId, parentId: environments.parentId, owner: environments.owner, slug: environments.slug, archivedAt: environments.archivedAt, seal: environments.seal,
|
|
1280
|
+
projectArchived: truth(isNotNull(projects.archivedAt)),
|
|
1281
|
+
}).from(environments).leftJoin(projects, eq(projects.id, environments.projectId)).where(eq(environments.id, ref.id));
|
|
1282
|
+
const deleted = value.deleted === true;
|
|
1283
|
+
const holds = row !== undefined && sameBytes(row.seal ?? null, call.sealFrom) && value.kind === 'environment' && value.id === ref.id
|
|
1284
|
+
&& row.projectId === value.projectId && row.parentId === (value.parentId ?? null) && row.owner === (value.owner ?? null)
|
|
1285
|
+
// Its project's archival is the project's row's, none once deleted: held, never written here (0029).
|
|
1286
|
+
&& (!deleted && Boolean(row.projectArchived)) === value.projectArchived
|
|
1287
|
+
// A deleted row keeps its tombstone, and its object the slug before it (objects.ts): never written again (0025).
|
|
1288
|
+
&& isTombstone(row.slug) === deleted && !isTombstone(String(value.slug)) && (!deleted || row.slug.slice(0, row.slug.indexOf(TOMBSTONE)) === value.slug);
|
|
1289
|
+
if (!holds) {
|
|
1290
|
+
done = 0;
|
|
1291
|
+
break;
|
|
1292
|
+
}
|
|
1293
|
+
done = changedRows(await tx.update(environments).set({
|
|
1294
|
+
slug: deleted ? row.slug : String(value.slug),
|
|
1295
|
+
protected: value.protected === true,
|
|
1296
|
+
archivedAt: value.archived === true ? (row.archivedAt ?? new Date(call.at)) : null,
|
|
1297
|
+
seal: call.seal,
|
|
1298
|
+
}).where(eq(environments.id, ref.id)));
|
|
1299
|
+
break;
|
|
1300
|
+
}
|
|
1301
|
+
case 'roles': case 'policy': {
|
|
1302
|
+
const config = op === 'genesis' ? (JSON.parse(call.payload) as { value?: unknown }).value : value;
|
|
1303
|
+
if (ref.id !== 'instance' || config === null || typeof config !== 'object' || Array.isArray(config) || (op === 'genesis' && call.sealFrom !== null)) {
|
|
1304
|
+
throw new Error(`the ${ref.kind} object is one, made once from its value and changed from its seal`);
|
|
1305
|
+
}
|
|
1306
|
+
done = call.sealFrom === null
|
|
1307
|
+
? changedRows(await tx.insert(logConfig).values({ name: ref.kind, value: JSON.stringify(config), seal: call.seal }).onConflictDoNothing())
|
|
1308
|
+
: changedRows(await tx.update(logConfig).set({ value: JSON.stringify(config), seal: call.seal }).where(and(eq(logConfig.name, ref.kind), eq(logConfig.seal, call.sealFrom))));
|
|
1309
|
+
break;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
if (done === 0) return null;
|
|
1313
|
+
// One sequence for both tables, as `log_number` is on Postgres: the transaction holds the database's write lock.
|
|
1314
|
+
// Unlike the sequence, the newest number again once its entries are deleted: SQLite is for tests (the plan), where
|
|
1315
|
+
// only a test deletes entries.
|
|
1316
|
+
const [{ last }] = await tx.select({ last: sql<number | string | null>`max(n)` }).from(
|
|
1317
|
+
sql`(SELECT max(${logChanges.number}) AS n FROM ${logChanges} UNION ALL SELECT max(${logUses.number}) FROM ${logUses})`,
|
|
1318
|
+
);
|
|
1319
|
+
const number = BigInt(last ?? 0) + 1n;
|
|
1320
|
+
await tx.insert(logChanges).values({ number, at: call.at, requestId: call.requestId, object: target, actor: call.actor, payload: call.payload, seal: call.seal, mac: call.mac });
|
|
1321
|
+
return number;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/** The seal on an object's row, null if it has none or no row (the credentials' table first: a binding's id never meets a token's). */
|
|
1325
|
+
async function heldSeal(db: Queryable, ref: Ref): Promise<Buffer | null> {
|
|
1326
|
+
const { credentials, mcpConnections, serviceBindings, secrets } = tablesOf(db);
|
|
1327
|
+
switch (ref.kind) {
|
|
1328
|
+
case 'connection': {
|
|
1329
|
+
const [row] = await db.select({ seal: mcpConnections.seal }).from(mcpConnections).where(eq(mcpConnections.id, ref.id));
|
|
1330
|
+
return row?.seal ?? null;
|
|
1331
|
+
}
|
|
1332
|
+
case 'credential': {
|
|
1333
|
+
const [token] = await db.select({ seal: credentials.seal }).from(credentials).where(eq(credentials.id, ref.id));
|
|
1334
|
+
if (token !== undefined) return token.seal ?? null;
|
|
1335
|
+
const [binding] = await db.select({ seal: serviceBindings.seal }).from(serviceBindings).where(eq(serviceBindings.id, ref.id));
|
|
1336
|
+
return binding?.seal ?? null;
|
|
1337
|
+
}
|
|
1338
|
+
case 'secret': {
|
|
1339
|
+
const [row] = await db.select({ seal: secrets.seal }).from(secrets).where(eq(secrets.id, ref.id));
|
|
1340
|
+
return row?.seal ?? null;
|
|
1341
|
+
}
|
|
1342
|
+
default:
|
|
1343
|
+
return (await readObject(db, ref as Ref & { kind: ReadKind })).seal;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
function sameBytes(a: Buffer | null, b: Buffer | null): boolean {
|
|
1348
|
+
return a === null || b === null ? a === b : Buffer.compare(a, b) === 0;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/**
|
|
1352
|
+
* A credential a token's issue came in on (`Vault.issueToken`, T149): its
|
|
1353
|
+
* object's columns, as the app sends them (`CredentialColumns`), its seal,
|
|
1354
|
+
* and its object's newest change's seal, in one read; null for no row.
|
|
1355
|
+
*/
|
|
1356
|
+
export async function issuingCredential(db: Queryable, id: string): Promise<{ columns: CredentialColumns; seal: Buffer | null; newest: Buffer | null } | null> {
|
|
1357
|
+
type Read = { id: string; principal: string; kind: string; scope: string | null; revoked: boolean | number; expires_at: number | string; not_after: number | string | null; generation: number; seal: Buffer | null; newest: Buffer | null };
|
|
1358
|
+
const [row] = engineOf(db) === 'postgres'
|
|
1359
|
+
? await rowsOf<Read>(db, sql`SELECT * FROM issuing_credential(${id}::uuid)`)
|
|
1360
|
+
: await rowsOf<Read>(db, sql`SELECT c.id, c.principal, c.kind, c.scope, c.revoked_at IS NOT NULL AS revoked, c.expires_at, c.not_after, c.generation, c.seal,
|
|
1361
|
+
(SELECT l.seal FROM log_changes l WHERE l.object = ${objectKey({ kind: 'credential', id })} ORDER BY l.number DESC LIMIT 1) AS newest
|
|
1362
|
+
FROM credentials c WHERE c.id = ${id}`);
|
|
1363
|
+
if (row === undefined) return null;
|
|
1364
|
+
const ends = [Number(row.expires_at), ...(row.not_after == null ? [] : [Number(row.not_after)])];
|
|
1365
|
+
const bytes = (value: Buffer | null) => (value == null ? null : Buffer.from(value));
|
|
1366
|
+
return {
|
|
1367
|
+
columns: { id: row.id, principal: row.principal, kind: row.kind, scope: row.scope, revoked: Boolean(row.revoked), expiresAt: Math.min(...ends), generation: Number(row.generation) },
|
|
1368
|
+
seal: bytes(row.seal),
|
|
1369
|
+
newest: bytes(row.newest),
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// --- the cutover (cutover.ts) ------------------------------------------------
|
|
1374
|
+
|
|
1375
|
+
/** Whether the log is cut over from 0.14: its cutover change, one probe of `log_changes_object_idx`, no lock. */
|
|
1376
|
+
export async function cutOver(db: Queryable): Promise<boolean> {
|
|
1377
|
+
return (await cutoverNumber(db)) !== null;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/** The cutover change's number, null for none: one probe of `log_changes_object_idx`, no lock. */
|
|
1381
|
+
export async function cutoverNumber(db: Queryable): Promise<bigint | null> {
|
|
1382
|
+
const [row] = await rowsOf<{ number: string | number }>(db, sql`SELECT number FROM log_changes WHERE object = ${CUTOVER_OBJECT} LIMIT 1`);
|
|
1383
|
+
return row === undefined ? null : BigInt(row.number);
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/** Every object the log has a change of, by its key: before the cutover, the few 0.15's changes made already. */
|
|
1387
|
+
export async function loggedObjects(db: Queryable): Promise<Set<string>> {
|
|
1388
|
+
const rows = await rowsOf<{ object: string }>(db, sql`SELECT DISTINCT object FROM log_changes`);
|
|
1389
|
+
return new Set(rows.map((row) => row.object));
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/** The rows `log_config` holds, by name: only `change_input` writes one, sealed (the roles', the policy's). */
|
|
1393
|
+
export async function configRows(db: Queryable): Promise<Set<string>> {
|
|
1394
|
+
const rows = await rowsOf<{ name: string }>(db, sql`SELECT name FROM log_config`);
|
|
1395
|
+
return new Set(rows.map((row) => row.name));
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
/** A member's row's seal, by principal. */
|
|
1399
|
+
export async function memberSeals(db: Queryable): Promise<Map<string, Buffer | null>> {
|
|
1400
|
+
const rows = await rowsOf<{ principal: string; seal: Buffer | Uint8Array | null }>(db, sql`SELECT principal, seal FROM vault_members`);
|
|
1401
|
+
return new Map(rows.map((row) => [row.principal, bytes(row.seal)]));
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
export type ProjectRow = { id: string; slug: string; archived: boolean };
|
|
1405
|
+
export type EnvironmentRead = { id: string; projectId: string; slug: string; owner: string | null; parentId: string | null; archived: boolean; seal: Buffer | null };
|
|
1406
|
+
/** A key's rows, archived or not: its current version, and its newest reference row, ended or not. */
|
|
1407
|
+
export type SecretRead = {
|
|
1408
|
+
id: string;
|
|
1409
|
+
projectId: string;
|
|
1410
|
+
environmentId: string;
|
|
1411
|
+
archived: boolean;
|
|
1412
|
+
seal: Buffer | null;
|
|
1413
|
+
/**
|
|
1414
|
+
* Whether its current version is one of its own and its newest by the
|
|
1415
|
+
* versions' own order, as 0.14 holds a source's (`versions`, which joins a
|
|
1416
|
+
* version to its key): the app may write `current_version_id`, never a
|
|
1417
|
+
* version. False for a pointer to another key's version, or to none.
|
|
1418
|
+
*/
|
|
1419
|
+
newest: boolean;
|
|
1420
|
+
version: { id: string; sensitive: boolean; plaintext: string | null; kekProvider: string; kekId: string; kekVersion: string; wrappedDek: string } | null;
|
|
1421
|
+
/** Its newest reference row: its id, its source, the `reference.create` it names, and whether its row says it ended. */
|
|
1422
|
+
reference: { id: string; source: string; createdSeq: bigint; ended: boolean } | null;
|
|
1423
|
+
};
|
|
1424
|
+
|
|
1425
|
+
export async function everyProject(db: Queryable): Promise<ProjectRow[]> {
|
|
1426
|
+
const rows = await rowsOf<{ id: string; slug: string; archived: boolean | number }>(db, sql`SELECT id, slug, archived_at IS NOT NULL AS archived FROM projects`);
|
|
1427
|
+
return rows.map((row) => ({ id: row.id, slug: row.slug, archived: Boolean(row.archived) }));
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
export async function everyEnvironment(db: Queryable): Promise<EnvironmentRead[]> {
|
|
1431
|
+
const rows = await rowsOf<{ id: string; project_id: string; slug: string; owner: string | null; parent_id: string | null; archived: boolean | number; seal: Buffer | Uint8Array | null }>(
|
|
1432
|
+
db, sql`SELECT id, project_id, slug, owner, parent_id, archived_at IS NOT NULL AS archived, seal FROM environments`,
|
|
1433
|
+
);
|
|
1434
|
+
return rows.map((row) => ({ id: row.id, projectId: row.project_id, slug: row.slug, owner: row.owner, parentId: row.parent_id, archived: Boolean(row.archived), seal: bytes(row.seal) }));
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/** Every key, archived or not, as `readObject` makes one (store.ts), with its newest reference row whole. */
|
|
1438
|
+
export async function everySecret(db: Queryable): Promise<SecretRead[]> {
|
|
1439
|
+
const newest = (column: string) => sql.raw(`(SELECT y.${column} FROM secret_references y WHERE y.secret_id = s.id ORDER BY y.created_seq DESC LIMIT 1)`);
|
|
1440
|
+
const rows = await rowsOf<{
|
|
1441
|
+
id: string; project_id: string; environment_id: string; archived: boolean | number; seal: Buffer | Uint8Array | null; version_id: string | null;
|
|
1442
|
+
sensitive: boolean | number | null; plaintext: string | null; kek_provider: string | null; kek_id: string | null; kek_version: string | null; wrapped_dek: Buffer | Uint8Array | null;
|
|
1443
|
+
reference_id: string | null; reference_source: string | null; reference_seq: string | number | null; reference_ended: boolean | number | null;
|
|
1444
|
+
newest: boolean | number | null;
|
|
1445
|
+
}>(db, sql`SELECT s.id, s.project_id, s.environment_id, s.archived_at IS NOT NULL AS archived, s.seal, s.current_version_id AS version_id,
|
|
1446
|
+
s.current_version_id IS NULL OR (v.secret_id = s.id AND v.version = (SELECT max(w.version) FROM secret_versions w WHERE w.secret_id = s.id)) AS newest,
|
|
1447
|
+
v.sensitive, v.plaintext, v.kek_provider, v.kek_id, v.kek_version, v.wrapped_dek,
|
|
1448
|
+
${newest('id')} AS reference_id, ${newest('source_secret_id')} AS reference_source, ${newest('created_seq')} AS reference_seq,
|
|
1449
|
+
${newest('ended_at')} IS NOT NULL AS reference_ended
|
|
1450
|
+
FROM secrets s LEFT JOIN secret_versions v ON v.id = s.current_version_id`);
|
|
1451
|
+
return rows.map((row) => ({
|
|
1452
|
+
id: row.id,
|
|
1453
|
+
projectId: row.project_id,
|
|
1454
|
+
environmentId: row.environment_id,
|
|
1455
|
+
archived: Boolean(row.archived),
|
|
1456
|
+
seal: bytes(row.seal),
|
|
1457
|
+
newest: Boolean(row.newest),
|
|
1458
|
+
// As `readObject` takes a version: none without a wrapped key.
|
|
1459
|
+
version: row.version_id === null || row.wrapped_dek === null ? null : {
|
|
1460
|
+
id: row.version_id, sensitive: Boolean(row.sensitive), plaintext: row.plaintext,
|
|
1461
|
+
kekProvider: row.kek_provider!, kekId: row.kek_id!, kekVersion: row.kek_version!, wrappedDek: Buffer.from(row.wrapped_dek).toString('base64'),
|
|
1462
|
+
},
|
|
1463
|
+
reference: row.reference_id === null ? null : {
|
|
1464
|
+
id: row.reference_id, source: row.reference_source!, createdSeq: BigInt(row.reference_seq!), ended: Boolean(row.reference_ended),
|
|
1465
|
+
},
|
|
1466
|
+
}));
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/** A credential the app vouched for, as its row is now (`cutover_credentials`): its object's columns, its seal, and whether the log has a change of it. */
|
|
1470
|
+
export type CredentialRead = { columns: CredentialColumns; seal: Buffer | null; logged: boolean };
|
|
1471
|
+
/** A binding the app vouched for, as its row is now: its columns but the instance it was made on, its row's `origin` apart (null on one of 0.14's, which the cutover writes), its seal. */
|
|
1472
|
+
export type BindingRead = { columns: Omit<BindingColumns, 'origin'>; origin: string | null; seal: Buffer | null; logged: boolean };
|
|
1473
|
+
|
|
1474
|
+
export async function credentialsAt(db: Queryable, ids: readonly string[]): Promise<Map<string, CredentialRead>> {
|
|
1475
|
+
if (ids.length === 0) return new Map();
|
|
1476
|
+
type Read = { id: string; principal: string; kind: string; scope: string | null; revoked: boolean | number; expires_at: string | number; not_after: string | number | null; generation: number; seal: Buffer | Uint8Array | null; logged: boolean | number };
|
|
1477
|
+
const rows = engineOf(db) === 'postgres'
|
|
1478
|
+
? await rowsOf<Read>(db, sql`SELECT * FROM cutover_credentials(${sql.param([...ids])}::uuid[])`)
|
|
1479
|
+
// SQLite has no `log_object`: whether the log holds each is the plan's to see, by its key (`CutoverFacts.logged`).
|
|
1480
|
+
: await rowsOf<Read>(db, sql`SELECT c.id, c.principal, c.kind, c.scope, c.revoked_at IS NOT NULL AS revoked, c.expires_at, c.not_after, c.generation, c.seal,
|
|
1481
|
+
0 AS logged FROM credentials c WHERE c.id IN (${sql.join(ids.map((id) => sql`${id}`), sql`, `)})`);
|
|
1482
|
+
return new Map(rows.map((row) => {
|
|
1483
|
+
// As `issuingCredential` reads one (store.ts): its end the earlier of its expiry and its hard end.
|
|
1484
|
+
const ends = [Number(row.expires_at), ...(row.not_after == null ? [] : [Number(row.not_after)])];
|
|
1485
|
+
const columns: CredentialColumns = { id: row.id, principal: row.principal, kind: row.kind, scope: row.scope, revoked: Boolean(row.revoked), expiresAt: Math.min(...ends), generation: Number(row.generation) };
|
|
1486
|
+
return [row.id, { columns, seal: bytes(row.seal), logged: Boolean(row.logged) }];
|
|
1487
|
+
}));
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/** An MCP connection the app vouched for, as its row is now (`cutover_connections`, T147): its object's columns, its seal, and whether the log has a change of it. */
|
|
1491
|
+
export type ConnectionRead = { columns: ConnectionColumns; seal: Buffer | null; logged: boolean };
|
|
1492
|
+
|
|
1493
|
+
export async function connectionsAt(db: Queryable, ids: readonly string[]): Promise<Map<string, ConnectionRead>> {
|
|
1494
|
+
if (ids.length === 0) return new Map();
|
|
1495
|
+
type Read = { id: string; principal: string; generation: number; expires_at: string | number; revoked: boolean | number; seal: Buffer | Uint8Array | null; logged: boolean | number };
|
|
1496
|
+
const rows = engineOf(db) === 'postgres'
|
|
1497
|
+
? await rowsOf<Read>(db, sql`SELECT * FROM cutover_connections(${sql.param([...ids])}::uuid[])`)
|
|
1498
|
+
: await rowsOf<Read>(db, sql`SELECT k.id, k.principal, k.generation, k.expires_at, k.revoked_at IS NOT NULL AS revoked, k.seal,
|
|
1499
|
+
0 AS logged FROM mcp_connections k WHERE k.id IN (${sql.join(ids.map((id) => sql`${id}`), sql`, `)})`);
|
|
1500
|
+
return new Map(rows.map((row) => {
|
|
1501
|
+
const columns: ConnectionColumns = { id: row.id, principal: row.principal, generation: Number(row.generation), expiresAt: Number(row.expires_at), revoked: Boolean(row.revoked) };
|
|
1502
|
+
return [row.id, { columns, seal: bytes(row.seal), logged: Boolean(row.logged) }];
|
|
1503
|
+
}));
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
export async function bindingsAt(db: Queryable, ids: readonly string[]): Promise<Map<string, BindingRead>> {
|
|
1507
|
+
if (ids.length === 0) return new Map();
|
|
1508
|
+
type Read = { id: string; principal: string; generation: number; profile: string; issuer: string; origin: string | null; jwks_uri: string; claims: string; scope: string | null; policy: string | null; revoked: boolean | number; seal: Buffer | Uint8Array | null; logged: boolean | number };
|
|
1509
|
+
const rows = engineOf(db) === 'postgres'
|
|
1510
|
+
? await rowsOf<Read>(db, sql`SELECT * FROM cutover_bindings(${sql.param([...ids])}::uuid[])`)
|
|
1511
|
+
: await rowsOf<Read>(db, sql`SELECT b.id, b.principal, b.generation, b.profile, b.issuer, b.origin, b.jwks_uri, b.claims, b.scope, b.policy, b.revoked_at IS NOT NULL AS revoked, b.seal,
|
|
1512
|
+
0 AS logged FROM service_bindings b WHERE b.id IN (${sql.join(ids.map((id) => sql`${id}`), sql`, `)})`);
|
|
1513
|
+
return new Map(rows.map((row) => {
|
|
1514
|
+
const columns: Omit<BindingColumns, 'origin'> = {
|
|
1515
|
+
id: row.id, principal: row.principal, generation: Number(row.generation), profile: row.profile, issuer: row.issuer, jwksUri: row.jwks_uri,
|
|
1516
|
+
claims: row.claims, scope: row.scope, policy: row.policy, revoked: Boolean(row.revoked),
|
|
1517
|
+
};
|
|
1518
|
+
return [row.id, { columns, origin: row.origin, seal: bytes(row.seal), logged: Boolean(row.logged) }];
|
|
1519
|
+
}));
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
/** Whether the app wrote any entry of 0.14's after `seq`: what it vouched for may have moved since. */
|
|
1523
|
+
export async function appEntriesAfter(db: Queryable, seq: bigint): Promise<boolean> {
|
|
1524
|
+
const rows = await rowsOf<{ found: number }>(db, sql`SELECT 1 AS found FROM audit_log WHERE seq > ${seq} AND author = 'app' LIMIT 1`);
|
|
1525
|
+
return rows.length > 0;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
/** The places 0.14 deleted made as 0.15 leaves them: each environment given `<slug>~deleted`, each key archived (`cutover_places`). */
|
|
1529
|
+
export async function cutoverPlaces(tx: Transaction, at: number, environments: readonly string[], secrets: readonly string[]): Promise<void> {
|
|
1530
|
+
if (environments.length === 0 && secrets.length === 0) return;
|
|
1531
|
+
if (engineOf(tx) === 'postgres') {
|
|
1532
|
+
await rowsOf(tx, sql`SELECT cutover_places(${at}::bigint, ${sql.param([...environments])}::uuid[], ${sql.param([...secrets])}::uuid[])`);
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
// `cutover_places`, step for step: the transaction holds the database's write lock.
|
|
1536
|
+
const { environments: envs, secrets: keys, projects } = tablesOf(tx);
|
|
1537
|
+
if (await cutOver(tx)) throw new Error('the log is cut over from 0.14 already');
|
|
1538
|
+
const logged = await loggedObjects(tx);
|
|
1539
|
+
const rows = environments.length === 0 ? [] : await tx.select({ id: envs.id, slug: envs.slug, seal: envs.seal, projectSlug: projects.slug })
|
|
1540
|
+
.from(envs).innerJoin(projects, eq(projects.id, envs.projectId)).where(inArray(envs.id, [...environments]));
|
|
1541
|
+
if (rows.length !== environments.length || rows.some((row) => isTombstone(row.slug) || row.seal !== null || !isTombstone(row.projectSlug) || logged.has(objectKey({ kind: 'environment', id: row.id })))) {
|
|
1542
|
+
throw new Error('an environment named is no standing one of a deleted project, 0.14\'s alone');
|
|
1543
|
+
}
|
|
1544
|
+
for (const row of rows) await tx.update(envs).set({ slug: deletedWithProject(row.slug) }).where(eq(envs.id, row.id));
|
|
1545
|
+
const held = secrets.length === 0 ? [] : await tx.select({ id: keys.id, seal: keys.seal, archivedAt: keys.archivedAt, slug: envs.slug, projectSlug: projects.slug })
|
|
1546
|
+
.from(keys).innerJoin(envs, eq(envs.id, keys.environmentId)).innerJoin(projects, eq(projects.id, envs.projectId)).where(inArray(keys.id, [...secrets]));
|
|
1547
|
+
if (held.length !== secrets.length || held.some((row) => row.archivedAt !== null || row.seal !== null || !(isTombstone(row.slug) || isTombstone(row.projectSlug)) || logged.has(objectKey({ kind: 'secret', id: row.id })))) {
|
|
1548
|
+
throw new Error('a key named is no live one under a deleted place, 0.14\'s alone');
|
|
1549
|
+
}
|
|
1550
|
+
const archived = changedRows(await tx.update(keys).set({ archivedAt: new Date(at) }).where(and(inArray(keys.id, [...secrets]), isNull(keys.archivedAt))));
|
|
1551
|
+
if (archived !== secrets.length) throw new Error('a key named was archived meanwhile');
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
/** A binding's genesis (`seal_binding`): its number, or null if its row is not as its object says, or the log holds it. */
|
|
1555
|
+
export async function sealBinding(tx: Transaction, call: ChangeCall): Promise<bigint | null> {
|
|
1556
|
+
if (engineOf(tx) === 'postgres') {
|
|
1557
|
+
const [row] = await rowsOf<{ n: string | number | null }>(tx, sql`SELECT seal_binding(${call.ref.id}::uuid, ${call.requestId}, ${call.at}::bigint, ${call.actor}, ${call.payload},
|
|
1558
|
+
${call.seal}::bytea, ${call.mac}::bytea) AS n`);
|
|
1559
|
+
return row?.n == null ? null : BigInt(row.n);
|
|
1560
|
+
}
|
|
1561
|
+
const { serviceBindings, logChanges } = tablesOf(tx);
|
|
1562
|
+
if (await cutOver(tx)) throw new Error('the log is cut over from 0.14 already: a binding is the vault\'s to make');
|
|
1563
|
+
if ((await loggedObjects(tx)).has(objectKey(call.ref))) return null;
|
|
1564
|
+
const origin = (JSON.parse(call.payload) as { value?: { origin?: unknown } }).value?.origin;
|
|
1565
|
+
if (typeof origin !== 'string') throw new Error("a binding's genesis names the instance it was made on");
|
|
1566
|
+
const done = changedRows(await tx.update(serviceBindings).set({ seal: call.seal, origin })
|
|
1567
|
+
.where(and(eq(serviceBindings.id, call.ref.id), isNull(serviceBindings.seal), isNull(serviceBindings.revokedAt), isNull(serviceBindings.origin))));
|
|
1568
|
+
if (done === 0) return null;
|
|
1569
|
+
const number = await nextNumber(tx);
|
|
1570
|
+
await tx.insert(logChanges).values({ number, at: call.at, requestId: call.requestId, object: objectKey(call.ref), actor: call.actor, payload: call.payload, seal: call.seal, mac: call.mac });
|
|
1571
|
+
return number;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
/** The cutover's change (`log_cutover`): its number. */
|
|
1575
|
+
export async function logCutover(tx: Transaction, row: { requestId: string; at: number; actor: string; payload: string; mac: Buffer }): Promise<bigint> {
|
|
1576
|
+
if (engineOf(tx) === 'postgres') {
|
|
1577
|
+
const [found] = await rowsOf<{ n: string | number }>(tx, sql`SELECT log_cutover(${row.requestId}, ${row.at}::bigint, ${row.actor}, ${row.payload}, ${row.mac}::bytea) AS n`);
|
|
1578
|
+
return BigInt(found!.n);
|
|
1579
|
+
}
|
|
1580
|
+
const { logChanges, auditChainHead } = tablesOf(tx);
|
|
1581
|
+
if (await cutOver(tx)) throw Object.assign(new Error('the log is cut over from 0.14 already'), { code: 'SQLITE_CONSTRAINT_UNIQUE' });
|
|
1582
|
+
const { anchor } = JSON.parse(row.payload) as { anchor: { seq: string; hash: string } };
|
|
1583
|
+
const [head] = await tx.select({ nextSeq: auditChainHead.nextSeq, headHash: auditChainHead.headHash }).from(auditChainHead);
|
|
1584
|
+
if (head === undefined || BigInt(head.nextSeq) !== BigInt(anchor.seq) + 1n || Buffer.from(head.headHash).toString('hex') !== anchor.hash) {
|
|
1585
|
+
throw new Error('a cutover names 0.14\'s head as it stands');
|
|
1586
|
+
}
|
|
1587
|
+
const number = await nextNumber(tx);
|
|
1588
|
+
await tx.insert(logChanges).values({ number, at: row.at, requestId: row.requestId, object: CUTOVER_OBJECT, actor: row.actor, payload: row.payload, seal: null, mac: row.mac });
|
|
1589
|
+
return number;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
/** SQLite's next number, both tables one sequence (as `changeOnSqlite` takes it, store.ts). */
|
|
1593
|
+
async function nextNumber(tx: Transaction): Promise<bigint> {
|
|
1594
|
+
const [{ last }] = await rowsOf<{ last: number | string | null }>(tx, sql`SELECT max(n) AS last FROM (SELECT max(number) AS n FROM log_changes UNION ALL SELECT max(number) FROM log_uses)`);
|
|
1595
|
+
return BigInt(last ?? 0) + 1n;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
function bytes(value: Buffer | Uint8Array | null): Buffer | null {
|
|
1599
|
+
return value === null ? null : Buffer.from(value);
|
|
1600
|
+
}
|