@coffre/vault 0.10.0 → 0.10.2
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/dist/cloudflare.d.ts +2 -1
- package/dist/cloudflare.js +2 -2
- package/dist/{index-CByXzyi-.d.ts → index-zkW6DEm9.d.ts} +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/node.d.ts +12 -1
- package/dist/node.js +2 -2
- package/dist/{src-D82jyprb.js → src-B0d-W-SI.js} +4 -3
- package/dist/{vault-COqZ12ny.js → vault-66Xf0Hw1.js} +1474 -543
- package/package.json +3 -3
- package/src/accounting.ts +37 -8
- package/src/config.ts +6 -5
- package/src/log.ts +91 -60
- package/src/replay.ts +1 -1
- package/src/snapshot.postgres.ts +97 -0
- package/src/snapshot.sqlite.ts +103 -0
- package/src/snapshot.ts +191 -0
- package/src/store.ts +88 -46
- package/src/tally.ts +241 -0
- package/src/vault.ts +580 -273
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffre/vault",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.2",
|
|
4
4
|
"description": "coffre's vault: keys, grants and members, in the database the app uses, as a Cloudflare Worker or on Node.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"drizzle-orm": "0.45.2",
|
|
39
|
-
"@coffre/core": "0.10.
|
|
40
|
-
"@coffre/db": "0.10.
|
|
39
|
+
"@coffre/core": "0.10.2",
|
|
40
|
+
"@coffre/db": "0.10.2"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "26.1.1",
|
package/src/accounting.ts
CHANGED
|
@@ -7,19 +7,48 @@ type Item = { item: number; secretId: string; version: number; subject: string }
|
|
|
7
7
|
type ParsedIntent = { intentId: string; operation: string; expiresAt: number; keys: Item[] };
|
|
8
8
|
/** An outcome entry: one item of its intent, or several, for a read logged once per place. */
|
|
9
9
|
type ParsedOutcome = { intentId: string; relatedSeq: bigint; items: Item[] };
|
|
10
|
-
type Intent = ParsedIntent & {
|
|
10
|
+
type Intent = ParsedIntent & { seq: bigint; actor: string; seen: Set<number> };
|
|
11
11
|
type Accounting = { ok: true; pending: number } | Extract<LogVerification, { ok: false }>;
|
|
12
|
+
type Broken = Extract<Accounting, { ok: false }>;
|
|
13
|
+
|
|
14
|
+
/** A key batch still under way, as a checkpoint keeps it: its intent's entry, and the items whose outcomes are logged. */
|
|
15
|
+
export type OpenIntent = { seq: number; intentId: string; operation: string; expiresAt: number; actor: string; keys: Item[]; seen: number[] };
|
|
16
|
+
|
|
17
|
+
/** Where the accounting stands, as a checkpoint keeps it: the batches still open, and the first fault, once found. */
|
|
18
|
+
export type AccountingState = { intents: OpenIntent[]; fault: { failedAtSeq: number; reason: string } | null };
|
|
12
19
|
|
|
13
20
|
/**
|
|
14
21
|
* Every key operation's intent and its outcomes, entry by entry, oldest
|
|
15
|
-
* first, as
|
|
16
|
-
*
|
|
22
|
+
* first, as a walk reads the vault's entries (`add`); then what is still
|
|
23
|
+
* open (`result`). The chain can hold while a process died between an
|
|
17
24
|
* intent and its outcomes: only a missed deadline is a fault.
|
|
25
|
+
*
|
|
26
|
+
* A walk resumes from a checkpoint's `state()`: the batches open there, and
|
|
27
|
+
* nothing of those that closed. So an intent's identity is checked against
|
|
28
|
+
* those still open and those since, not every one the log ever held: a
|
|
29
|
+
* closed batch takes no more outcomes in any case, since each names its
|
|
30
|
+
* intent's entry.
|
|
18
31
|
*/
|
|
19
32
|
export class KeyAccounting {
|
|
20
33
|
readonly #intents = new Map<bigint, Intent>();
|
|
21
34
|
readonly #ids = new Set<string>();
|
|
22
|
-
#fault:
|
|
35
|
+
#fault: Broken | null = null;
|
|
36
|
+
|
|
37
|
+
constructor(from: AccountingState = { intents: [], fault: null }) {
|
|
38
|
+
for (const { seq, seen, ...open } of from.intents) {
|
|
39
|
+
this.#intents.set(BigInt(seq), { ...open, seq: BigInt(seq), seen: new Set(seen) });
|
|
40
|
+
this.#ids.add(open.intentId);
|
|
41
|
+
}
|
|
42
|
+
this.#fault = from.fault === null ? null : { ok: false, ...from.fault };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Where it stands, to resume from: the batches open, oldest first. */
|
|
46
|
+
state(): AccountingState {
|
|
47
|
+
const intents = [...this.#intents.values()]
|
|
48
|
+
.sort((a, b) => (a.seq < b.seq ? -1 : 1))
|
|
49
|
+
.map(({ seq, seen, ...open }) => ({ ...open, seq: Number(seq), seen: [...seen].sort((a, b) => a - b) }));
|
|
50
|
+
return { intents, fault: this.#fault === null ? null : { failedAtSeq: this.#fault.failedAtSeq!, reason: this.#fault.reason } };
|
|
51
|
+
}
|
|
23
52
|
|
|
24
53
|
add(entry: StoredEntry): void {
|
|
25
54
|
if (this.#fault !== null || entry.author !== 'vault') return;
|
|
@@ -31,7 +60,7 @@ export class KeyAccounting {
|
|
|
31
60
|
if (typeof parsed === 'string') return broken(parsed);
|
|
32
61
|
if (this.#ids.has(parsed.intentId)) return broken('key intent repeats an operation identity');
|
|
33
62
|
this.#ids.add(parsed.intentId);
|
|
34
|
-
if (parsed.keys.length > 0) this.#intents.set(entry.seq, { ...parsed, entry, seen: new Set() });
|
|
63
|
+
if (parsed.keys.length > 0) this.#intents.set(entry.seq, { ...parsed, seq: entry.seq, actor: entry.actor, seen: new Set() });
|
|
35
64
|
} else if (KEY_ACTIONS.has(entry.action) && entry.relatedSeq !== null) {
|
|
36
65
|
// An outcome names its intent; a key released with no service to call has none.
|
|
37
66
|
const parsed = parseOutcome(entry);
|
|
@@ -42,7 +71,7 @@ export class KeyAccounting {
|
|
|
42
71
|
if (intent === undefined || key === undefined || intent.seen.has(outcome.item)) {
|
|
43
72
|
return broken('key outcome does not identify one item of its intent');
|
|
44
73
|
}
|
|
45
|
-
if (parsed.intentId !== intent.intentId || entry.actor !== intent.
|
|
74
|
+
if (parsed.intentId !== intent.intentId || entry.actor !== intent.actor || entry.action !== intent.operation) {
|
|
46
75
|
return broken('key outcome belongs to another operation');
|
|
47
76
|
}
|
|
48
77
|
if (outcome.secretId !== key.secretId || outcome.version !== key.version || outcome.subject !== key.subject) {
|
|
@@ -51,7 +80,7 @@ export class KeyAccounting {
|
|
|
51
80
|
intent.seen.add(outcome.item);
|
|
52
81
|
}
|
|
53
82
|
// Completed batches need no state, and another outcome for one is a duplicate.
|
|
54
|
-
if (intent !== undefined && intent.seen.size === intent.keys.length) this.#intents.delete(intent.
|
|
83
|
+
if (intent !== undefined && intent.seen.size === intent.keys.length) this.#intents.delete(intent.seq);
|
|
55
84
|
}
|
|
56
85
|
}
|
|
57
86
|
|
|
@@ -65,7 +94,7 @@ export class KeyAccounting {
|
|
|
65
94
|
const missing = intent.keys.length - intent.seen.size;
|
|
66
95
|
return `key intent ${intent.intentId} is overdue: ${missing} of ${intent.keys.length} outcomes missing`;
|
|
67
96
|
}).join('; ');
|
|
68
|
-
return { ok: false, failedAtSeq: Number(overdue[0]!.
|
|
97
|
+
return { ok: false, failedAtSeq: Number(overdue[0]!.seq), reason };
|
|
69
98
|
}
|
|
70
99
|
}
|
|
71
100
|
|
package/src/config.ts
CHANGED
|
@@ -2,13 +2,14 @@ import { hkdfSync } from 'node:crypto';
|
|
|
2
2
|
|
|
3
3
|
import { KekRegistry, LocalKekProvider, type KekProvider } from '@coffre/core/kek';
|
|
4
4
|
|
|
5
|
-
/** At most `count` data keys unwrapped per principal in any `windowMs`. */
|
|
5
|
+
/** At most `count` distinct data keys unwrapped per principal in any `windowMs`. */
|
|
6
6
|
export type BulkLimit = { count: number; windowMs: number };
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* 1000 keys in 15 minutes
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* 1000 different keys in 15 minutes, which no person or deploy pipeline
|
|
10
|
+
* reads, while a script pulling every secret it can reach stops within a
|
|
11
|
+
* few seconds. A key read again counts once: a 50-key `coffre run` is 50
|
|
12
|
+
* however often it runs.
|
|
12
13
|
*/
|
|
13
14
|
export const DEFAULT_BULK_LIMIT: BulkLimit = { count: 1000, windowMs: 15 * 60_000 };
|
|
14
15
|
|
|
@@ -62,7 +63,7 @@ export type VaultConfig = {
|
|
|
62
63
|
* service holds the KEK, as AWS KMS does: the vault never sees that key.
|
|
63
64
|
*/
|
|
64
65
|
signingKey?: string;
|
|
65
|
-
/** At most `count` data keys unwrapped per principal in any `windowMinutes`; 1000 in 15 unless set. */
|
|
66
|
+
/** At most `count` distinct data keys unwrapped per principal in any `windowMinutes`; 1000 in 15 unless set. */
|
|
66
67
|
bulkLimit?: { count: number; windowMinutes: number };
|
|
67
68
|
/**
|
|
68
69
|
* @deprecated Gone in 0.10, and refused: each key says who sees it now. Mark
|
package/src/log.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { deriveLogKey, GENESIS_HASH, verifyEntries, type LogKey, type StoredEntr
|
|
|
2
2
|
import type { LogHead, LogVerification } from '@coffre/core/vault';
|
|
3
3
|
import type { Queryable } from '@coffre/db';
|
|
4
4
|
|
|
5
|
-
import { entriesFrom, hashAt } from './store.ts';
|
|
5
|
+
import { entriesFrom, hashAt, vaultEntriesAfter } from './store.ts';
|
|
6
|
+
import type { Walk } from './tally.ts';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* The vault's half of the one audit log: its entries are the ones it
|
|
@@ -21,83 +22,113 @@ export function vaultLogKey(signingKey: Uint8Array): LogKey {
|
|
|
21
22
|
return deriveLogKey('vault', signingKey);
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
* `nextSeq`, the last of which has `hash`, with `vaultEntries` of the
|
|
27
|
-
* vault's among them, written under `vaultKeys`, in the order the log moved
|
|
28
|
-
* through them (`forward`). In memory only: the database cannot vouch for
|
|
29
|
-
* itself.
|
|
30
|
-
*/
|
|
31
|
-
export type Anchor = { nextSeq: bigint; hash: Buffer; vaultEntries: number; vaultKeys: readonly string[] };
|
|
25
|
+
/** Where a walk of the log stands: the last entry it covered, -1 before the first, and that entry's hash. */
|
|
26
|
+
export type Position = { seq: bigint; hash: Buffer };
|
|
32
27
|
|
|
33
|
-
/** Before the first entry
|
|
34
|
-
export const
|
|
28
|
+
/** Before the first entry. */
|
|
29
|
+
export const START: Position = { seq: -1n, hash: GENESIS_HASH };
|
|
35
30
|
|
|
36
|
-
/** The further of two
|
|
37
|
-
export function further(a:
|
|
38
|
-
return b.
|
|
31
|
+
/** The further of two positions, when two calls walked at once. */
|
|
32
|
+
export function further(a: Position, b: Position): Position {
|
|
33
|
+
return b.seq > a.seq ? b : a;
|
|
39
34
|
}
|
|
40
35
|
|
|
41
36
|
/** Entries read at once, at most, and no more than `BATCH_BYTES` of what they say: well within a Worker's memory, and a fifth of the round trips of 1000. */
|
|
42
37
|
export const VERIFY_BATCH = 5000;
|
|
43
38
|
|
|
44
|
-
type
|
|
39
|
+
type Broken = Extract<LogVerification, { ok: false }>;
|
|
45
40
|
|
|
46
41
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
42
|
+
* Walk the chain on from `from`, a batch in memory at a time, through `to`
|
|
43
|
+
* or the log's last entry, and no more than `limit` entries: every link and
|
|
44
|
+
* hash recomputed, each of the vault's entries by its MAC, its keys moving
|
|
45
|
+
* only forward, and each fed to `walk`. Where it got to, and whether that
|
|
46
|
+
* is as far as it was to go: a walk cut short by `limit` goes on from there
|
|
47
|
+
* another time.
|
|
49
48
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* - `anchor`, the head at the last check: still there, unchanged. A rewrite
|
|
53
|
-
* of anything before it, chained again to hide, changes its hash;
|
|
54
|
-
* - every entry after the anchor, or from the first.
|
|
55
|
-
*
|
|
56
|
-
* So a view rehashes only what is new since the last one. What it leaves
|
|
57
|
-
* out is an entry before the anchor edited in place, not chained again, and
|
|
58
|
-
* not on the page: a full check, which starts from `UNVERIFIED`, finds that,
|
|
59
|
-
* as does the first view after a start, which has no anchor. `onBatch` sees
|
|
60
|
-
* each batch once it has verified, for checks that read the same entries.
|
|
49
|
+
* It takes `from` as given: whether the log still holds it is the caller's
|
|
50
|
+
* to check, with the other heads it must hold, in one read.
|
|
61
51
|
*/
|
|
62
|
-
export async function
|
|
52
|
+
export async function rehash(
|
|
63
53
|
db: Queryable,
|
|
64
54
|
logKeys: readonly LogKey[],
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
): Promise<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
55
|
+
from: Position,
|
|
56
|
+
walk: Walk,
|
|
57
|
+
{ to, limit = Infinity }: { to?: bigint; limit?: number },
|
|
58
|
+
): Promise<{ ok: true; at: Position; done: boolean } | Broken> {
|
|
59
|
+
let at = from;
|
|
60
|
+
for (let left = limit; ; ) {
|
|
61
|
+
if (to !== undefined && at.seq >= to) return { ok: true, at, done: true };
|
|
62
|
+
// Out of entries to walk, it is done if there are none left to: a walk that ends at its budget's last read one more.
|
|
63
|
+
if (left <= 0) return { ok: true, at, done: !(await entriesFrom(db, at.seq + 1n, 1)).some((entry) => to === undefined || entry.seq <= to) };
|
|
64
|
+
let batch = await entriesFrom(db, at.seq + 1n, Math.min(VERIFY_BATCH, left));
|
|
65
|
+
if (to !== undefined) batch = batch.filter((entry) => entry.seq <= to);
|
|
66
|
+
if (batch.length === 0) return { ok: true, at, done: to === undefined };
|
|
67
|
+
const result = verifyEntries(batch, { keys: logKeys, chainOnly: ['app'], startSeq: at.seq + 1n, startPrevHash: at.hash });
|
|
68
|
+
if (!result.ok) return { ok: false, failedAtSeq: Number(result.failedAtSeq), reason: withCause(result.reason) };
|
|
69
|
+
for (const entry of batch) {
|
|
70
|
+
const moved = entry.author === 'vault' ? walk.add(entry, logKeys[0]!.keyId) : null;
|
|
71
|
+
if (moved !== null) return { ok: false, failedAtSeq: Number(moved.failedAtSeq), reason: moved.reason };
|
|
72
|
+
}
|
|
73
|
+
at = { seq: result.nextSeq - 1n, hash: result.head };
|
|
74
|
+
left -= batch.length;
|
|
78
75
|
}
|
|
76
|
+
}
|
|
79
77
|
|
|
80
|
-
|
|
81
|
-
|
|
78
|
+
/**
|
|
79
|
+
* The chain again from `from`, through `to` and no more than `limit`
|
|
80
|
+
* entries: each hash recomputed from its entry and each link checked, and
|
|
81
|
+
* nothing else, so a stretch costs a hash an entry. What a checkpoint does
|
|
82
|
+
* in the background to the prefixes signed before (`#scrub` in vault.ts):
|
|
83
|
+
* each entry's MAC was checked when it was signed over, and its hash pins
|
|
84
|
+
* it since, so all that can change behind a signed prefix and keep the
|
|
85
|
+
* chain is an entry edited in place, its stored hash left, which its hash
|
|
86
|
+
* finds.
|
|
87
|
+
*/
|
|
88
|
+
export async function relink(db: Queryable, from: Position, to: bigint, limit: number): Promise<{ ok: true; at: Position } | Broken> {
|
|
89
|
+
let at = from;
|
|
90
|
+
while (at.seq < to && limit > 0) {
|
|
91
|
+
const batch = (await entriesFrom(db, at.seq + 1n, Math.min(VERIFY_BATCH, limit))).filter((entry) => entry.seq <= to);
|
|
92
|
+
if (batch.length === 0) break;
|
|
93
|
+
const result = verifyEntries(batch, { keys: [], chainOnly: ['app', 'vault'], startSeq: at.seq + 1n, startPrevHash: at.hash });
|
|
94
|
+
if (!result.ok) return { ok: false, failedAtSeq: Number(result.failedAtSeq), reason: result.reason };
|
|
95
|
+
at = { seq: result.nextSeq - 1n, hash: result.head };
|
|
96
|
+
limit -= batch.length;
|
|
82
97
|
}
|
|
98
|
+
return { ok: true, at };
|
|
99
|
+
}
|
|
83
100
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
101
|
+
/**
|
|
102
|
+
* The vault's own entries after `after`, through `to` or its last, no more
|
|
103
|
+
* than `limit` of them, oldest first: each against its own hash, and by its
|
|
104
|
+
* MAC, over the link it names, its keys moving only forward, each fed to
|
|
105
|
+
* `walk`. The links between entries are the caller's to recompute, as the
|
|
106
|
+
* app does over the same stretch. The last it read, and whether that is as
|
|
107
|
+
* far as it was to go.
|
|
108
|
+
*/
|
|
109
|
+
export async function ownEntries(
|
|
110
|
+
db: Queryable,
|
|
111
|
+
logKeys: readonly LogKey[],
|
|
112
|
+
after: bigint,
|
|
113
|
+
walk: Walk,
|
|
114
|
+
{ to, limit = Infinity }: { to?: bigint; limit?: number },
|
|
115
|
+
): Promise<{ ok: true; at: bigint; done: boolean } | Broken> {
|
|
116
|
+
for (let left = limit; ; ) {
|
|
117
|
+
if (left <= 0) return { ok: true, at: after, done: !(await vaultEntriesAfter(db, after, 1)).some((entry) => to === undefined || entry.seq <= to) };
|
|
118
|
+
let batch = await vaultEntriesAfter(db, after, Math.min(VERIFY_BATCH, left));
|
|
119
|
+
if (to !== undefined) batch = batch.filter((entry) => entry.seq <= to);
|
|
120
|
+
if (batch.length === 0) return { ok: true, at: after, done: true };
|
|
121
|
+
for (const entry of batch) {
|
|
122
|
+
const result = verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys: logKeys });
|
|
123
|
+
if (!result.ok) return { ok: false, failedAtSeq: Number(result.failedAtSeq), reason: withCause(result.reason) };
|
|
124
|
+
}
|
|
125
|
+
for (const entry of batch) {
|
|
126
|
+
const moved = walk.add(entry, logKeys[0]!.keyId);
|
|
127
|
+
if (moved !== null) return { ok: false, failedAtSeq: Number(moved.failedAtSeq), reason: moved.reason };
|
|
128
|
+
}
|
|
129
|
+
after = batch[batch.length - 1]!.seq;
|
|
130
|
+
left -= batch.length;
|
|
99
131
|
}
|
|
100
|
-
return { verification: { ok: true, entries: verified.vaultEntries }, anchor: verified };
|
|
101
132
|
}
|
|
102
133
|
|
|
103
134
|
/**
|
package/src/replay.ts
CHANGED
|
@@ -141,7 +141,7 @@ function placeOf(row: StoredEntry, detail: Record<string, unknown>): Place {
|
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
/** A grant's place: its environment, or its project when it has none, or every project's path. */
|
|
144
|
-
function placeKey(place: Place): string {
|
|
144
|
+
export function placeKey(place: Place): string {
|
|
145
145
|
return place.environmentId ?? place.projectId ?? (place.environmentSlug === null ? '*' : `*/${place.environmentSlug}`);
|
|
146
146
|
}
|
|
147
147
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { TOMBSTONE } from '@coffre/core/schemas';
|
|
2
|
+
import { ACCESS_ACTIONS } from '@coffre/core/vault';
|
|
3
|
+
import { sql, type SQL } from 'drizzle-orm';
|
|
4
|
+
|
|
5
|
+
import type { Asked } from './snapshot.ts';
|
|
6
|
+
|
|
7
|
+
/** An entry of `audit_log` aliased `e`, as JSON (`RawEntry`): whole numbers past 2^53 as text, bytes as hex. */
|
|
8
|
+
const ENTRY = sql.raw(`json_build_object(
|
|
9
|
+
'seq', e.seq::text, 'author', e.author, 'keyId', e.key_id, 'occurredAt', e.occurred_at, 'actor', e.actor, 'action', e.action,
|
|
10
|
+
'decision', e.decision, 'code', e.code, 'subjectPrincipal', e.subject_principal, 'projectId', e.project_id,
|
|
11
|
+
'environmentId', e.environment_id, 'secretId', e.secret_id, 'secretVersionId', e.secret_version_id,
|
|
12
|
+
'operationId', e.operation_id, 'requestId', e.request_id, 'sourceIp', e.source_ip, 'relatedSeq', e.related_seq::text,
|
|
13
|
+
'metadata', e.metadata, 'prevHash', encode(e.prev_hash, 'hex'), 'mac', encode(e.mac, 'hex'), 'hash', encode(e.hash, 'hex'))`);
|
|
14
|
+
|
|
15
|
+
const NOW = sql.raw('(extract(epoch from clock_timestamp()) * 1000)::bigint');
|
|
16
|
+
|
|
17
|
+
/** `snapshot`'s statement on Postgres: one row, one JSON column. Each part reads one index range. */
|
|
18
|
+
export function snapshotQuery(asked: Asked): SQL {
|
|
19
|
+
const principals = sql`${sql.param([...asked.principals])}::text[]`;
|
|
20
|
+
const environments = sql`${sql.param([...(asked.environments ?? [])])}::uuid[]`;
|
|
21
|
+
const references = sql`${sql.param((asked.references ?? []).map(String))}::bigint[]`;
|
|
22
|
+
const actions = sql`${sql.param([...ACCESS_ACTIONS])}::text[]`;
|
|
23
|
+
const tomb = `%${TOMBSTONE}%`;
|
|
24
|
+
const released = asked.released;
|
|
25
|
+
// 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
|
+
const head = asked.head === true
|
|
27
|
+
? sql`, head AS (SELECT next_seq, head_hash FROM audit_chain_head WHERE only_row FOR UPDATE SKIP LOCKED)`
|
|
28
|
+
: sql``;
|
|
29
|
+
return sql`
|
|
30
|
+
WITH policy AS (
|
|
31
|
+
SELECT * FROM audit_log WHERE author = 'vault' AND action = 'policy.change' AND decision = 'allow' ORDER BY seq DESC LIMIT 1
|
|
32
|
+
)${head}
|
|
33
|
+
SELECT json_build_object(
|
|
34
|
+
'at', ${NOW},
|
|
35
|
+
'head', ${asked.head === true ? sql`(SELECT json_build_object('nextSeq', next_seq::text, 'hash', encode(head_hash, 'hex')) FROM head)` : sql`NULL`},
|
|
36
|
+
'last', ${asked.head === true ? sql`(SELECT json_build_object('seq', seq::text, 'hash', encode(hash, 'hex')) FROM audit_log ORDER BY seq DESC LIMIT 1)` : sql`NULL`},
|
|
37
|
+
'rotation', ${asked.head === true
|
|
38
|
+
? sql`(SELECT ${ENTRY} FROM audit_log e WHERE author = 'vault' AND action = 'key.rotate' AND decision = 'allow' ORDER BY seq DESC LIMIT 1)`
|
|
39
|
+
: sql`NULL`},
|
|
40
|
+
'members', ${asked.members === true
|
|
41
|
+
? sql`(SELECT coalesce(json_agg(json_build_object(
|
|
42
|
+
'principal', principal, 'status', status, 'owner', owner, 'role', role, 'scope', scope, 'generation', generation,
|
|
43
|
+
'createdAt', created_at, 'createdBy', created_by, 'statusChangedAt', status_changed_at, 'statusChangedBy', status_changed_by,
|
|
44
|
+
'accessSeq', access_seq::text, 'mac', encode(mac, 'hex'))), '[]') FROM vault_members WHERE principal = ANY(${principals}))`
|
|
45
|
+
: sql`'[]'::json`},
|
|
46
|
+
'grants', (
|
|
47
|
+
SELECT coalesce(json_agg(json_build_object(
|
|
48
|
+
'principal', g.principal, 'projectId', coalesce(g.project_id, env.project_id), 'environmentId', g.environment_id,
|
|
49
|
+
'environmentSlug', g.environment_slug, 'role', g.role, 'expiresAt', g.expires_at, 'grantedAt', g.granted_at, 'grantedBy', g.granted_by)), '[]')
|
|
50
|
+
FROM vault_grants g LEFT JOIN environments env ON env.id = g.environment_id
|
|
51
|
+
WHERE g.principal = ANY(${principals})
|
|
52
|
+
),
|
|
53
|
+
'access', (
|
|
54
|
+
SELECT coalesce(json_agg(${ENTRY}), '[]')
|
|
55
|
+
FROM unnest(${principals}) AS asked (principal)
|
|
56
|
+
CROSS JOIN LATERAL (
|
|
57
|
+
SELECT * FROM audit_log
|
|
58
|
+
WHERE author = 'vault' AND subject_principal = asked.principal AND action = ANY(${actions}) AND decision = 'allow'
|
|
59
|
+
ORDER BY seq DESC LIMIT 1
|
|
60
|
+
) e
|
|
61
|
+
),
|
|
62
|
+
'policy', (SELECT ${ENTRY} FROM policy e),
|
|
63
|
+
'environments', (
|
|
64
|
+
SELECT coalesce(json_agg(json_build_object(
|
|
65
|
+
'id', env.id, 'projectId', env.project_id, 'slug', env.slug, 'owner', env.owner, 'parentId', env.parent_id,
|
|
66
|
+
'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)), '[]')
|
|
68
|
+
FROM environments env JOIN projects project ON project.id = env.project_id
|
|
69
|
+
-- One list of ids, each found by the primary key.
|
|
70
|
+
WHERE env.id = ANY(ARRAY(
|
|
71
|
+
SELECT unnest(${environments})
|
|
72
|
+
UNION SELECT parent_id FROM environments WHERE id = ANY(${environments}) AND owner IS NOT NULL
|
|
73
|
+
-- What the newest policy protects, by the ids its JSON names; one that is no UUID names nothing.
|
|
74
|
+
UNION SELECT (place #>> '{}')::uuid FROM policy, jsonb_path_query(policy.metadata::jsonb, '$.policy.protected[*].environmentId') AS place
|
|
75
|
+
WHERE (place #>> '{}') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
|
76
|
+
))
|
|
77
|
+
),
|
|
78
|
+
'ended', (
|
|
79
|
+
SELECT coalesce(json_agg(related_seq::text), '[]') FROM audit_log
|
|
80
|
+
WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq = ANY(${references})
|
|
81
|
+
),
|
|
82
|
+
'released', ${released === undefined
|
|
83
|
+
? sql`0`
|
|
84
|
+
: sql`(
|
|
85
|
+
SELECT count(DISTINCT key) FROM (
|
|
86
|
+
-- A read of one key names it; of several at one place, lists them (readsByPlace, vault.ts). An entry, or an
|
|
87
|
+
-- item of its list, that names no key, which only a forged entry is, counts as one key of its own.
|
|
88
|
+
SELECT coalesce(e.secret_id::text, CASE jsonb_typeof(one -> 'secretId') WHEN 'string' THEN one ->> 'secretId' END, 'entry:' || e.seq) FROM audit_log e
|
|
89
|
+
-- Each item of a list, never what lax paths would make of an object; silent, as strict paths fail on what is missing.
|
|
90
|
+
LEFT JOIN LATERAL jsonb_path_query(CASE WHEN e.secret_id IS NULL THEN e.metadata::jsonb END, 'strict $.secrets ? (@.type() == "array")[*]', '{}', true) AS one ON true
|
|
91
|
+
WHERE e.author = 'vault' AND e.actor = ${released.principal} AND e.action = 'secret.read' AND e.decision = 'allow'
|
|
92
|
+
AND e.occurred_at > ${NOW} + ${released.clockOffsetMs} - ${released.windowMs}
|
|
93
|
+
UNION ALL SELECT unnest(${sql.param([...released.keys])}::text[])
|
|
94
|
+
) AS released (key)
|
|
95
|
+
)`}
|
|
96
|
+
) AS snapshot`;
|
|
97
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { TOMBSTONE } from '@coffre/core/schemas';
|
|
2
|
+
import { ACCESS_ACTIONS } from '@coffre/core/vault';
|
|
3
|
+
import { sql, type SQL } from 'drizzle-orm';
|
|
4
|
+
|
|
5
|
+
import type { Asked } from './snapshot.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* An entry of `audit_log` aliased `e`, as JSON (`RawEntry`), bytes as hex.
|
|
9
|
+
* A JSON value loses its type leaving a subquery, so whoever takes one
|
|
10
|
+
* from a subquery wraps it in `json(…)`.
|
|
11
|
+
*/
|
|
12
|
+
const ENTRY = sql.raw(`json_object(
|
|
13
|
+
'seq', e.seq, 'author', e.author, 'keyId', e.key_id, 'occurredAt', e.occurred_at, 'actor', e.actor, 'action', e.action,
|
|
14
|
+
'decision', e.decision, 'code', e.code, 'subjectPrincipal', e.subject_principal, 'projectId', e.project_id,
|
|
15
|
+
'environmentId', e.environment_id, 'secretId', e.secret_id, 'secretVersionId', e.secret_version_id,
|
|
16
|
+
'operationId', e.operation_id, 'requestId', e.request_id, 'sourceIp', e.source_ip, 'relatedSeq', e.related_seq,
|
|
17
|
+
'metadata', e.metadata, 'prevHash', hex(e.prev_hash), 'mac', hex(e.mac), 'hash', hex(e.hash))`);
|
|
18
|
+
|
|
19
|
+
const NOW = sql.raw(`CAST(unixepoch('subsec') * 1000 AS INTEGER)`);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `snapshot`'s statement on SQLite, the same as on Postgres. Nothing is
|
|
23
|
+
* locked: one transaction runs at a time (connect.ts), so the head is the
|
|
24
|
+
* last entry's.
|
|
25
|
+
*/
|
|
26
|
+
export function snapshotQuery(asked: Asked): SQL {
|
|
27
|
+
const principals = JSON.stringify(asked.principals);
|
|
28
|
+
const environments = JSON.stringify(asked.environments ?? []);
|
|
29
|
+
const references = JSON.stringify((asked.references ?? []).map(Number));
|
|
30
|
+
const actions = JSON.stringify(ACCESS_ACTIONS);
|
|
31
|
+
const tomb = `%${TOMBSTONE}%`;
|
|
32
|
+
const released = asked.released;
|
|
33
|
+
const head = asked.head === true;
|
|
34
|
+
return sql`
|
|
35
|
+
WITH policy AS (
|
|
36
|
+
SELECT * FROM audit_log WHERE author = 'vault' AND action = 'policy.change' AND decision = 'allow' ORDER BY seq DESC LIMIT 1
|
|
37
|
+
)
|
|
38
|
+
SELECT json_object(
|
|
39
|
+
'at', ${NOW},
|
|
40
|
+
'head', ${head ? sql`json((SELECT json_object('nextSeq', next_seq, 'hash', hex(head_hash)) FROM audit_chain_head WHERE only_row))` : sql`NULL`},
|
|
41
|
+
'last', ${head ? sql`json((SELECT json_object('seq', seq, 'hash', hex(hash)) FROM audit_log ORDER BY seq DESC LIMIT 1))` : sql`NULL`},
|
|
42
|
+
'rotation', ${head
|
|
43
|
+
? sql`json((SELECT ${ENTRY} FROM audit_log e WHERE author = 'vault' AND action = 'key.rotate' AND decision = 'allow' ORDER BY seq DESC LIMIT 1))`
|
|
44
|
+
: sql`NULL`},
|
|
45
|
+
'members', json(${asked.members === true
|
|
46
|
+
? sql`(SELECT json_group_array(json_object(
|
|
47
|
+
'principal', principal, 'status', status, 'owner', owner, 'role', role, 'scope', scope, 'generation', generation,
|
|
48
|
+
'createdAt', created_at, 'createdBy', created_by, 'statusChangedAt', status_changed_at, 'statusChangedBy', status_changed_by,
|
|
49
|
+
'accessSeq', access_seq, 'mac', hex(mac))) FROM vault_members WHERE principal IN (SELECT value FROM json_each(${principals})))`
|
|
50
|
+
: sql`'[]'`}),
|
|
51
|
+
'grants', json((
|
|
52
|
+
SELECT json_group_array(json_object(
|
|
53
|
+
'principal', g.principal, 'projectId', coalesce(g.project_id, env.project_id), 'environmentId', g.environment_id,
|
|
54
|
+
'environmentSlug', g.environment_slug, 'role', g.role, 'expiresAt', g.expires_at, 'grantedAt', g.granted_at, 'grantedBy', g.granted_by))
|
|
55
|
+
FROM vault_grants g LEFT JOIN environments env ON env.id = g.environment_id
|
|
56
|
+
WHERE g.principal IN (SELECT value FROM json_each(${principals}))
|
|
57
|
+
)),
|
|
58
|
+
'access', json((
|
|
59
|
+
SELECT json_group_array(json(newest)) FROM (
|
|
60
|
+
SELECT (
|
|
61
|
+
SELECT ${ENTRY} FROM audit_log e
|
|
62
|
+
WHERE author = 'vault' AND subject_principal = asked.value AND action IN (SELECT value FROM json_each(${actions})) AND decision = 'allow'
|
|
63
|
+
ORDER BY seq DESC LIMIT 1
|
|
64
|
+
) AS newest
|
|
65
|
+
FROM json_each(${principals}) AS asked
|
|
66
|
+
) WHERE newest IS NOT NULL
|
|
67
|
+
)),
|
|
68
|
+
'policy', json((SELECT ${ENTRY} FROM policy e)),
|
|
69
|
+
'environments', json((
|
|
70
|
+
SELECT json_group_array(json_object(
|
|
71
|
+
'id', env.id, 'projectId', env.project_id, 'slug', env.slug, 'owner', env.owner, 'parentId', env.parent_id,
|
|
72
|
+
'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))
|
|
74
|
+
FROM environments env JOIN projects project ON project.id = env.project_id
|
|
75
|
+
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)
|
|
77
|
+
OR env.id IN (
|
|
78
|
+
SELECT json_extract(place.value, '$.environmentId')
|
|
79
|
+
FROM policy, json_each(policy.metadata, '$.policy.protected') AS place
|
|
80
|
+
-- Of a forged entry's, an element that is no object names nothing: json_extract would fail on a string.
|
|
81
|
+
WHERE json_valid(policy.metadata) AND json_type(policy.metadata, '$.policy.protected') = 'array' AND place.type = 'object'
|
|
82
|
+
)
|
|
83
|
+
)),
|
|
84
|
+
'ended', json((
|
|
85
|
+
SELECT json_group_array(related_seq) FROM audit_log
|
|
86
|
+
WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq IN (SELECT value FROM json_each(${references}))
|
|
87
|
+
)),
|
|
88
|
+
'released', ${released === undefined
|
|
89
|
+
? sql`0`
|
|
90
|
+
: sql`(
|
|
91
|
+
SELECT count(DISTINCT key) FROM (
|
|
92
|
+
-- A read of one key names it; of several at one place, lists them (readsByPlace, vault.ts). An entry, or an
|
|
93
|
+
-- item of its list, that names no key, which only a forged entry is, counts as one key of its own.
|
|
94
|
+
SELECT coalesce(e.secret_id, CASE WHEN one.type = 'object' AND json_type(one.value, '$.secretId') = 'text' THEN json_extract(one.value, '$.secretId') END, 'entry:' || e.seq) AS key
|
|
95
|
+
FROM audit_log e
|
|
96
|
+
LEFT JOIN json_each(CASE WHEN e.secret_id IS NULL AND json_type(e.metadata, '$.secrets') = 'array' THEN e.metadata END, '$.secrets') AS one
|
|
97
|
+
WHERE e.author = 'vault' AND e.actor = ${released.principal} AND e.action = 'secret.read' AND e.decision = 'allow'
|
|
98
|
+
AND e.occurred_at > ${NOW} + ${released.clockOffsetMs} - ${released.windowMs}
|
|
99
|
+
UNION ALL SELECT value FROM json_each(${JSON.stringify(released.keys)})
|
|
100
|
+
)
|
|
101
|
+
)`}
|
|
102
|
+
) AS snapshot`;
|
|
103
|
+
}
|