@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/retention.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { leafData, openPair, proveConsistency, TREES, verifyConsistency, type Cut, type LogEntry, type StoredPair } from '@coffre/core/merkle';
|
|
2
|
+
|
|
3
|
+
import { FenceClosed, type Checkpointer, type CheckpointStore } from './checkpointer.ts';
|
|
4
|
+
import { entryMac, RETENTION_ACTOR, RETENTION_OBJECT, retentionCut, retentionPayload, type LogMacKey } from './log-entry.ts';
|
|
5
|
+
import { cursorHolds, type ReadsCursor } from './reads.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Retention (docs/design/0.15-plan.md, PR 7; formal/Seal.tla's retention):
|
|
9
|
+
* days of uses dropped once a sealed retention change cuts the uses tree
|
|
10
|
+
* above them, and the uses tree's tiles below the cut, the one holding it at
|
|
11
|
+
* each level kept. Changes are kept for good: the changes tree is never cut.
|
|
12
|
+
*
|
|
13
|
+
* A day goes in these steps, oldest first, each a precondition of the next:
|
|
14
|
+
* 1. Its cut: the first pair whose floor of `at` is past the day's end. Every
|
|
15
|
+
* use of the day took its number at or below it (a use's `at` is held to
|
|
16
|
+
* the floor), so all of them lie below the cut; a few of the next day's
|
|
17
|
+
* do too (stragglers), and stay until their own day goes. The cut must be
|
|
18
|
+
* a pair the signer signed, the first past the day's end by its own note
|
|
19
|
+
* (its `prev`, the floor of the pair signed before it, below the end:
|
|
20
|
+
* CutFirst), on its history (a consistency proof to its head in both
|
|
21
|
+
* trees), at or below its head.
|
|
22
|
+
* 2. The retention change naming it, unless the newest sealed one already
|
|
23
|
+
* cuts at or past it (a quiet day, or a retry): appended, and sealed in
|
|
24
|
+
* the signer's pair (DropAfterSeal; else the verifier, walking the uses
|
|
25
|
+
* from genesis, would find the day gone).
|
|
26
|
+
* 3. Freshness: the pair that sealed it (or the signer's, for a day an older
|
|
27
|
+
* change cuts) at least two freshness bounds past the cut (FreshBeforeDrop).
|
|
28
|
+
* The signer refuses any entry stamped a bound before its pair (FRESH_MS),
|
|
29
|
+
* so no copy of a dropped use, which keeps its stamp and has no original
|
|
30
|
+
* left for a request-ID check to find, is ever sealed again. Two bounds,
|
|
31
|
+
* not one: one for the clocks.
|
|
32
|
+
* 4. The day's partition: detached, and dropped by the next run (`drop_day`).
|
|
33
|
+
* Measured at 1,000 uses a second (0.15-plan.md, "As built: PR 7"): a DROP
|
|
34
|
+
* right after the DETACH lands while every backend replans its plan of
|
|
35
|
+
* `log_uses`, and stalls uses 200 to 400 ms; seconds later, it stalls none.
|
|
36
|
+
* A crash between any two steps is finished by the next run.
|
|
37
|
+
* Then the tiles below the newest cut (`drop_tiles`).
|
|
38
|
+
*
|
|
39
|
+
* The signer's checks are the vault's: its memory, its pair, its keys. The
|
|
40
|
+
* database's functions check what they can see, so that the vault's login
|
|
41
|
+
* drops nothing a retention change it holds does not cut.
|
|
42
|
+
*/
|
|
43
|
+
export type RetentionStore = {
|
|
44
|
+
/** How far the reads' projector has gone (reads.ts, T166), with its MAC: no day nor tile it has yet to read is dropped. */
|
|
45
|
+
readsCursor(): Promise<ReadsCursor>;
|
|
46
|
+
/** The UTC days of uses the database holds, oldest first: YYYY-MM-DD. */
|
|
47
|
+
days(): Promise<string[]>;
|
|
48
|
+
/** Makes each day's partition of uses (`make_day`), once a store. */
|
|
49
|
+
makeDays(days: readonly string[]): Promise<void>;
|
|
50
|
+
/** The first checkpoint whose floor of `at` is at or past `ms`. */
|
|
51
|
+
cutAt(ms: number): Promise<StoredPair | null>;
|
|
52
|
+
/** The newest retention change at or below the newest checkpoint, with its leaf index; null if none. */
|
|
53
|
+
retention(): Promise<{ entry: LogEntry; index: number } | null>;
|
|
54
|
+
/** Appends a retention change, or finds the one its request ID names: its number. */
|
|
55
|
+
appendRetention(change: { requestId: string; at: number; payload: string; mac: Buffer }): Promise<bigint>;
|
|
56
|
+
/** One step of dropping a day: detached, dropped, or gone. */
|
|
57
|
+
dropDay(day: string): Promise<'detached' | 'dropped' | 'gone'>;
|
|
58
|
+
/** The uses tree's tiles wholly below leaf `below`: how many were deleted or ranges dropped. */
|
|
59
|
+
dropTiles(below: number): Promise<number>;
|
|
60
|
+
/** At most `limit` of the bulk cap's rows that its window no longer counts: how many went. */
|
|
61
|
+
pruneBulk(limit: number): Promise<number>;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const DAY_MS = 86_400_000;
|
|
65
|
+
|
|
66
|
+
/** A UTC day, YYYY-MM-DD, of a time in ms. */
|
|
67
|
+
export const dayOf = (ms: number) => new Date(ms).toISOString().slice(0, 10);
|
|
68
|
+
|
|
69
|
+
export type RetentionRun = {
|
|
70
|
+
/** The days whose uses went this run, each with the retention change written for it, if one was. */
|
|
71
|
+
dropped: { day: string; change: bigint | null }[];
|
|
72
|
+
/** Of those, the days detached and not dropped yet: the next run drops them. */
|
|
73
|
+
detached: string[];
|
|
74
|
+
/**
|
|
75
|
+
* Why the run stopped before a due day (`day`) or the tiles (no day), when it waits for something: the log to move on,
|
|
76
|
+
* or (`lock`) a reader holding the uses past the lock timeout. No alarm; a lock standing a day is one (`upkeep`).
|
|
77
|
+
*/
|
|
78
|
+
waiting: { day: string | null; lock: boolean; reason: string } | null;
|
|
79
|
+
/** Tiles deleted or leaf ranges dropped below the newest cut. */
|
|
80
|
+
tiles: number;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
type Options = {
|
|
84
|
+
/** Full days of uses kept before today's: a day goes once `keepDays` days have ended after it. */
|
|
85
|
+
keepDays: number;
|
|
86
|
+
/** The log's MAC key (`logMacKey`): the retention change is an entry like any. */
|
|
87
|
+
macKey: LogMacKey;
|
|
88
|
+
now?: () => number;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* One run. Throws on what is an alarm (a cut off the signer's history, a
|
|
93
|
+
* retention change not sealed, a day the database refuses); returns `waiting`
|
|
94
|
+
* when a day is not ready yet.
|
|
95
|
+
*/
|
|
96
|
+
export async function retain(cp: Checkpointer, store: CheckpointStore & RetentionStore, options: Options): Promise<RetentionRun> {
|
|
97
|
+
if (!Number.isSafeInteger(options.keepDays) || options.keepDays < 1) throw new Error(`keepDays must be a whole number of days, at least 1, not ${options.keepDays}`);
|
|
98
|
+
const now = options.now ?? Date.now;
|
|
99
|
+
const run: RetentionRun = { dropped: [], detached: [], waiting: null, tiles: 0 };
|
|
100
|
+
const last = dayOf(now() - (options.keepDays + 1) * DAY_MS);
|
|
101
|
+
for (const day of (await store.days()).filter((d) => d <= last)) {
|
|
102
|
+
const done = await retainDay(cp, store, day, options.macKey, now);
|
|
103
|
+
if ('reason' in done) {
|
|
104
|
+
run.waiting = { day, ...done };
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
run.dropped.push({ day, change: done.change });
|
|
108
|
+
// One DETACH a run: each invalidates every backend's plan of log_uses, and a second landing while they replan stalls
|
|
109
|
+
// uses (measured for a DROP: 0.15-plan.md, "As built: PR 7"). A backlog of days goes a day an hour.
|
|
110
|
+
if (done.detached) {
|
|
111
|
+
run.detached.push(day);
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// The tiles below the newest cut, once its sealing is two freshness bounds past it, as a day goes (FreshBeforeDrop).
|
|
116
|
+
const newest = await sealedCut(cp, store);
|
|
117
|
+
const projecting = newest === null ? null : await behindReads(store, options.macKey, newest.upto);
|
|
118
|
+
if (projecting !== null) run.waiting ??= { day: null, lock: false, reason: `the tiles below ${newest!.upto}: ${projecting}` };
|
|
119
|
+
else if (newest !== null && cp.pair.pair.at >= newest.at + 2 * cp.freshMs) {
|
|
120
|
+
const tiles = await waitingOnLock(() => store.dropTiles(newest.uses.size));
|
|
121
|
+
if (typeof tiles === 'number') run.tiles = tiles;
|
|
122
|
+
else run.waiting ??= { day: null, lock: true, reason: `the tiles below ${newest.upto}: ${tiles.reason}` };
|
|
123
|
+
}
|
|
124
|
+
return run;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** One day: dropped, with the change written for it if any; or why it waits. */
|
|
128
|
+
type Wait = { lock: boolean; reason: string };
|
|
129
|
+
|
|
130
|
+
async function retainDay(cp: Checkpointer, store: CheckpointStore & RetentionStore, day: string, macKey: LogMacKey, now: () => number): Promise<{ change: bigint | null; detached: boolean } | Wait> {
|
|
131
|
+
const end = Date.parse(`${day}T00:00:00Z`) + DAY_MS;
|
|
132
|
+
const head = cp.pair.pair;
|
|
133
|
+
if (head.floor < end) return { lock: false, reason: `the signer's newest pair (up to ${head.upto}) is not past the day's end yet` };
|
|
134
|
+
// 1. The cut: a pair of this log at or below the signer's, the first it signed past the day's end by its own note (its
|
|
135
|
+
// floor at or past the end, the floor before it below: CutFirst). The rows say which pair that is, and a writer who
|
|
136
|
+
// deletes some, or edits their floors, would make a later one pass for it, and every use between go unwalked.
|
|
137
|
+
const row = await store.cutAt(end);
|
|
138
|
+
const cut = row === null ? null : await openPair(row.notes, cp.verifiers);
|
|
139
|
+
if (row === null || cut === null || cut.upto !== row.upto || cut.floor < end || cut.prev >= end || cut.upto > head.upto) {
|
|
140
|
+
throw new Error(`retention of ${day}: the database holds no pair of this log, at or below the signer's (up to ${head.upto}), that is the first past the day's end: checkpoints were deleted or replaced`);
|
|
141
|
+
}
|
|
142
|
+
// On the signer's history, by a consistency proof to its head; unless the newest sealed cut is at or past it (a day a
|
|
143
|
+
// change already cut, or one waiting to be dropped): that cut was proved when it was sealed, and the tiles below it,
|
|
144
|
+
// which a proof from this one would read, may be gone.
|
|
145
|
+
const sealed = await sealedCut(cp, store);
|
|
146
|
+
if (sealed === null || sealed.upto < cut.upto) {
|
|
147
|
+
for (const tree of TREES) {
|
|
148
|
+
if (cut[tree].size === head[tree].size && cut.upto === head.upto) continue;
|
|
149
|
+
// A fence closed under it (T169) is no answer of the database's: it goes through, and no alarm says the cut is off.
|
|
150
|
+
const proof = cut[tree].size > head[tree].size ? null : await proveConsistency(store, tree, cut[tree].size, head[tree].size).catch((error: unknown) => {
|
|
151
|
+
if (error instanceof FenceClosed) throw error;
|
|
152
|
+
return null;
|
|
153
|
+
});
|
|
154
|
+
if (proof === null || !verifyConsistency(cut[tree].size, head[tree].size, cut[tree].root, head[tree].root, proof)) {
|
|
155
|
+
throw new Error(`retention of ${day}: the cut's ${tree} head (up to ${cut.upto}) is not on the signer's history`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// 2. The retention change, unless the newest sealed one cuts at or past this day's.
|
|
160
|
+
let change: bigint | null = null;
|
|
161
|
+
let freshAt = head.at;
|
|
162
|
+
if (sealed === null || sealed.upto < cut.upto) {
|
|
163
|
+
const named: Cut = { upto: cut.upto, at: cut.at, uses: { size: cut.uses.size, root: cut.uses.root } };
|
|
164
|
+
const at = now();
|
|
165
|
+
const requestId = `retention:${day}`;
|
|
166
|
+
const payload = retentionPayload(day, named);
|
|
167
|
+
const mac = entryMac(macKey, { tree: 'changes', row: { at, requestId, object: RETENTION_OBJECT, actor: RETENTION_ACTOR, payload, seal: null } });
|
|
168
|
+
change = await store.appendRetention({ requestId, at, payload, mac });
|
|
169
|
+
// Sealed in the signer's pair, witnessed or not: its proof is checked against the pair. A retry finds the change it
|
|
170
|
+
// made before, whose cut is this day's (the first pair past its end) unless the rows moved: then nothing is dropped.
|
|
171
|
+
const answer = await cp.sealThrough(change);
|
|
172
|
+
const entry = (await store.retention())?.entry;
|
|
173
|
+
const written = entry?.number === change ? retentionCut(entry) : null;
|
|
174
|
+
if (written === null || written.upto !== cut.upto || !leafData('changes', entry!.number, entry!.columns).equals(answer.proof.leaf)) {
|
|
175
|
+
throw new Error(`retention of ${day}: the retention change ${change} is not the one sealed, or does not cut at ${cut.upto}`);
|
|
176
|
+
}
|
|
177
|
+
freshAt = answer.pair.at;
|
|
178
|
+
}
|
|
179
|
+
// 3. Freshness, against the signer's own pair, never a row.
|
|
180
|
+
const bound = Math.max(cut.at, end) + 2 * cp.freshMs;
|
|
181
|
+
if (freshAt < bound) return { lock: false, reason: `the pair sealing its cut (at ${freshAt}) is not two freshness bounds past it (${bound}) yet` };
|
|
182
|
+
// 4. Not before the reads' projector has read every use at or below the cut (T166): those it has yet to list would
|
|
183
|
+
// otherwise never be, and the range it walks needs the tiles too.
|
|
184
|
+
const projecting = await behindReads(store, macKey, cut.upto);
|
|
185
|
+
if (projecting !== null) return { lock: false, reason: projecting };
|
|
186
|
+
// 5. The day's partition: detached now, and dropped by a later run, never right after its DETACH (see above). A reader
|
|
187
|
+
// holding log_uses longer than `drop_day`'s lock_timeout (a backup, say) makes it give up: the next run tries again.
|
|
188
|
+
let state = await waitingOnLock(() => store.dropDay(day));
|
|
189
|
+
if (state === 'dropped') state = await waitingOnLock(() => store.dropDay(day));
|
|
190
|
+
if (typeof state === 'object') return state;
|
|
191
|
+
if (state === 'dropped') throw new Error(`retention of ${day}: its uses were not dropped`);
|
|
192
|
+
return { change, detached: state === 'detached' };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Why a cut waits on the reads' projector (T166), or null once it has gone
|
|
197
|
+
* past it: by the cursor the vault's MAC holds, never a number a database
|
|
198
|
+
* writer moved on, which would drop uses the projector has yet to list
|
|
199
|
+
* (review r7). Such a cursor stops the projector too, so retention waits,
|
|
200
|
+
* and alarms once it has waited long.
|
|
201
|
+
*/
|
|
202
|
+
async function behindReads(store: RetentionStore, macKey: LogMacKey, upto: bigint): Promise<string | null> {
|
|
203
|
+
const cursor = await store.readsCursor();
|
|
204
|
+
if (!cursorHolds(macKey, cursor)) return `the reads' cursor, at ${cursor.through}, is not the vault's: it was moved around the projector`;
|
|
205
|
+
return cursor.through >= upto ? null : `the reads' projection (through ${cursor.through}) has not reached the cut (${upto}) yet`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** What `work` answers, or, when the database gave up waiting for a lock (55P03, lock_timeout), why: no alarm. */
|
|
209
|
+
async function waitingOnLock<T>(work: () => Promise<T>): Promise<T | Wait> {
|
|
210
|
+
try {
|
|
211
|
+
return await work();
|
|
212
|
+
} catch (error) {
|
|
213
|
+
const e = error as { code?: unknown; cause?: { code?: unknown } };
|
|
214
|
+
if (e.code === '55P03' || e.cause?.code === '55P03') return { lock: true, reason: 'a reader holds the table past the lock timeout: the next run tries again' };
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The newest retention change's cut, proved in the signer's pair; null if there is none. */
|
|
220
|
+
async function sealedCut(cp: Checkpointer, store: RetentionStore): Promise<Cut | null> {
|
|
221
|
+
const found = await store.retention();
|
|
222
|
+
if (found === null) return null;
|
|
223
|
+
const cut = retentionCut(found.entry);
|
|
224
|
+
const proof = await cp.prove(found.entry.number, { tree: 'changes' });
|
|
225
|
+
if (cut === null || proof === null || proof.tree !== 'changes' || !proof.leaf.equals(leafData('changes', found.entry.number, found.entry.columns))) {
|
|
226
|
+
throw new Error(`the retention change ${found.entry.number} is not sealed in the signer's changes tree as the database holds it`);
|
|
227
|
+
}
|
|
228
|
+
return cut;
|
|
229
|
+
}
|
package/src/snapshot.postgres.ts
CHANGED
|
@@ -22,6 +22,19 @@ export function snapshotQuery(asked: Asked): SQL {
|
|
|
22
22
|
const actions = sql`${sql.param([...ACCESS_ACTIONS])}::text[]`;
|
|
23
23
|
const tomb = `%${TOMBSTONE}%`;
|
|
24
24
|
const released = asked.released;
|
|
25
|
+
const seals = asked.seals === true;
|
|
26
|
+
// The places read: those asked, the environments each personal config among them is a config of, with `seals` those
|
|
27
|
+
// the asked principals' grants are on (whose projects their grants are read with), and what the newest policy
|
|
28
|
+
// protects, by the ids its JSON names (one that is no UUID names nothing).
|
|
29
|
+
const placeIds = sql`ARRAY(
|
|
30
|
+
SELECT unnest(${environments})
|
|
31
|
+
UNION SELECT parent_id FROM environments WHERE id = ANY(${environments}) AND owner IS NOT NULL${asked.seals === true
|
|
32
|
+
? sql`
|
|
33
|
+
UNION SELECT environment_id FROM vault_grants WHERE principal = ANY(${principals}) AND environment_id IS NOT NULL`
|
|
34
|
+
: sql``}
|
|
35
|
+
UNION SELECT (place #>> '{}')::uuid FROM policy, jsonb_path_query(policy.metadata::jsonb, '$.policy.protected[*].environmentId') AS place
|
|
36
|
+
WHERE (place #>> '{}') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
|
37
|
+
)`;
|
|
25
38
|
// The head only if nobody holds it: a statement never waits for it, so all it reads is as of when it took it (`snapshot`).
|
|
26
39
|
const head = asked.head === true
|
|
27
40
|
? sql`, head AS (SELECT next_seq, head_hash FROM audit_chain_head WHERE only_row FOR UPDATE SKIP LOCKED)`
|
|
@@ -60,21 +73,38 @@ export function snapshotQuery(asked: Asked): SQL {
|
|
|
60
73
|
) e
|
|
61
74
|
),
|
|
62
75
|
'policy', (SELECT ${ENTRY} FROM policy e),
|
|
76
|
+
'settings', ${asked.settings === true
|
|
77
|
+
? sql`(SELECT ${ENTRY} FROM audit_log e WHERE author = 'vault' AND action = 'settings.change' AND decision = 'allow' ORDER BY seq DESC LIMIT 1)`
|
|
78
|
+
: sql`NULL`},
|
|
63
79
|
'environments', (
|
|
64
80
|
SELECT coalesce(json_agg(json_build_object(
|
|
65
81
|
'id', env.id, 'projectId', env.project_id, 'slug', env.slug, 'owner', env.owner, 'parentId', env.parent_id,
|
|
66
82
|
'deleted', env.slug LIKE ${tomb} OR project.slug LIKE ${tomb},
|
|
67
|
-
'archived', env.archived_at IS NOT NULL OR project.archived_at IS NOT NULL
|
|
83
|
+
'archived', env.archived_at IS NOT NULL OR project.archived_at IS NOT NULL${seals
|
|
84
|
+
? sql`,
|
|
85
|
+
-- What 0.15's log holds the row to (T159): its object's own columns, its seal, and its newest change's, one probe
|
|
86
|
+
-- of its object's changes, newest first (log_changes_object_idx, which holds the seal).
|
|
87
|
+
'protected', env.protected, 'ownArchived', env.archived_at IS NOT NULL, 'projectArchived', project.archived_at IS NOT NULL,
|
|
88
|
+
'seal', encode(env.seal, 'hex'),
|
|
89
|
+
'newest', (SELECT encode(l.seal, 'hex') FROM log_changes l WHERE l.object = log_object('environment', env.id::text) ORDER BY l.number DESC LIMIT 1)`
|
|
90
|
+
: sql``})), '[]')
|
|
68
91
|
FROM environments env JOIN projects project ON project.id = env.project_id
|
|
69
92
|
-- One list of ids, each found by the primary key.
|
|
70
|
-
WHERE env.id = ANY(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
))
|
|
77
|
-
|
|
93
|
+
WHERE env.id = ANY(${placeIds})
|
|
94
|
+
),${seals
|
|
95
|
+
? sql`
|
|
96
|
+
-- Of those ids, each the log holds a change of but no row (or no project's) answers to: deleted outright around it,
|
|
97
|
+
-- with its newest change's seal (T159, review r5). One probe of its object's changes each.
|
|
98
|
+
'gone', (
|
|
99
|
+
SELECT coalesce(json_agg(json_build_object('id', g.id, 'newest', encode(l.seal, 'hex'))), '[]')
|
|
100
|
+
FROM (
|
|
101
|
+
SELECT a.id FROM unnest(${placeIds}) AS a (id)
|
|
102
|
+
WHERE NOT EXISTS (SELECT 1 FROM environments env JOIN projects project ON project.id = env.project_id WHERE env.id = a.id)
|
|
103
|
+
OFFSET 0
|
|
104
|
+
) g
|
|
105
|
+
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
|
|
106
|
+
),`
|
|
107
|
+
: sql``}
|
|
78
108
|
'ended', (
|
|
79
109
|
SELECT coalesce(json_agg(related_seq::text), '[]') FROM audit_log
|
|
80
110
|
WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq = ANY(${references})
|
package/src/snapshot.sqlite.ts
CHANGED
|
@@ -31,6 +31,7 @@ export function snapshotQuery(asked: Asked): SQL {
|
|
|
31
31
|
const tomb = `%${TOMBSTONE}%`;
|
|
32
32
|
const released = asked.released;
|
|
33
33
|
const head = asked.head === true;
|
|
34
|
+
const seals = asked.seals === true;
|
|
34
35
|
return sql`
|
|
35
36
|
WITH policy AS (
|
|
36
37
|
SELECT * FROM audit_log WHERE author = 'vault' AND action = 'policy.change' AND decision = 'allow' ORDER BY seq DESC LIMIT 1
|
|
@@ -66,21 +67,45 @@ export function snapshotQuery(asked: Asked): SQL {
|
|
|
66
67
|
) WHERE newest IS NOT NULL
|
|
67
68
|
)),
|
|
68
69
|
'policy', json((SELECT ${ENTRY} FROM policy e)),
|
|
70
|
+
'settings', ${asked.settings === true
|
|
71
|
+
? sql`json((SELECT ${ENTRY} FROM audit_log e WHERE author = 'vault' AND action = 'settings.change' AND decision = 'allow' ORDER BY seq DESC LIMIT 1))`
|
|
72
|
+
: sql`NULL`},
|
|
69
73
|
'environments', json((
|
|
70
74
|
SELECT json_group_array(json_object(
|
|
71
75
|
'id', env.id, 'projectId', env.project_id, 'slug', env.slug, 'owner', env.owner, 'parentId', env.parent_id,
|
|
72
76
|
'deleted', env.slug LIKE ${tomb} OR project.slug LIKE ${tomb},
|
|
73
|
-
'archived', env.archived_at IS NOT NULL OR project.archived_at IS NOT NULL
|
|
77
|
+
'archived', env.archived_at IS NOT NULL OR project.archived_at IS NOT NULL${seals
|
|
78
|
+
? sql`,
|
|
79
|
+
-- What 0.15's log holds the row to (T159), but its newest change's seal, which snapshot reads after.
|
|
80
|
+
'protected', env.protected, 'ownArchived', env.archived_at IS NOT NULL, 'projectArchived', project.archived_at IS NOT NULL,
|
|
81
|
+
'seal', lower(hex(env.seal))`
|
|
82
|
+
: sql``}))
|
|
74
83
|
FROM environments env JOIN projects project ON project.id = env.project_id
|
|
75
84
|
WHERE env.id IN (SELECT value FROM json_each(${environments}))
|
|
76
|
-
OR env.id IN (SELECT parent_id FROM environments WHERE id IN (SELECT value FROM json_each(${environments})) AND owner IS NOT NULL)
|
|
85
|
+
OR env.id IN (SELECT parent_id FROM environments WHERE id IN (SELECT value FROM json_each(${environments})) AND owner IS NOT NULL)${seals
|
|
86
|
+
? sql`
|
|
87
|
+
OR env.id IN (SELECT environment_id FROM vault_grants WHERE principal IN (SELECT value FROM json_each(${principals})) AND environment_id IS NOT NULL)`
|
|
88
|
+
: sql``}
|
|
77
89
|
OR env.id IN (
|
|
78
90
|
SELECT json_extract(place.value, '$.environmentId')
|
|
79
91
|
FROM policy, json_each(policy.metadata, '$.policy.protected') AS place
|
|
80
92
|
-- Of a forged entry's, an element that is no object names nothing: json_extract would fail on a string.
|
|
81
93
|
WHERE json_valid(policy.metadata) AND json_type(policy.metadata, '$.policy.protected') = 'array' AND place.type = 'object'
|
|
82
94
|
)
|
|
83
|
-
))
|
|
95
|
+
)),${seals
|
|
96
|
+
? sql`
|
|
97
|
+
-- Every place id asked, row or none: snapshot finds those the log holds a change of and no row answers to (review r5).
|
|
98
|
+
'placeIds', json((
|
|
99
|
+
SELECT json_group_array(id) FROM (
|
|
100
|
+
SELECT value AS id FROM json_each(${environments})
|
|
101
|
+
UNION SELECT parent_id FROM environments WHERE id IN (SELECT value FROM json_each(${environments})) AND owner IS NOT NULL
|
|
102
|
+
UNION SELECT environment_id FROM vault_grants WHERE principal IN (SELECT value FROM json_each(${principals})) AND environment_id IS NOT NULL
|
|
103
|
+
UNION SELECT json_extract(place.value, '$.environmentId')
|
|
104
|
+
FROM policy, json_each(policy.metadata, '$.policy.protected') AS place
|
|
105
|
+
WHERE json_valid(policy.metadata) AND json_type(policy.metadata, '$.policy.protected') = 'array' AND place.type = 'object'
|
|
106
|
+
) WHERE id IS NOT NULL
|
|
107
|
+
)),`
|
|
108
|
+
: sql``}
|
|
84
109
|
'ended', json((
|
|
85
110
|
SELECT json_group_array(related_seq) FROM audit_log
|
|
86
111
|
WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq IN (SELECT value FROM json_each(${references}))
|
package/src/snapshot.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { StoredEntry } from '@coffre/core/audit';
|
|
2
2
|
import type { Queryable, Transaction } from '@coffre/db';
|
|
3
|
-
import { LogHeadMismatch, lockLogHead } from '@coffre/db/log';
|
|
3
|
+
import { LogHeadMismatch, lockLogHead, lockLogHeadQueued } from '@coffre/db/log';
|
|
4
4
|
import { engineOf, rowsOf } from '@coffre/db/dialect';
|
|
5
5
|
import type { GrantRow } from '@coffre/db/grants';
|
|
6
6
|
|
|
7
7
|
import { snapshotQuery as postgres } from './snapshot.postgres.ts';
|
|
8
8
|
import { snapshotQuery as sqlite } from './snapshot.sqlite.ts';
|
|
9
|
+
import { objectKey } from './inputs.ts';
|
|
9
10
|
import * as store from './store.ts';
|
|
10
11
|
import type { EnvironmentRow, Member } from './store.ts';
|
|
11
12
|
|
|
@@ -39,9 +40,13 @@ export type Snapshot = {
|
|
|
39
40
|
newestAccess: Map<string, StoredEntry>;
|
|
40
41
|
/** The newest allowed `policy.change` in the vault's name, unchecked. */
|
|
41
42
|
newestPolicy: StoredEntry | null;
|
|
43
|
+
/** With `asked.settings`, the newest allowed `settings.change` in the vault's name, unchecked, or null for none; not asked, undefined. */
|
|
44
|
+
newestSettings: StoredEntry | null | undefined;
|
|
42
45
|
/**
|
|
43
46
|
* The asked environments, the environments each personal config among
|
|
44
|
-
* them is a config of, and those the newest `policy.change` names, by id
|
|
47
|
+
* them is a config of, and those the newest `policy.change` names, by id;
|
|
48
|
+
* with `asked.seals`, those the asked principals' grants are on too, each
|
|
49
|
+
* with what 0.15's log holds it to (`EnvironmentRow.sealed`).
|
|
45
50
|
*/
|
|
46
51
|
environments: Map<string, EnvironmentRow>;
|
|
47
52
|
/** Of the asked `reference.create` entries, those a `reference.end` of the vault's names, allowed. */
|
|
@@ -72,7 +77,15 @@ export type Asked = {
|
|
|
72
77
|
/** Whose grants and newest access entry; with `members`, their rows too, unlocked. */
|
|
73
78
|
principals: readonly string[];
|
|
74
79
|
members?: boolean;
|
|
80
|
+
/** The newest `settings.change` too (`newestSettings`). */
|
|
81
|
+
settings?: boolean;
|
|
75
82
|
environments?: readonly string[];
|
|
83
|
+
/**
|
|
84
|
+
* Each environment read with what 0.15's log holds it to, and the asked
|
|
85
|
+
* principals' grants' too, whose projects their grants are read with
|
|
86
|
+
* (T159): asked once 0.15's log is cut over, before which no row is sealed.
|
|
87
|
+
*/
|
|
88
|
+
seals?: boolean;
|
|
76
89
|
references?: readonly bigint[];
|
|
77
90
|
/**
|
|
78
91
|
* One principal's releases within the last `windowMs` of the clock, moved
|
|
@@ -83,6 +96,12 @@ export type Asked = {
|
|
|
83
96
|
released?: { principal: string; keys: readonly string[]; windowMs: number; clockOffsetMs: number; cap: number };
|
|
84
97
|
/** Lock the log's head first. */
|
|
85
98
|
head?: boolean;
|
|
99
|
+
/**
|
|
100
|
+
* Held by another, wait for the head only in one of this many places in
|
|
101
|
+
* line, and refuse at once when none is free (`lockLogHeadQueued`); with
|
|
102
|
+
* none, wait as long as the transaction's bound lets.
|
|
103
|
+
*/
|
|
104
|
+
waiters?: number;
|
|
86
105
|
};
|
|
87
106
|
|
|
88
107
|
/** One statement's read, as `Snapshot` says; with the head, two more when another holds it. */
|
|
@@ -94,7 +113,7 @@ export async function snapshot(db: Queryable, asked: Asked): Promise<Snapshot> {
|
|
|
94
113
|
if (asked.head !== true || (first.head !== null && current(first.head))) return first;
|
|
95
114
|
// Held by another, or moved in the instant before the lock: everything again, once the head is this transaction's.
|
|
96
115
|
// Asked for the head, `db` is a transaction (the overloads above).
|
|
97
|
-
if (first.head === null) await lockLogHead(db as Transaction);
|
|
116
|
+
if (first.head === null) await (asked.waiters === undefined ? lockLogHead(db as Transaction) : lockLogHeadQueued(db as Transaction, asked.waiters));
|
|
98
117
|
const again = await read();
|
|
99
118
|
if (again.head === null) throw new Error("the log's head, held by this transaction, was read as held by another");
|
|
100
119
|
// Held, nothing appends: a head that names another last entry was moved, or the log written, around it (`appendEntries`).
|
|
@@ -111,20 +130,48 @@ export function current(head: LockedHead): boolean {
|
|
|
111
130
|
}
|
|
112
131
|
|
|
113
132
|
async function readOnce(db: Queryable, asked: Asked): Promise<Snapshot> {
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
const
|
|
133
|
+
const engine = engineOf(db);
|
|
134
|
+
const query = (engine === 'postgres' ? postgres : sqlite)(asked);
|
|
135
|
+
const statement = async (on: Queryable) => {
|
|
136
|
+
const [row] = await rowsOf<{ snapshot: string | Raw }>(on, query);
|
|
137
|
+
return typeof row!.snapshot === 'string' ? (JSON.parse(row!.snapshot) as Raw) : row!.snapshot;
|
|
138
|
+
};
|
|
139
|
+
if (engine === 'postgres' || asked.seals !== true) return snapshotOf(asked, await statement(db), null);
|
|
140
|
+
// SQLite has no `log_object`: the environments' newest changes in a statement of their own, by the keys the vault
|
|
141
|
+
// computes, the places asked with no row among them (`gone`, which Postgres's statement answers itself). Both in one
|
|
142
|
+
// transaction, one snapshot of the file, so that a change committed between them is never a row's seal against a
|
|
143
|
+
// newer one (review r6); inside a decision's, a savepoint of it.
|
|
144
|
+
return store.oneSnapshot(db, async (on) => {
|
|
145
|
+
const raw = await statement(on);
|
|
146
|
+
const rowless = (raw.placeIds ?? []).filter((id) => !raw.environments.some((row) => row.id === id));
|
|
147
|
+
return snapshotOf(asked, raw, await store.newestSeals(on, [...raw.environments.map(({ id }) => id), ...rowless].map((id) => ({ kind: 'environment' as const, id }))), rowless);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A snapshot from its statement's row; on SQLite, with the newest seals and the rowless places read beside it. */
|
|
152
|
+
function snapshotOf(asked: Asked, raw: Raw, newest: Map<string, Buffer | null> | null, rowless: readonly string[] = []): Snapshot {
|
|
153
|
+
const gone = newest === null
|
|
154
|
+
? (raw.gone ?? []).map(({ id, newest: last }) => [id, hex(last)] as const)
|
|
155
|
+
: rowless.flatMap((id) => (newest.has(objectKey({ kind: 'environment', id })) ? [[id, newest.get(objectKey({ kind: 'environment', id })) ?? null] as const] : []));
|
|
117
156
|
return {
|
|
118
157
|
at: Number(raw.at),
|
|
119
158
|
members: new Map(raw.members.map((member) => [member.principal, memberOf(member)])),
|
|
120
159
|
grants: new Map(asked.principals.map((principal) => [principal, raw.grants.filter((grant) => grant.principal === principal).map(grantOf)])),
|
|
121
160
|
newestAccess: new Map(raw.access.map((entry) => [entry.subjectPrincipal!, entryOf(entry)])),
|
|
122
161
|
newestPolicy: raw.policy === null ? null : entryOf(raw.policy),
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
162
|
+
newestSettings: asked.settings !== true ? undefined : raw.settings == null ? null : entryOf(raw.settings),
|
|
163
|
+
environments: new Map<string, EnvironmentRow>([...gone.map(([id, last]) => [id, store.goneEnvironment(id, last)] as const), ...raw.environments.map(({ id, projectId, slug, owner, parentId, deleted, archived, ...sealed }) => [id, {
|
|
164
|
+
projectId, slug, owner, parentId,
|
|
165
|
+
deleted: Boolean(deleted),
|
|
166
|
+
archived: Boolean(archived),
|
|
167
|
+
...(asked.seals === true ? {
|
|
168
|
+
sealed: store.environmentSeal(
|
|
169
|
+
{ id, projectId, slug, owner, parentId, protected: sealed.protected == null ? null : Boolean(sealed.protected), archived: Boolean(sealed.ownArchived), projectArchived: Boolean(sealed.projectArchived) },
|
|
170
|
+
hex(sealed.seal),
|
|
171
|
+
newest === null ? hex(sealed.newest) : newest.get(objectKey({ kind: 'environment', id })) ?? null,
|
|
172
|
+
),
|
|
173
|
+
} : {}),
|
|
174
|
+
}] as const)]),
|
|
128
175
|
ended: new Set(raw.ended.map((seq) => BigInt(seq))),
|
|
129
176
|
released: Number(raw.released ?? 0),
|
|
130
177
|
head: raw.head === null ? null : {
|
|
@@ -159,11 +206,24 @@ type Raw = {
|
|
|
159
206
|
grants: { principal: string; projectId: string | null; environmentId: string | null; environmentSlug: string | null; role: string; expiresAt: Int | null; grantedAt: Int; grantedBy: string }[];
|
|
160
207
|
access: RawEntry[];
|
|
161
208
|
policy: RawEntry | null;
|
|
162
|
-
|
|
209
|
+
settings?: RawEntry | null;
|
|
210
|
+
environments: {
|
|
211
|
+
id: string; projectId: string; slug: string; owner: string | null; parentId: string | null; deleted: boolean | number; archived: boolean | number;
|
|
212
|
+
/** With `asked.seals`: the object's own columns, the row's seal and, on Postgres, its newest change's, as hex. */
|
|
213
|
+
protected?: boolean | number | null; ownArchived?: boolean | number; projectArchived?: boolean | number; seal?: string | null; newest?: string | null;
|
|
214
|
+
}[];
|
|
215
|
+
/** With `asked.seals`, on Postgres: the places asked that the log holds a change of and no row answers to. */
|
|
216
|
+
gone?: { id: string; newest: string | null }[];
|
|
217
|
+
/** With `asked.seals`, on SQLite: every place id asked, row or none. */
|
|
218
|
+
placeIds?: string[];
|
|
163
219
|
ended: Int[];
|
|
164
220
|
released: Int | null;
|
|
165
221
|
};
|
|
166
222
|
|
|
223
|
+
function hex(value: string | null | undefined): Buffer | null {
|
|
224
|
+
return value == null ? null : Buffer.from(value, 'hex');
|
|
225
|
+
}
|
|
226
|
+
|
|
167
227
|
/** An entry as the chain's codec reads it, from what `entryJson` wrote. */
|
|
168
228
|
export function entryOf(raw: RawEntry): StoredEntry {
|
|
169
229
|
return {
|