@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
|
@@ -1,155 +1,15 @@
|
|
|
1
|
-
import { ACCESS_ACTIONS, POLICY_REVISION, checkpointMessage, checkpointVerifier, describeAccessFault, handsBack, policyRevision } from "@coffre/core/vault";
|
|
1
|
+
import { ACCESS_ACTIONS, ACCESS_ACTIONS as ACCESS_ACTIONS$1, POLICY_REVISION, checkpointMessage, checkpointVerifier, describeAccessFault, handsBack, policyRevision } from "@coffre/core/vault";
|
|
2
2
|
import { DEK_BYTES, KEY_CHECK, KEY_CHECK_CONTEXT, KEY_CHECK_VALUE, KekBadClaimError, KekCancelledError, KekUnavailableError, LocalKekProvider } from "@coffre/core/kek";
|
|
3
|
-
import { createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
|
-
import { tablesOf } from "@coffre/db";
|
|
3
|
+
import { createHash, createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { prepared, tablesOf } from "@coffre/db";
|
|
5
5
|
import { DEFAULT_POLICY, EVERYWHERE, EVERY_ENVIRONMENT, FIXED_ROLES, INSTANCE_ROLES, NOTHING, PROJECT_ONLY_PERMISSIONS, ROLES, ROLE_ID, UNWRITTEN_FORK_MS, allows, assignableToEnvironment, definitionProblem, givesService, grantKind, isBuiltinRole, isCredentialScope, isInstanceRole, isRole, isScope, managesService, mayManageAccess, normalDefinition, normalScope, ownsInstance, runsInstance, setsUpServices, unscoped, withinCredential } from "@coffre/core/access";
|
|
6
6
|
import { GENESIS_HASH, deriveLogKey, verifyEntries } from "@coffre/core/audit";
|
|
7
7
|
import { checkContext, ownerKey } from "@coffre/core/envelope";
|
|
8
|
-
import { shownMember } from "@coffre/core/schemas";
|
|
9
|
-
import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUniqueViolation, tombstone, truth } from "@coffre/db/dialect";
|
|
8
|
+
import { TOMBSTONE, shownMember } from "@coffre/core/schemas";
|
|
9
|
+
import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUniqueViolation, isUnreachable, rowsOf, tombstone, truth } from "@coffre/db/dialect";
|
|
10
10
|
import { accessEntriesAbout, everyProjectConversion, insertGrant, liveProjects, newestAccess, readGrants, reportStands, roleColumns, storedRole } from "@coffre/db/grants";
|
|
11
|
-
import {
|
|
12
|
-
import { and, asc,
|
|
13
|
-
//#region src/accounting.ts
|
|
14
|
-
const KEY_ACTIONS = /* @__PURE__ */ new Set([
|
|
15
|
-
"secret.read",
|
|
16
|
-
"key.wrap",
|
|
17
|
-
"key.rewrap"
|
|
18
|
-
]);
|
|
19
|
-
/**
|
|
20
|
-
* Every key operation's intent and its outcomes, entry by entry, oldest
|
|
21
|
-
* first, as the full check reads the vault's entries (`add`); then what is
|
|
22
|
-
* still open (`result`). The chain can hold while a process died between an
|
|
23
|
-
* intent and its outcomes: only a missed deadline is a fault.
|
|
24
|
-
*/
|
|
25
|
-
var KeyAccounting = class {
|
|
26
|
-
#intents = /* @__PURE__ */ new Map();
|
|
27
|
-
#ids = /* @__PURE__ */ new Set();
|
|
28
|
-
#fault = null;
|
|
29
|
-
add(entry) {
|
|
30
|
-
if (this.#fault !== null || entry.author !== "vault") return;
|
|
31
|
-
const broken = (reason) => {
|
|
32
|
-
this.#fault = {
|
|
33
|
-
ok: false,
|
|
34
|
-
failedAtSeq: Number(entry.seq),
|
|
35
|
-
reason
|
|
36
|
-
};
|
|
37
|
-
};
|
|
38
|
-
if (entry.action === "key.intent") {
|
|
39
|
-
const parsed = parseIntent(entry);
|
|
40
|
-
if (typeof parsed === "string") return broken(parsed);
|
|
41
|
-
if (this.#ids.has(parsed.intentId)) return broken("key intent repeats an operation identity");
|
|
42
|
-
this.#ids.add(parsed.intentId);
|
|
43
|
-
if (parsed.keys.length > 0) this.#intents.set(entry.seq, {
|
|
44
|
-
...parsed,
|
|
45
|
-
entry,
|
|
46
|
-
seen: /* @__PURE__ */ new Set()
|
|
47
|
-
});
|
|
48
|
-
} else if (KEY_ACTIONS.has(entry.action) && entry.relatedSeq !== null) {
|
|
49
|
-
const parsed = parseOutcome(entry);
|
|
50
|
-
if (typeof parsed === "string") return broken(parsed);
|
|
51
|
-
const intent = this.#intents.get(parsed.relatedSeq);
|
|
52
|
-
for (const outcome of parsed.items) {
|
|
53
|
-
const key = intent?.keys[outcome.item];
|
|
54
|
-
if (intent === void 0 || key === void 0 || intent.seen.has(outcome.item)) return broken("key outcome does not identify one item of its intent");
|
|
55
|
-
if (parsed.intentId !== intent.intentId || entry.actor !== intent.entry.actor || entry.action !== intent.operation) return broken("key outcome belongs to another operation");
|
|
56
|
-
if (outcome.secretId !== key.secretId || outcome.version !== key.version || outcome.subject !== key.subject) return broken("key outcome does not match its intended secret");
|
|
57
|
-
intent.seen.add(outcome.item);
|
|
58
|
-
}
|
|
59
|
-
if (intent !== void 0 && intent.seen.size === intent.keys.length) this.#intents.delete(intent.entry.seq);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
/** The first fault, or the batches still under way at `at`: live calls can finish after the snapshot. */
|
|
63
|
-
result(at) {
|
|
64
|
-
if (this.#fault !== null) return this.#fault;
|
|
65
|
-
const intents = [...this.#intents.values()];
|
|
66
|
-
const overdue = intents.filter((intent) => intent.expiresAt <= at).sort((a, b) => a.expiresAt - b.expiresAt);
|
|
67
|
-
if (overdue.length === 0) return {
|
|
68
|
-
ok: true,
|
|
69
|
-
pending: intents.length
|
|
70
|
-
};
|
|
71
|
-
const reason = overdue.map((intent) => {
|
|
72
|
-
const missing = intent.keys.length - intent.seen.size;
|
|
73
|
-
return `key intent ${intent.intentId} is overdue: ${missing} of ${intent.keys.length} outcomes missing`;
|
|
74
|
-
}).join("; ");
|
|
75
|
-
return {
|
|
76
|
-
ok: false,
|
|
77
|
-
failedAtSeq: Number(overdue[0].entry.seq),
|
|
78
|
-
reason
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
};
|
|
82
|
-
function parseIntent(entry) {
|
|
83
|
-
const detail = payload(entry.metadata);
|
|
84
|
-
if (detail === null) return "key intent has no valid accounting payload";
|
|
85
|
-
const { intent, operation, expiresAt } = detail;
|
|
86
|
-
if (typeof intent !== "string") return "key intent has no operation identity";
|
|
87
|
-
if (typeof operation !== "string" || !KEY_ACTIONS.has(operation)) return "key intent has an invalid operation";
|
|
88
|
-
if (typeof expiresAt !== "number" || !Number.isSafeInteger(expiresAt) || expiresAt < entry.occurredAt) return "key intent has no valid deadline";
|
|
89
|
-
if (!Array.isArray(detail.keys)) return "key intent has no valid item list";
|
|
90
|
-
const keys = [];
|
|
91
|
-
for (const [index, value] of detail.keys.entries()) {
|
|
92
|
-
const key = record(value);
|
|
93
|
-
if (key === null || key.item !== index) return "key intent items are not numbered in order";
|
|
94
|
-
const { secretId, version, subject } = key;
|
|
95
|
-
if (typeof secretId !== "string" || typeof subject !== "string") return "key intent item has no secret identity";
|
|
96
|
-
if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key intent item has no valid version";
|
|
97
|
-
keys.push({
|
|
98
|
-
item: index,
|
|
99
|
-
secretId,
|
|
100
|
-
version,
|
|
101
|
-
subject
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
return {
|
|
105
|
-
intentId: intent,
|
|
106
|
-
operation,
|
|
107
|
-
expiresAt,
|
|
108
|
-
keys
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
function parseOutcome(entry) {
|
|
112
|
-
const detail = payload(entry.metadata);
|
|
113
|
-
if (detail === null) return "key outcome has no valid accounting payload";
|
|
114
|
-
const { intent } = detail;
|
|
115
|
-
if (typeof intent !== "string" || entry.relatedSeq === null) return "key outcome has no intent identity";
|
|
116
|
-
const many = detail.secrets;
|
|
117
|
-
if (many !== void 0 && (!Array.isArray(many) || many.length === 0)) return "key outcome has no valid item list";
|
|
118
|
-
const listed = many === void 0 ? [{
|
|
119
|
-
...detail,
|
|
120
|
-
secretId: entry.secretId ?? detail.secretId
|
|
121
|
-
}] : many.map(record);
|
|
122
|
-
const items = [];
|
|
123
|
-
for (const one of listed) {
|
|
124
|
-
if (one === null) return "key outcome has no valid item list";
|
|
125
|
-
const { item, secretId, version, subject } = one;
|
|
126
|
-
if (typeof item !== "number" || !Number.isSafeInteger(item) || item < 0) return "key outcome has no valid item number";
|
|
127
|
-
if (typeof secretId !== "string" || typeof subject !== "string") return "key outcome has no secret identity";
|
|
128
|
-
if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key outcome has no valid version";
|
|
129
|
-
items.push({
|
|
130
|
-
item,
|
|
131
|
-
secretId,
|
|
132
|
-
version,
|
|
133
|
-
subject
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
return {
|
|
137
|
-
intentId: intent,
|
|
138
|
-
relatedSeq: entry.relatedSeq,
|
|
139
|
-
items
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
function payload(text) {
|
|
143
|
-
try {
|
|
144
|
-
return record(JSON.parse(text));
|
|
145
|
-
} catch {
|
|
146
|
-
return null;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
function record(value) {
|
|
150
|
-
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
151
|
-
}
|
|
152
|
-
//#endregion
|
|
11
|
+
import { LogHeadMismatch, appendEntries, entryColumns, lockLogHead, missingEntry, newestAuthentic, newestVaultEntries, withinBytes } from "@coffre/db/log";
|
|
12
|
+
import { and, asc, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm";
|
|
153
13
|
//#region src/checkpoint.ts
|
|
154
14
|
const PKCS8_ED25519 = [
|
|
155
15
|
48,
|
|
@@ -193,10 +53,10 @@ async function boundLockWaits(tx, ms) {
|
|
|
193
53
|
}
|
|
194
54
|
/** The database's clock, now. */
|
|
195
55
|
async function now(db) {
|
|
196
|
-
const {
|
|
197
|
-
const [{ at }] = await db.select({ at: clockMillis(db) }).from(auditChainHead);
|
|
56
|
+
const [{ at }] = await clock(db);
|
|
198
57
|
return at;
|
|
199
58
|
}
|
|
59
|
+
const clock = prepared((db) => db.select({ at: clockMillis(db) }).from(tablesOf(db).auditChainHead));
|
|
200
60
|
function memberColumns(db) {
|
|
201
61
|
const { vaultMembers } = tablesOf(db);
|
|
202
62
|
return {
|
|
@@ -215,7 +75,7 @@ function memberColumns(db) {
|
|
|
215
75
|
};
|
|
216
76
|
}
|
|
217
77
|
/** A member as their row says (`storedRole`). */
|
|
218
|
-
function memberOf({ owner, role, scope, ...row }) {
|
|
78
|
+
function memberOf$1({ owner, role, scope, ...row }) {
|
|
219
79
|
return {
|
|
220
80
|
...row,
|
|
221
81
|
...storedRole({
|
|
@@ -234,13 +94,16 @@ function storedChange(change) {
|
|
|
234
94
|
};
|
|
235
95
|
}
|
|
236
96
|
async function member(db, principal) {
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
return row === void 0 ? void 0 : memberOf(row);
|
|
97
|
+
const [row] = await memberRow(db, { principal });
|
|
98
|
+
return row === void 0 ? void 0 : memberOf$1(row);
|
|
240
99
|
}
|
|
100
|
+
const memberRow = prepared((db) => {
|
|
101
|
+
const { vaultMembers } = tablesOf(db);
|
|
102
|
+
return db.select(memberColumns(db)).from(vaultMembers).where(eq(vaultMembers.principal, sql.placeholder("principal")));
|
|
103
|
+
});
|
|
241
104
|
async function allMembers(db) {
|
|
242
105
|
const { vaultMembers } = tablesOf(db);
|
|
243
|
-
return (await db.select(memberColumns(db)).from(vaultMembers).orderBy(asc(vaultMembers.principal))).map(memberOf);
|
|
106
|
+
return (await db.select(memberColumns(db)).from(vaultMembers).orderBy(asc(vaultMembers.principal))).map(memberOf$1);
|
|
244
107
|
}
|
|
245
108
|
/**
|
|
246
109
|
* The rows that say their member is an active Owner of the whole instance
|
|
@@ -250,7 +113,7 @@ async function allMembers(db) {
|
|
|
250
113
|
*/
|
|
251
114
|
async function instanceOwners(db) {
|
|
252
115
|
const { vaultMembers } = tablesOf(db);
|
|
253
|
-
return (await db.select(memberColumns(db)).from(vaultMembers).where(and(eq(vaultMembers.status, "active"), eq(vaultMembers.role, "owner"))).orderBy(asc(vaultMembers.principal))).map(memberOf).filter(ownsInstance);
|
|
116
|
+
return (await db.select(memberColumns(db)).from(vaultMembers).where(and(eq(vaultMembers.status, "active"), eq(vaultMembers.role, "owner"))).orderBy(asc(vaultMembers.principal))).map(memberOf$1).filter(ownsInstance);
|
|
254
117
|
}
|
|
255
118
|
/**
|
|
256
119
|
* Lock these members' rows for the rest of the transaction, in one order
|
|
@@ -261,7 +124,7 @@ async function instanceOwners(db) {
|
|
|
261
124
|
async function lockMembers(tx, principals) {
|
|
262
125
|
const { vaultMembers } = tablesOf(tx);
|
|
263
126
|
const rows = await forUpdate(tx, tx.select(memberColumns(tx)).from(vaultMembers).where(inArray(vaultMembers.principal, [...new Set(principals)])).orderBy(asc(vaultMembers.principal)), "no key update");
|
|
264
|
-
return new Map(rows.map((row) => [row.principal, memberOf(row)]));
|
|
127
|
+
return new Map(rows.map((row) => [row.principal, memberOf$1(row)]));
|
|
265
128
|
}
|
|
266
129
|
/**
|
|
267
130
|
* Add `row`. A principal has one row: when two decisions admit the same
|
|
@@ -358,19 +221,6 @@ async function everyProjectHolders(db) {
|
|
|
358
221
|
const { vaultGrants } = tablesOf(db);
|
|
359
222
|
return (await db.selectDistinct({ principal: vaultGrants.principal }).from(vaultGrants).where(and(isNull(vaultGrants.projectId), isNull(vaultGrants.environmentId))).orderBy(asc(vaultGrants.principal))).map((row) => row.principal);
|
|
360
223
|
}
|
|
361
|
-
/** The action of an entry that releases a key, which the bulk limit counts. */
|
|
362
|
-
const RELEASE = "secret.read";
|
|
363
|
-
/**
|
|
364
|
-
* How many keys the vault has released to `principal` since `after`: one
|
|
365
|
-
* for an entry about one secret, and as many as it lists for one about
|
|
366
|
-
* several (`secrets`), a read logged once per place.
|
|
367
|
-
*/
|
|
368
|
-
async function releasesSince(db, principal, after) {
|
|
369
|
-
const { auditLog } = tablesOf(db);
|
|
370
|
-
const listed = engineOf(db) === "postgres" ? sql`jsonb_array_length((${auditLog.metadata})::jsonb -> 'secrets')` : sql`json_array_length(${auditLog.metadata}, '$.secrets')`;
|
|
371
|
-
const [{ n }] = await db.select({ n: sql`SUM(COALESCE(${listed}, 1))` }).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.actor, principal), eq(auditLog.action, RELEASE), eq(auditLog.decision, "allow"), gt(auditLog.occurredAt, after)));
|
|
372
|
-
return Number(n ?? 0);
|
|
373
|
-
}
|
|
374
224
|
/** Rows of the log as the chain's codec reads them: `author` and `decision` are checked by the table. */
|
|
375
225
|
function stored(rows) {
|
|
376
226
|
return rows;
|
|
@@ -405,16 +255,6 @@ async function hashesAt(db, seqs) {
|
|
|
405
255
|
}
|
|
406
256
|
/** How many entries' hashes one query asks for: a checkpoint every five minutes is tens of thousands of a year. */
|
|
407
257
|
const HASHES_AT_ONCE = 5e3;
|
|
408
|
-
/**
|
|
409
|
-
* Whether every entry from the first through `seq` is there. Seqs are the
|
|
410
|
-
* primary key, so the count is `seq + 1` exactly when none is missing: one
|
|
411
|
-
* pass over the key's index, never the entries.
|
|
412
|
-
*/
|
|
413
|
-
async function complete(db, seq) {
|
|
414
|
-
const { auditLog } = tablesOf(db);
|
|
415
|
-
const [{ n }] = await db.select({ n: count() }).from(auditLog).where(and(gte(auditLog.seq, 0n), lte(auditLog.seq, seq)));
|
|
416
|
-
return BigInt(n) === seq + 1n;
|
|
417
|
-
}
|
|
418
258
|
/** The entry at `seq`'s hash, or undefined when there is none. */
|
|
419
259
|
async function hashAt(db, seq) {
|
|
420
260
|
const { auditLog } = tablesOf(db);
|
|
@@ -529,10 +369,55 @@ async function latestVaultEntry(db, actions) {
|
|
|
529
369
|
const [row] = await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), inArray(auditLog.action, [...actions]), eq(auditLog.decision, "allow"))).orderBy(desc(auditLog.seq)).limit(1);
|
|
530
370
|
return row === void 0 ? void 0 : stored([row])[0];
|
|
531
371
|
}
|
|
372
|
+
/**
|
|
373
|
+
* The vault's newest checkpoint that keeps a tally, allowed, or undefined:
|
|
374
|
+
* one written since 0.10.1, which names the last entry of the prefix it
|
|
375
|
+
* signs as related (`related_seq`); those before name none.
|
|
376
|
+
*/
|
|
377
|
+
async function latestTallied(db) {
|
|
378
|
+
const { auditLog } = tablesOf(db);
|
|
379
|
+
const [row] = await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.action, "audit.checkpoint"), eq(auditLog.decision, "allow"), isNotNull(auditLog.relatedSeq))).orderBy(desc(auditLog.seq)).limit(1);
|
|
380
|
+
return row === void 0 ? void 0 : stored([row])[0];
|
|
381
|
+
}
|
|
382
|
+
/** The vault's newest `audit.checkpoint` entry, signed or refused, or undefined. */
|
|
383
|
+
async function newestCheckpointEntry(db) {
|
|
384
|
+
const { auditLog } = tablesOf(db);
|
|
385
|
+
const [row] = await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.action, "audit.checkpoint"))).orderBy(desc(auditLog.seq)).limit(1);
|
|
386
|
+
return row === void 0 ? void 0 : stored([row])[0];
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* The vault's checkpoints that keep a tally after entry `after` and through
|
|
390
|
+
* `through`, oldest first, whole: as many as `BATCH_BYTES` of what they say
|
|
391
|
+
* holds, but always one. A reader asks again after the last.
|
|
392
|
+
*/
|
|
393
|
+
async function talliedWithin(db, after, through, limit) {
|
|
394
|
+
const { auditLog } = tablesOf(db);
|
|
395
|
+
const tallied = and(eq(auditLog.author, "vault"), eq(auditLog.action, "audit.checkpoint"), eq(auditLog.decision, "allow"), isNotNull(auditLog.relatedSeq), gt(auditLog.seq, after), lte(auditLog.seq, through));
|
|
396
|
+
const batch = await withinBytes(db, tallied, limit);
|
|
397
|
+
if (batch === null) return [];
|
|
398
|
+
return stored(await db.select(entryColumns(db)).from(auditLog).where(and(tallied, gte(auditLog.seq, batch.from), lte(auditLog.seq, batch.to))).orderBy(asc(auditLog.seq)));
|
|
399
|
+
}
|
|
400
|
+
/** The vault's newest refusal of `action` with `code` after entry `after`, or undefined: one range of the (author, action, seq) index. */
|
|
401
|
+
async function newestRefusal(db, action, code, after) {
|
|
402
|
+
const { auditLog } = tablesOf(db);
|
|
403
|
+
const [row] = await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.action, action), gt(auditLog.seq, after), eq(auditLog.decision, "deny"), eq(auditLog.code, code))).orderBy(desc(auditLog.seq)).limit(1);
|
|
404
|
+
return row === void 0 ? void 0 : stored([row])[0];
|
|
405
|
+
}
|
|
406
|
+
/** Up to `limit` of the vault's checkpoints that keep a tally after entry `after`, oldest first: each one's entry, and the last entry it signs. */
|
|
407
|
+
async function talliedAfter(db, after, limit) {
|
|
408
|
+
const { auditLog } = tablesOf(db);
|
|
409
|
+
return (await db.select({
|
|
410
|
+
seq: auditLog.seq,
|
|
411
|
+
signs: auditLog.relatedSeq
|
|
412
|
+
}).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.action, "audit.checkpoint"), eq(auditLog.decision, "allow"), isNotNull(auditLog.relatedSeq), gt(auditLog.seq, after))).orderBy(asc(auditLog.seq)).limit(limit)).map(({ seq, signs }) => ({
|
|
413
|
+
seq,
|
|
414
|
+
signs
|
|
415
|
+
}));
|
|
416
|
+
}
|
|
532
417
|
/** The vault's newest allowed access entry about each member who has one. */
|
|
533
418
|
async function newestAccessEntries(db) {
|
|
534
419
|
const { auditLog } = tablesOf(db);
|
|
535
|
-
const newest = db.select({ seq: sql`max(${auditLog.seq})` }).from(auditLog).where(and(eq(auditLog.author, "vault"), inArray(auditLog.action, [...ACCESS_ACTIONS]), eq(auditLog.decision, "allow"))).groupBy(auditLog.subjectPrincipal);
|
|
420
|
+
const newest = db.select({ seq: sql`max(${auditLog.seq})` }).from(auditLog).where(and(eq(auditLog.author, "vault"), inArray(auditLog.action, [...ACCESS_ACTIONS$1]), eq(auditLog.decision, "allow"))).groupBy(auditLog.subjectPrincipal);
|
|
536
421
|
const rows = stored(await db.select(entryColumns(db)).from(auditLog).where(inArray(auditLog.seq, newest)));
|
|
537
422
|
return new Map(rows.flatMap((row) => row.subjectPrincipal === null ? [] : [[row.subjectPrincipal, row]]));
|
|
538
423
|
}
|
|
@@ -621,86 +506,158 @@ async function versions(db, ids) {
|
|
|
621
506
|
function vaultLogKey(signingKey) {
|
|
622
507
|
return deriveLogKey("vault", signingKey);
|
|
623
508
|
}
|
|
624
|
-
/** Before the first entry
|
|
625
|
-
const
|
|
626
|
-
|
|
627
|
-
hash: GENESIS_HASH
|
|
628
|
-
vaultEntries: 0,
|
|
629
|
-
vaultKeys: []
|
|
509
|
+
/** Before the first entry. */
|
|
510
|
+
const START = {
|
|
511
|
+
seq: -1n,
|
|
512
|
+
hash: GENESIS_HASH
|
|
630
513
|
};
|
|
631
|
-
/** The further of two
|
|
514
|
+
/** The further of two positions, when two calls walked at once. */
|
|
632
515
|
function further(a, b) {
|
|
633
|
-
return b.
|
|
516
|
+
return b.seq > a.seq ? b : a;
|
|
634
517
|
}
|
|
635
518
|
/** 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. */
|
|
636
519
|
const VERIFY_BATCH = 5e3;
|
|
637
520
|
/**
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
* of anything before it, chained again to hide, changes its hash;
|
|
645
|
-
* - every entry after the anchor, or from the first.
|
|
521
|
+
* Walk the chain on from `from`, a batch in memory at a time, through `to`
|
|
522
|
+
* or the log's last entry, and no more than `limit` entries: every link and
|
|
523
|
+
* hash recomputed, each of the vault's entries by its MAC, its keys moving
|
|
524
|
+
* only forward, and each fed to `walk`. Where it got to, and whether that
|
|
525
|
+
* is as far as it was to go: a walk cut short by `limit` goes on from there
|
|
526
|
+
* another time.
|
|
646
527
|
*
|
|
647
|
-
*
|
|
648
|
-
*
|
|
649
|
-
* not on the page: a full check, which starts from `UNVERIFIED`, finds that,
|
|
650
|
-
* as does the first view after a start, which has no anchor. `onBatch` sees
|
|
651
|
-
* each batch once it has verified, for checks that read the same entries.
|
|
528
|
+
* It takes `from` as given: whether the log still holds it is the caller's
|
|
529
|
+
* to check, with the other heads it must hold, in one read.
|
|
652
530
|
*/
|
|
653
|
-
async function
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
531
|
+
async function rehash(db, logKeys, from, walk, { to, limit = Infinity }) {
|
|
532
|
+
let at = from;
|
|
533
|
+
for (let left = limit;;) {
|
|
534
|
+
if (to !== void 0 && at.seq >= to) return {
|
|
535
|
+
ok: true,
|
|
536
|
+
at,
|
|
537
|
+
done: true
|
|
538
|
+
};
|
|
539
|
+
if (left <= 0) return {
|
|
540
|
+
ok: true,
|
|
541
|
+
at,
|
|
542
|
+
done: !(await entriesFrom(db, at.seq + 1n, 1)).some((entry) => to === void 0 || entry.seq <= to)
|
|
543
|
+
};
|
|
544
|
+
let batch = await entriesFrom(db, at.seq + 1n, Math.min(VERIFY_BATCH, left));
|
|
545
|
+
if (to !== void 0) batch = batch.filter((entry) => entry.seq <= to);
|
|
546
|
+
if (batch.length === 0) return {
|
|
547
|
+
ok: true,
|
|
548
|
+
at,
|
|
549
|
+
done: to === void 0
|
|
550
|
+
};
|
|
551
|
+
const result = verifyEntries(batch, {
|
|
552
|
+
keys: logKeys,
|
|
553
|
+
chainOnly: ["app"],
|
|
554
|
+
startSeq: at.seq + 1n,
|
|
555
|
+
startPrevHash: at.hash
|
|
671
556
|
});
|
|
672
|
-
if (!result.ok) return
|
|
557
|
+
if (!result.ok) return {
|
|
558
|
+
ok: false,
|
|
559
|
+
failedAtSeq: Number(result.failedAtSeq),
|
|
560
|
+
reason: withCause(result.reason)
|
|
561
|
+
};
|
|
562
|
+
for (const entry of batch) {
|
|
563
|
+
const moved = entry.author === "vault" ? walk.add(entry, logKeys[0].keyId) : null;
|
|
564
|
+
if (moved !== null) return {
|
|
565
|
+
ok: false,
|
|
566
|
+
failedAtSeq: Number(moved.failedAtSeq),
|
|
567
|
+
reason: moved.reason
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
at = {
|
|
571
|
+
seq: result.nextSeq - 1n,
|
|
572
|
+
hash: result.head
|
|
573
|
+
};
|
|
574
|
+
left -= batch.length;
|
|
673
575
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* The chain again from `from`, through `to` and no more than `limit`
|
|
579
|
+
* entries: each hash recomputed from its entry and each link checked, and
|
|
580
|
+
* nothing else, so a stretch costs a hash an entry. What a checkpoint does
|
|
581
|
+
* in the background to the prefixes signed before (`#scrub` in vault.ts):
|
|
582
|
+
* each entry's MAC was checked when it was signed over, and its hash pins
|
|
583
|
+
* it since, so all that can change behind a signed prefix and keep the
|
|
584
|
+
* chain is an entry edited in place, its stored hash left, which its hash
|
|
585
|
+
* finds.
|
|
586
|
+
*/
|
|
587
|
+
async function relink(db, from, to, limit) {
|
|
588
|
+
let at = from;
|
|
589
|
+
while (at.seq < to && limit > 0) {
|
|
590
|
+
const batch = (await entriesFrom(db, at.seq + 1n, Math.min(VERIFY_BATCH, limit))).filter((entry) => entry.seq <= to);
|
|
678
591
|
if (batch.length === 0) break;
|
|
679
592
|
const result = verifyEntries(batch, {
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
593
|
+
keys: [],
|
|
594
|
+
chainOnly: ["app", "vault"],
|
|
595
|
+
startSeq: at.seq + 1n,
|
|
596
|
+
startPrevHash: at.hash
|
|
683
597
|
});
|
|
684
|
-
if (!result.ok) return
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
vaultKeys: moved.keys
|
|
598
|
+
if (!result.ok) return {
|
|
599
|
+
ok: false,
|
|
600
|
+
failedAtSeq: Number(result.failedAtSeq),
|
|
601
|
+
reason: result.reason
|
|
602
|
+
};
|
|
603
|
+
at = {
|
|
604
|
+
seq: result.nextSeq - 1n,
|
|
605
|
+
hash: result.head
|
|
693
606
|
};
|
|
607
|
+
limit -= batch.length;
|
|
694
608
|
}
|
|
695
609
|
return {
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
entries: verified.vaultEntries
|
|
699
|
-
},
|
|
700
|
-
anchor: verified
|
|
610
|
+
ok: true,
|
|
611
|
+
at
|
|
701
612
|
};
|
|
702
613
|
}
|
|
703
614
|
/**
|
|
615
|
+
* The vault's own entries after `after`, through `to` or its last, no more
|
|
616
|
+
* than `limit` of them, oldest first: each against its own hash, and by its
|
|
617
|
+
* MAC, over the link it names, its keys moving only forward, each fed to
|
|
618
|
+
* `walk`. The links between entries are the caller's to recompute, as the
|
|
619
|
+
* app does over the same stretch. The last it read, and whether that is as
|
|
620
|
+
* far as it was to go.
|
|
621
|
+
*/
|
|
622
|
+
async function ownEntries(db, logKeys, after, walk, { to, limit = Infinity }) {
|
|
623
|
+
for (let left = limit;;) {
|
|
624
|
+
if (left <= 0) return {
|
|
625
|
+
ok: true,
|
|
626
|
+
at: after,
|
|
627
|
+
done: !(await vaultEntriesAfter(db, after, 1)).some((entry) => to === void 0 || entry.seq <= to)
|
|
628
|
+
};
|
|
629
|
+
let batch = await vaultEntriesAfter(db, after, Math.min(VERIFY_BATCH, left));
|
|
630
|
+
if (to !== void 0) batch = batch.filter((entry) => entry.seq <= to);
|
|
631
|
+
if (batch.length === 0) return {
|
|
632
|
+
ok: true,
|
|
633
|
+
at: after,
|
|
634
|
+
done: true
|
|
635
|
+
};
|
|
636
|
+
for (const entry of batch) {
|
|
637
|
+
const result = verifyEntries([entry], {
|
|
638
|
+
startSeq: entry.seq,
|
|
639
|
+
startPrevHash: entry.prevHash,
|
|
640
|
+
keys: logKeys
|
|
641
|
+
});
|
|
642
|
+
if (!result.ok) return {
|
|
643
|
+
ok: false,
|
|
644
|
+
failedAtSeq: Number(result.failedAtSeq),
|
|
645
|
+
reason: withCause(result.reason)
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
for (const entry of batch) {
|
|
649
|
+
const moved = walk.add(entry, logKeys[0].keyId);
|
|
650
|
+
if (moved !== null) return {
|
|
651
|
+
ok: false,
|
|
652
|
+
failedAtSeq: Number(moved.failedAtSeq),
|
|
653
|
+
reason: moved.reason
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
after = batch[batch.length - 1].seq;
|
|
657
|
+
left -= batch.length;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
704
661
|
* The vault's keys only move forward. Once its entries move from one key to
|
|
705
662
|
* the next, the one before writes no more; once they reach `current`, the
|
|
706
663
|
* key it writes with now, no other key writes again. So a key it replaced,
|
|
@@ -979,43 +936,662 @@ function grantSet(grants) {
|
|
|
979
936
|
grant.grantedBy
|
|
980
937
|
]).map((tuple) => JSON.stringify(tuple)).sort();
|
|
981
938
|
}
|
|
982
|
-
/** Whether two reads of a member's grants are the same set, as the MAC sees them. */
|
|
983
|
-
function sameGrants(a, b) {
|
|
984
|
-
const [x, y] = [grantSet(a), grantSet(b)];
|
|
985
|
-
return x.length === y.length && x.every((tuple, i) => tuple === y[i]);
|
|
939
|
+
/** Whether two reads of a member's grants are the same set, as the MAC sees them. */
|
|
940
|
+
function sameGrants(a, b) {
|
|
941
|
+
const [x, y] = [grantSet(a), grantSet(b)];
|
|
942
|
+
return x.length === y.length && x.every((tuple, i) => tuple === y[i]);
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* A member's instance role and its scope, as the MAC covers them: as 0.4
|
|
946
|
+
* did, whether they are an owner, for a Member or an Admin everywhere, so
|
|
947
|
+
* that every row 0.4 sealed holds as it is; otherwise the role and the
|
|
948
|
+
* scope, which no row of 0.4 can match.
|
|
949
|
+
*/
|
|
950
|
+
function standing(member) {
|
|
951
|
+
const scope = normalScope(member.scope);
|
|
952
|
+
if (unscoped(scope) && (member.role === "member" || member.role === "admin")) return member.role === "admin";
|
|
953
|
+
return [member.role, scope];
|
|
954
|
+
}
|
|
955
|
+
function memberMac(key, member, grants) {
|
|
956
|
+
const held = grantSet(grants);
|
|
957
|
+
const tuple = [
|
|
958
|
+
"coffre.vault.member.v1",
|
|
959
|
+
member.principal,
|
|
960
|
+
member.status,
|
|
961
|
+
standing(member),
|
|
962
|
+
member.generation,
|
|
963
|
+
member.accessSeq.toString(),
|
|
964
|
+
member.createdAt,
|
|
965
|
+
member.createdBy,
|
|
966
|
+
member.statusChangedAt,
|
|
967
|
+
member.statusChangedBy,
|
|
968
|
+
held
|
|
969
|
+
];
|
|
970
|
+
return createHmac("sha256", key).update(JSON.stringify(tuple)).digest();
|
|
971
|
+
}
|
|
972
|
+
/** Whether `member`'s MAC is the vault's, over this row and these grants. */
|
|
973
|
+
function sealed(key, member, grants) {
|
|
974
|
+
const expected = memberMac(key, member, grants);
|
|
975
|
+
return member.mac.length === expected.length && timingSafeEqual(member.mac, expected);
|
|
976
|
+
}
|
|
977
|
+
//#endregion
|
|
978
|
+
//#region src/snapshot.postgres.ts
|
|
979
|
+
/** An entry of `audit_log` aliased `e`, as JSON (`RawEntry`): whole numbers past 2^53 as text, bytes as hex. */
|
|
980
|
+
const ENTRY$1 = sql.raw(`json_build_object(
|
|
981
|
+
'seq', e.seq::text, 'author', e.author, 'keyId', e.key_id, 'occurredAt', e.occurred_at, 'actor', e.actor, 'action', e.action,
|
|
982
|
+
'decision', e.decision, 'code', e.code, 'subjectPrincipal', e.subject_principal, 'projectId', e.project_id,
|
|
983
|
+
'environmentId', e.environment_id, 'secretId', e.secret_id, 'secretVersionId', e.secret_version_id,
|
|
984
|
+
'operationId', e.operation_id, 'requestId', e.request_id, 'sourceIp', e.source_ip, 'relatedSeq', e.related_seq::text,
|
|
985
|
+
'metadata', e.metadata, 'prevHash', encode(e.prev_hash, 'hex'), 'mac', encode(e.mac, 'hex'), 'hash', encode(e.hash, 'hex'))`);
|
|
986
|
+
const NOW$1 = sql.raw("(extract(epoch from clock_timestamp()) * 1000)::bigint");
|
|
987
|
+
/** `snapshot`'s statement on Postgres: one row, one JSON column. Each part reads one index range. */
|
|
988
|
+
function snapshotQuery$1(asked) {
|
|
989
|
+
const principals = sql`${sql.param([...asked.principals])}::text[]`;
|
|
990
|
+
const environments = sql`${sql.param([...asked.environments ?? []])}::uuid[]`;
|
|
991
|
+
const references = sql`${sql.param((asked.references ?? []).map(String))}::bigint[]`;
|
|
992
|
+
const actions = sql`${sql.param([...ACCESS_ACTIONS])}::text[]`;
|
|
993
|
+
const tomb = `%${TOMBSTONE}%`;
|
|
994
|
+
const released = asked.released;
|
|
995
|
+
const head = asked.head === true ? sql`, head AS (SELECT next_seq, head_hash FROM audit_chain_head WHERE only_row FOR UPDATE SKIP LOCKED)` : sql``;
|
|
996
|
+
return sql`
|
|
997
|
+
WITH policy AS (
|
|
998
|
+
SELECT * FROM audit_log WHERE author = 'vault' AND action = 'policy.change' AND decision = 'allow' ORDER BY seq DESC LIMIT 1
|
|
999
|
+
)${head}
|
|
1000
|
+
SELECT json_build_object(
|
|
1001
|
+
'at', ${NOW$1},
|
|
1002
|
+
'head', ${asked.head === true ? sql`(SELECT json_build_object('nextSeq', next_seq::text, 'hash', encode(head_hash, 'hex')) FROM head)` : sql`NULL`},
|
|
1003
|
+
'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`},
|
|
1004
|
+
'rotation', ${asked.head === true ? sql`(SELECT ${ENTRY$1} FROM audit_log e WHERE author = 'vault' AND action = 'key.rotate' AND decision = 'allow' ORDER BY seq DESC LIMIT 1)` : sql`NULL`},
|
|
1005
|
+
'members', ${asked.members === true ? sql`(SELECT coalesce(json_agg(json_build_object(
|
|
1006
|
+
'principal', principal, 'status', status, 'owner', owner, 'role', role, 'scope', scope, 'generation', generation,
|
|
1007
|
+
'createdAt', created_at, 'createdBy', created_by, 'statusChangedAt', status_changed_at, 'statusChangedBy', status_changed_by,
|
|
1008
|
+
'accessSeq', access_seq::text, 'mac', encode(mac, 'hex'))), '[]') FROM vault_members WHERE principal = ANY(${principals}))` : sql`'[]'::json`},
|
|
1009
|
+
'grants', (
|
|
1010
|
+
SELECT coalesce(json_agg(json_build_object(
|
|
1011
|
+
'principal', g.principal, 'projectId', coalesce(g.project_id, env.project_id), 'environmentId', g.environment_id,
|
|
1012
|
+
'environmentSlug', g.environment_slug, 'role', g.role, 'expiresAt', g.expires_at, 'grantedAt', g.granted_at, 'grantedBy', g.granted_by)), '[]')
|
|
1013
|
+
FROM vault_grants g LEFT JOIN environments env ON env.id = g.environment_id
|
|
1014
|
+
WHERE g.principal = ANY(${principals})
|
|
1015
|
+
),
|
|
1016
|
+
'access', (
|
|
1017
|
+
SELECT coalesce(json_agg(${ENTRY$1}), '[]')
|
|
1018
|
+
FROM unnest(${principals}) AS asked (principal)
|
|
1019
|
+
CROSS JOIN LATERAL (
|
|
1020
|
+
SELECT * FROM audit_log
|
|
1021
|
+
WHERE author = 'vault' AND subject_principal = asked.principal AND action = ANY(${actions}) AND decision = 'allow'
|
|
1022
|
+
ORDER BY seq DESC LIMIT 1
|
|
1023
|
+
) e
|
|
1024
|
+
),
|
|
1025
|
+
'policy', (SELECT ${ENTRY$1} FROM policy e),
|
|
1026
|
+
'environments', (
|
|
1027
|
+
SELECT coalesce(json_agg(json_build_object(
|
|
1028
|
+
'id', env.id, 'projectId', env.project_id, 'slug', env.slug, 'owner', env.owner, 'parentId', env.parent_id,
|
|
1029
|
+
'deleted', env.slug LIKE ${tomb} OR project.slug LIKE ${tomb},
|
|
1030
|
+
'archived', env.archived_at IS NOT NULL OR project.archived_at IS NOT NULL)), '[]')
|
|
1031
|
+
FROM environments env JOIN projects project ON project.id = env.project_id
|
|
1032
|
+
-- One list of ids, each found by the primary key.
|
|
1033
|
+
WHERE env.id = ANY(ARRAY(
|
|
1034
|
+
SELECT unnest(${environments})
|
|
1035
|
+
UNION SELECT parent_id FROM environments WHERE id = ANY(${environments}) AND owner IS NOT NULL
|
|
1036
|
+
-- What the newest policy protects, by the ids its JSON names; one that is no UUID names nothing.
|
|
1037
|
+
UNION SELECT (place #>> '{}')::uuid FROM policy, jsonb_path_query(policy.metadata::jsonb, '$.policy.protected[*].environmentId') AS place
|
|
1038
|
+
WHERE (place #>> '{}') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
|
1039
|
+
))
|
|
1040
|
+
),
|
|
1041
|
+
'ended', (
|
|
1042
|
+
SELECT coalesce(json_agg(related_seq::text), '[]') FROM audit_log
|
|
1043
|
+
WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq = ANY(${references})
|
|
1044
|
+
),
|
|
1045
|
+
'released', ${released === void 0 ? sql`0` : sql`(
|
|
1046
|
+
SELECT count(DISTINCT key) FROM (
|
|
1047
|
+
-- A read of one key names it; of several at one place, lists them (readsByPlace, vault.ts). An entry, or an
|
|
1048
|
+
-- item of its list, that names no key, which only a forged entry is, counts as one key of its own.
|
|
1049
|
+
SELECT coalesce(e.secret_id::text, CASE jsonb_typeof(one -> 'secretId') WHEN 'string' THEN one ->> 'secretId' END, 'entry:' || e.seq) FROM audit_log e
|
|
1050
|
+
-- Each item of a list, never what lax paths would make of an object; silent, as strict paths fail on what is missing.
|
|
1051
|
+
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
|
|
1052
|
+
WHERE e.author = 'vault' AND e.actor = ${released.principal} AND e.action = 'secret.read' AND e.decision = 'allow'
|
|
1053
|
+
AND e.occurred_at > ${NOW$1} + ${released.clockOffsetMs} - ${released.windowMs}
|
|
1054
|
+
UNION ALL SELECT unnest(${sql.param([...released.keys])}::text[])
|
|
1055
|
+
) AS released (key)
|
|
1056
|
+
)`}
|
|
1057
|
+
) AS snapshot`;
|
|
1058
|
+
}
|
|
1059
|
+
//#endregion
|
|
1060
|
+
//#region src/snapshot.sqlite.ts
|
|
1061
|
+
/**
|
|
1062
|
+
* An entry of `audit_log` aliased `e`, as JSON (`RawEntry`), bytes as hex.
|
|
1063
|
+
* A JSON value loses its type leaving a subquery, so whoever takes one
|
|
1064
|
+
* from a subquery wraps it in `json(…)`.
|
|
1065
|
+
*/
|
|
1066
|
+
const ENTRY = sql.raw(`json_object(
|
|
1067
|
+
'seq', e.seq, 'author', e.author, 'keyId', e.key_id, 'occurredAt', e.occurred_at, 'actor', e.actor, 'action', e.action,
|
|
1068
|
+
'decision', e.decision, 'code', e.code, 'subjectPrincipal', e.subject_principal, 'projectId', e.project_id,
|
|
1069
|
+
'environmentId', e.environment_id, 'secretId', e.secret_id, 'secretVersionId', e.secret_version_id,
|
|
1070
|
+
'operationId', e.operation_id, 'requestId', e.request_id, 'sourceIp', e.source_ip, 'relatedSeq', e.related_seq,
|
|
1071
|
+
'metadata', e.metadata, 'prevHash', hex(e.prev_hash), 'mac', hex(e.mac), 'hash', hex(e.hash))`);
|
|
1072
|
+
const NOW = sql.raw(`CAST(unixepoch('subsec') * 1000 AS INTEGER)`);
|
|
1073
|
+
/**
|
|
1074
|
+
* `snapshot`'s statement on SQLite, the same as on Postgres. Nothing is
|
|
1075
|
+
* locked: one transaction runs at a time (connect.ts), so the head is the
|
|
1076
|
+
* last entry's.
|
|
1077
|
+
*/
|
|
1078
|
+
function snapshotQuery(asked) {
|
|
1079
|
+
const principals = JSON.stringify(asked.principals);
|
|
1080
|
+
const environments = JSON.stringify(asked.environments ?? []);
|
|
1081
|
+
const references = JSON.stringify((asked.references ?? []).map(Number));
|
|
1082
|
+
const actions = JSON.stringify(ACCESS_ACTIONS);
|
|
1083
|
+
const tomb = `%${TOMBSTONE}%`;
|
|
1084
|
+
const released = asked.released;
|
|
1085
|
+
const head = asked.head === true;
|
|
1086
|
+
return sql`
|
|
1087
|
+
WITH policy AS (
|
|
1088
|
+
SELECT * FROM audit_log WHERE author = 'vault' AND action = 'policy.change' AND decision = 'allow' ORDER BY seq DESC LIMIT 1
|
|
1089
|
+
)
|
|
1090
|
+
SELECT json_object(
|
|
1091
|
+
'at', ${NOW},
|
|
1092
|
+
'head', ${head ? sql`json((SELECT json_object('nextSeq', next_seq, 'hash', hex(head_hash)) FROM audit_chain_head WHERE only_row))` : sql`NULL`},
|
|
1093
|
+
'last', ${head ? sql`json((SELECT json_object('seq', seq, 'hash', hex(hash)) FROM audit_log ORDER BY seq DESC LIMIT 1))` : sql`NULL`},
|
|
1094
|
+
'rotation', ${head ? sql`json((SELECT ${ENTRY} FROM audit_log e WHERE author = 'vault' AND action = 'key.rotate' AND decision = 'allow' ORDER BY seq DESC LIMIT 1))` : sql`NULL`},
|
|
1095
|
+
'members', json(${asked.members === true ? sql`(SELECT json_group_array(json_object(
|
|
1096
|
+
'principal', principal, 'status', status, 'owner', owner, 'role', role, 'scope', scope, 'generation', generation,
|
|
1097
|
+
'createdAt', created_at, 'createdBy', created_by, 'statusChangedAt', status_changed_at, 'statusChangedBy', status_changed_by,
|
|
1098
|
+
'accessSeq', access_seq, 'mac', hex(mac))) FROM vault_members WHERE principal IN (SELECT value FROM json_each(${principals})))` : sql`'[]'`}),
|
|
1099
|
+
'grants', json((
|
|
1100
|
+
SELECT json_group_array(json_object(
|
|
1101
|
+
'principal', g.principal, 'projectId', coalesce(g.project_id, env.project_id), 'environmentId', g.environment_id,
|
|
1102
|
+
'environmentSlug', g.environment_slug, 'role', g.role, 'expiresAt', g.expires_at, 'grantedAt', g.granted_at, 'grantedBy', g.granted_by))
|
|
1103
|
+
FROM vault_grants g LEFT JOIN environments env ON env.id = g.environment_id
|
|
1104
|
+
WHERE g.principal IN (SELECT value FROM json_each(${principals}))
|
|
1105
|
+
)),
|
|
1106
|
+
'access', json((
|
|
1107
|
+
SELECT json_group_array(json(newest)) FROM (
|
|
1108
|
+
SELECT (
|
|
1109
|
+
SELECT ${ENTRY} FROM audit_log e
|
|
1110
|
+
WHERE author = 'vault' AND subject_principal = asked.value AND action IN (SELECT value FROM json_each(${actions})) AND decision = 'allow'
|
|
1111
|
+
ORDER BY seq DESC LIMIT 1
|
|
1112
|
+
) AS newest
|
|
1113
|
+
FROM json_each(${principals}) AS asked
|
|
1114
|
+
) WHERE newest IS NOT NULL
|
|
1115
|
+
)),
|
|
1116
|
+
'policy', json((SELECT ${ENTRY} FROM policy e)),
|
|
1117
|
+
'environments', json((
|
|
1118
|
+
SELECT json_group_array(json_object(
|
|
1119
|
+
'id', env.id, 'projectId', env.project_id, 'slug', env.slug, 'owner', env.owner, 'parentId', env.parent_id,
|
|
1120
|
+
'deleted', env.slug LIKE ${tomb} OR project.slug LIKE ${tomb},
|
|
1121
|
+
'archived', env.archived_at IS NOT NULL OR project.archived_at IS NOT NULL))
|
|
1122
|
+
FROM environments env JOIN projects project ON project.id = env.project_id
|
|
1123
|
+
WHERE env.id IN (SELECT value FROM json_each(${environments}))
|
|
1124
|
+
OR env.id IN (SELECT parent_id FROM environments WHERE id IN (SELECT value FROM json_each(${environments})) AND owner IS NOT NULL)
|
|
1125
|
+
OR env.id IN (
|
|
1126
|
+
SELECT json_extract(place.value, '$.environmentId')
|
|
1127
|
+
FROM policy, json_each(policy.metadata, '$.policy.protected') AS place
|
|
1128
|
+
-- Of a forged entry's, an element that is no object names nothing: json_extract would fail on a string.
|
|
1129
|
+
WHERE json_valid(policy.metadata) AND json_type(policy.metadata, '$.policy.protected') = 'array' AND place.type = 'object'
|
|
1130
|
+
)
|
|
1131
|
+
)),
|
|
1132
|
+
'ended', json((
|
|
1133
|
+
SELECT json_group_array(related_seq) FROM audit_log
|
|
1134
|
+
WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq IN (SELECT value FROM json_each(${references}))
|
|
1135
|
+
)),
|
|
1136
|
+
'released', ${released === void 0 ? sql`0` : sql`(
|
|
1137
|
+
SELECT count(DISTINCT key) FROM (
|
|
1138
|
+
-- A read of one key names it; of several at one place, lists them (readsByPlace, vault.ts). An entry, or an
|
|
1139
|
+
-- item of its list, that names no key, which only a forged entry is, counts as one key of its own.
|
|
1140
|
+
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
|
|
1141
|
+
FROM audit_log e
|
|
1142
|
+
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
|
|
1143
|
+
WHERE e.author = 'vault' AND e.actor = ${released.principal} AND e.action = 'secret.read' AND e.decision = 'allow'
|
|
1144
|
+
AND e.occurred_at > ${NOW} + ${released.clockOffsetMs} - ${released.windowMs}
|
|
1145
|
+
UNION ALL SELECT value FROM json_each(${JSON.stringify(released.keys)})
|
|
1146
|
+
)
|
|
1147
|
+
)`}
|
|
1148
|
+
) AS snapshot`;
|
|
1149
|
+
}
|
|
1150
|
+
//#endregion
|
|
1151
|
+
//#region src/snapshot.ts
|
|
1152
|
+
async function snapshot(db, asked) {
|
|
1153
|
+
const read = () => readOnce(db, asked);
|
|
1154
|
+
const first = await read();
|
|
1155
|
+
if (asked.head !== true || first.head !== null && current(first.head)) return first;
|
|
1156
|
+
if (first.head === null) await lockLogHead(db);
|
|
1157
|
+
const again = await read();
|
|
1158
|
+
if (again.head === null) throw new Error("the log's head, held by this transaction, was read as held by another");
|
|
1159
|
+
if (!current(again.head)) throw new LogHeadMismatch(`the log's head says entry ${again.head.nextSeq} comes next, but the log ends ${again.head.last === null ? "empty" : `at entry ${again.head.last.seq}`}, or with another hash`);
|
|
1160
|
+
return again;
|
|
1161
|
+
}
|
|
1162
|
+
/** Whether the head names the last entry the same statement saw: nothing was appended between its snapshot and its lock. */
|
|
1163
|
+
function current(head) {
|
|
1164
|
+
if (head.last === null) return head.nextSeq === 0n;
|
|
1165
|
+
return head.nextSeq === head.last.seq + 1n && head.headHash.equals(head.last.hash);
|
|
1166
|
+
}
|
|
1167
|
+
async function readOnce(db, asked) {
|
|
1168
|
+
const query = (engineOf(db) === "postgres" ? snapshotQuery$1 : snapshotQuery)(asked);
|
|
1169
|
+
const [row] = await rowsOf(db, query);
|
|
1170
|
+
const raw = typeof row.snapshot === "string" ? JSON.parse(row.snapshot) : row.snapshot;
|
|
1171
|
+
return {
|
|
1172
|
+
at: Number(raw.at),
|
|
1173
|
+
members: new Map(raw.members.map((member) => [member.principal, memberOf(member)])),
|
|
1174
|
+
grants: new Map(asked.principals.map((principal) => [principal, raw.grants.filter((grant) => grant.principal === principal).map(grantOf)])),
|
|
1175
|
+
newestAccess: new Map(raw.access.map((entry) => [entry.subjectPrincipal, entryOf(entry)])),
|
|
1176
|
+
newestPolicy: raw.policy === null ? null : entryOf(raw.policy),
|
|
1177
|
+
environments: new Map(raw.environments.map(({ id, ...environment }) => [id, {
|
|
1178
|
+
...environment,
|
|
1179
|
+
deleted: Boolean(environment.deleted),
|
|
1180
|
+
archived: Boolean(environment.archived)
|
|
1181
|
+
}])),
|
|
1182
|
+
ended: new Set(raw.ended.map((seq) => BigInt(seq))),
|
|
1183
|
+
released: Number(raw.released ?? 0),
|
|
1184
|
+
head: raw.head === null ? null : {
|
|
1185
|
+
nextSeq: BigInt(raw.head.nextSeq),
|
|
1186
|
+
headHash: Buffer.from(raw.head.hash, "hex"),
|
|
1187
|
+
last: raw.last === null ? null : {
|
|
1188
|
+
seq: BigInt(raw.last.seq),
|
|
1189
|
+
hash: Buffer.from(raw.last.hash, "hex")
|
|
1190
|
+
},
|
|
1191
|
+
rotation: raw.rotation === null ? null : entryOf(raw.rotation)
|
|
1192
|
+
}
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
/** An entry as the chain's codec reads it, from what `entryJson` wrote. */
|
|
1196
|
+
function entryOf(raw) {
|
|
1197
|
+
return {
|
|
1198
|
+
...raw,
|
|
1199
|
+
seq: BigInt(raw.seq),
|
|
1200
|
+
author: raw.author,
|
|
1201
|
+
occurredAt: Number(raw.occurredAt),
|
|
1202
|
+
relatedSeq: raw.relatedSeq === null ? null : BigInt(raw.relatedSeq),
|
|
1203
|
+
prevHash: Buffer.from(raw.prevHash, "hex"),
|
|
1204
|
+
mac: Buffer.from(raw.mac, "hex"),
|
|
1205
|
+
hash: Buffer.from(raw.hash, "hex")
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
function grantOf(raw) {
|
|
1209
|
+
return {
|
|
1210
|
+
...raw,
|
|
1211
|
+
expiresAt: raw.expiresAt === null ? null : Number(raw.expiresAt),
|
|
1212
|
+
grantedAt: Number(raw.grantedAt)
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
function memberOf({ owner, ...raw }) {
|
|
1216
|
+
return memberOf$1({
|
|
1217
|
+
...raw,
|
|
1218
|
+
owner: owner === true || owner === 1,
|
|
1219
|
+
generation: Number(raw.generation),
|
|
1220
|
+
createdAt: Number(raw.createdAt),
|
|
1221
|
+
statusChangedAt: Number(raw.statusChangedAt),
|
|
1222
|
+
accessSeq: BigInt(raw.accessSeq),
|
|
1223
|
+
mac: Buffer.from(raw.mac, "hex")
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
//#endregion
|
|
1227
|
+
//#region src/accounting.ts
|
|
1228
|
+
const KEY_ACTIONS = /* @__PURE__ */ new Set([
|
|
1229
|
+
"secret.read",
|
|
1230
|
+
"key.wrap",
|
|
1231
|
+
"key.rewrap"
|
|
1232
|
+
]);
|
|
1233
|
+
/**
|
|
1234
|
+
* Every key operation's intent and its outcomes, entry by entry, oldest
|
|
1235
|
+
* first, as a walk reads the vault's entries (`add`); then what is still
|
|
1236
|
+
* open (`result`). The chain can hold while a process died between an
|
|
1237
|
+
* intent and its outcomes: only a missed deadline is a fault.
|
|
1238
|
+
*
|
|
1239
|
+
* A walk resumes from a checkpoint's `state()`: the batches open there, and
|
|
1240
|
+
* nothing of those that closed. So an intent's identity is checked against
|
|
1241
|
+
* those still open and those since, not every one the log ever held: a
|
|
1242
|
+
* closed batch takes no more outcomes in any case, since each names its
|
|
1243
|
+
* intent's entry.
|
|
1244
|
+
*/
|
|
1245
|
+
var KeyAccounting = class {
|
|
1246
|
+
#intents = /* @__PURE__ */ new Map();
|
|
1247
|
+
#ids = /* @__PURE__ */ new Set();
|
|
1248
|
+
#fault = null;
|
|
1249
|
+
constructor(from = {
|
|
1250
|
+
intents: [],
|
|
1251
|
+
fault: null
|
|
1252
|
+
}) {
|
|
1253
|
+
for (const { seq, seen, ...open } of from.intents) {
|
|
1254
|
+
this.#intents.set(BigInt(seq), {
|
|
1255
|
+
...open,
|
|
1256
|
+
seq: BigInt(seq),
|
|
1257
|
+
seen: new Set(seen)
|
|
1258
|
+
});
|
|
1259
|
+
this.#ids.add(open.intentId);
|
|
1260
|
+
}
|
|
1261
|
+
this.#fault = from.fault === null ? null : {
|
|
1262
|
+
ok: false,
|
|
1263
|
+
...from.fault
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
/** Where it stands, to resume from: the batches open, oldest first. */
|
|
1267
|
+
state() {
|
|
1268
|
+
return {
|
|
1269
|
+
intents: [...this.#intents.values()].sort((a, b) => a.seq < b.seq ? -1 : 1).map(({ seq, seen, ...open }) => ({
|
|
1270
|
+
...open,
|
|
1271
|
+
seq: Number(seq),
|
|
1272
|
+
seen: [...seen].sort((a, b) => a - b)
|
|
1273
|
+
})),
|
|
1274
|
+
fault: this.#fault === null ? null : {
|
|
1275
|
+
failedAtSeq: this.#fault.failedAtSeq,
|
|
1276
|
+
reason: this.#fault.reason
|
|
1277
|
+
}
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
add(entry) {
|
|
1281
|
+
if (this.#fault !== null || entry.author !== "vault") return;
|
|
1282
|
+
const broken = (reason) => {
|
|
1283
|
+
this.#fault = {
|
|
1284
|
+
ok: false,
|
|
1285
|
+
failedAtSeq: Number(entry.seq),
|
|
1286
|
+
reason
|
|
1287
|
+
};
|
|
1288
|
+
};
|
|
1289
|
+
if (entry.action === "key.intent") {
|
|
1290
|
+
const parsed = parseIntent(entry);
|
|
1291
|
+
if (typeof parsed === "string") return broken(parsed);
|
|
1292
|
+
if (this.#ids.has(parsed.intentId)) return broken("key intent repeats an operation identity");
|
|
1293
|
+
this.#ids.add(parsed.intentId);
|
|
1294
|
+
if (parsed.keys.length > 0) this.#intents.set(entry.seq, {
|
|
1295
|
+
...parsed,
|
|
1296
|
+
seq: entry.seq,
|
|
1297
|
+
actor: entry.actor,
|
|
1298
|
+
seen: /* @__PURE__ */ new Set()
|
|
1299
|
+
});
|
|
1300
|
+
} else if (KEY_ACTIONS.has(entry.action) && entry.relatedSeq !== null) {
|
|
1301
|
+
const parsed = parseOutcome(entry);
|
|
1302
|
+
if (typeof parsed === "string") return broken(parsed);
|
|
1303
|
+
const intent = this.#intents.get(parsed.relatedSeq);
|
|
1304
|
+
for (const outcome of parsed.items) {
|
|
1305
|
+
const key = intent?.keys[outcome.item];
|
|
1306
|
+
if (intent === void 0 || key === void 0 || intent.seen.has(outcome.item)) return broken("key outcome does not identify one item of its intent");
|
|
1307
|
+
if (parsed.intentId !== intent.intentId || entry.actor !== intent.actor || entry.action !== intent.operation) return broken("key outcome belongs to another operation");
|
|
1308
|
+
if (outcome.secretId !== key.secretId || outcome.version !== key.version || outcome.subject !== key.subject) return broken("key outcome does not match its intended secret");
|
|
1309
|
+
intent.seen.add(outcome.item);
|
|
1310
|
+
}
|
|
1311
|
+
if (intent !== void 0 && intent.seen.size === intent.keys.length) this.#intents.delete(intent.seq);
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
/** The first fault, or the batches still under way at `at`: live calls can finish after the snapshot. */
|
|
1315
|
+
result(at) {
|
|
1316
|
+
if (this.#fault !== null) return this.#fault;
|
|
1317
|
+
const intents = [...this.#intents.values()];
|
|
1318
|
+
const overdue = intents.filter((intent) => intent.expiresAt <= at).sort((a, b) => a.expiresAt - b.expiresAt);
|
|
1319
|
+
if (overdue.length === 0) return {
|
|
1320
|
+
ok: true,
|
|
1321
|
+
pending: intents.length
|
|
1322
|
+
};
|
|
1323
|
+
const reason = overdue.map((intent) => {
|
|
1324
|
+
const missing = intent.keys.length - intent.seen.size;
|
|
1325
|
+
return `key intent ${intent.intentId} is overdue: ${missing} of ${intent.keys.length} outcomes missing`;
|
|
1326
|
+
}).join("; ");
|
|
1327
|
+
return {
|
|
1328
|
+
ok: false,
|
|
1329
|
+
failedAtSeq: Number(overdue[0].seq),
|
|
1330
|
+
reason
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
};
|
|
1334
|
+
function parseIntent(entry) {
|
|
1335
|
+
const detail = payload(entry.metadata);
|
|
1336
|
+
if (detail === null) return "key intent has no valid accounting payload";
|
|
1337
|
+
const { intent, operation, expiresAt } = detail;
|
|
1338
|
+
if (typeof intent !== "string") return "key intent has no operation identity";
|
|
1339
|
+
if (typeof operation !== "string" || !KEY_ACTIONS.has(operation)) return "key intent has an invalid operation";
|
|
1340
|
+
if (typeof expiresAt !== "number" || !Number.isSafeInteger(expiresAt) || expiresAt < entry.occurredAt) return "key intent has no valid deadline";
|
|
1341
|
+
if (!Array.isArray(detail.keys)) return "key intent has no valid item list";
|
|
1342
|
+
const keys = [];
|
|
1343
|
+
for (const [index, value] of detail.keys.entries()) {
|
|
1344
|
+
const key = record(value);
|
|
1345
|
+
if (key === null || key.item !== index) return "key intent items are not numbered in order";
|
|
1346
|
+
const { secretId, version, subject } = key;
|
|
1347
|
+
if (typeof secretId !== "string" || typeof subject !== "string") return "key intent item has no secret identity";
|
|
1348
|
+
if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key intent item has no valid version";
|
|
1349
|
+
keys.push({
|
|
1350
|
+
item: index,
|
|
1351
|
+
secretId,
|
|
1352
|
+
version,
|
|
1353
|
+
subject
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
return {
|
|
1357
|
+
intentId: intent,
|
|
1358
|
+
operation,
|
|
1359
|
+
expiresAt,
|
|
1360
|
+
keys
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
function parseOutcome(entry) {
|
|
1364
|
+
const detail = payload(entry.metadata);
|
|
1365
|
+
if (detail === null) return "key outcome has no valid accounting payload";
|
|
1366
|
+
const { intent } = detail;
|
|
1367
|
+
if (typeof intent !== "string" || entry.relatedSeq === null) return "key outcome has no intent identity";
|
|
1368
|
+
const many = detail.secrets;
|
|
1369
|
+
if (many !== void 0 && (!Array.isArray(many) || many.length === 0)) return "key outcome has no valid item list";
|
|
1370
|
+
const listed = many === void 0 ? [{
|
|
1371
|
+
...detail,
|
|
1372
|
+
secretId: entry.secretId ?? detail.secretId
|
|
1373
|
+
}] : many.map(record);
|
|
1374
|
+
const items = [];
|
|
1375
|
+
for (const one of listed) {
|
|
1376
|
+
if (one === null) return "key outcome has no valid item list";
|
|
1377
|
+
const { item, secretId, version, subject } = one;
|
|
1378
|
+
if (typeof item !== "number" || !Number.isSafeInteger(item) || item < 0) return "key outcome has no valid item number";
|
|
1379
|
+
if (typeof secretId !== "string" || typeof subject !== "string") return "key outcome has no secret identity";
|
|
1380
|
+
if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key outcome has no valid version";
|
|
1381
|
+
items.push({
|
|
1382
|
+
item,
|
|
1383
|
+
secretId,
|
|
1384
|
+
version,
|
|
1385
|
+
subject
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
return {
|
|
1389
|
+
intentId: intent,
|
|
1390
|
+
relatedSeq: entry.relatedSeq,
|
|
1391
|
+
items
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
function payload(text) {
|
|
1395
|
+
try {
|
|
1396
|
+
return record(JSON.parse(text));
|
|
1397
|
+
} catch {
|
|
1398
|
+
return null;
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
function record(value) {
|
|
1402
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
1403
|
+
}
|
|
1404
|
+
//#endregion
|
|
1405
|
+
//#region src/tally.ts
|
|
1406
|
+
/** Before the first entry. */
|
|
1407
|
+
const EMPTY_TALLY = {
|
|
1408
|
+
entries: 0,
|
|
1409
|
+
keys: [],
|
|
1410
|
+
accounting: {
|
|
1411
|
+
intents: [],
|
|
1412
|
+
fault: null
|
|
1413
|
+
},
|
|
1414
|
+
access: {
|
|
1415
|
+
members: [],
|
|
1416
|
+
grants: [],
|
|
1417
|
+
newest: []
|
|
1418
|
+
},
|
|
1419
|
+
replayFault: null
|
|
1420
|
+
};
|
|
1421
|
+
/** The digest of what `access` says, over its canonical form. */
|
|
1422
|
+
function accessDigest(access) {
|
|
1423
|
+
return createHash("sha256").update(canonical(access)).digest("hex");
|
|
986
1424
|
}
|
|
987
1425
|
/**
|
|
988
|
-
*
|
|
989
|
-
*
|
|
990
|
-
*
|
|
991
|
-
*
|
|
1426
|
+
* What two tallies are compared by, so that they match whatever order
|
|
1427
|
+
* their maps were built in: the members and grants by their digest, which
|
|
1428
|
+
* a stretch passing many checkpoints computes once a change; and a fault by
|
|
1429
|
+
* where it was found, since the words for it may change from one release
|
|
1430
|
+
* to the next.
|
|
992
1431
|
*/
|
|
993
|
-
function
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1432
|
+
function tallyKey({ accounting, entries, keys, replayFault }, digest) {
|
|
1433
|
+
return canonical({
|
|
1434
|
+
entries,
|
|
1435
|
+
keys,
|
|
1436
|
+
replayFault,
|
|
1437
|
+
accounting: {
|
|
1438
|
+
...accounting,
|
|
1439
|
+
fault: accounting.fault?.failedAtSeq ?? null
|
|
1440
|
+
},
|
|
1441
|
+
access: digest
|
|
1442
|
+
});
|
|
997
1443
|
}
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
standing(member),
|
|
1005
|
-
member.generation,
|
|
1006
|
-
member.accessSeq.toString(),
|
|
1007
|
-
member.createdAt,
|
|
1008
|
-
member.createdBy,
|
|
1009
|
-
member.statusChangedAt,
|
|
1010
|
-
member.statusChangedBy,
|
|
1011
|
-
held
|
|
1012
|
-
];
|
|
1013
|
-
return createHmac("sha256", key).update(JSON.stringify(tuple)).digest();
|
|
1444
|
+
/**
|
|
1445
|
+
* What a walk must come to at a checkpoint, from the tally it keeps: its
|
|
1446
|
+
* members and grants by the digest it keeps of them, never read.
|
|
1447
|
+
*/
|
|
1448
|
+
function expectedKey(stored) {
|
|
1449
|
+
return tallyKey(stored, stored.access.digest);
|
|
1014
1450
|
}
|
|
1015
|
-
/**
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1451
|
+
/**
|
|
1452
|
+
* The vault's entries walked, oldest first, each after its MAC was checked,
|
|
1453
|
+
* gathered into what the checks after the walk need: the tally it started
|
|
1454
|
+
* from, carried on.
|
|
1455
|
+
*/
|
|
1456
|
+
var Walk = class {
|
|
1457
|
+
accounting;
|
|
1458
|
+
replayed;
|
|
1459
|
+
/** Each member's newest allowed access entry, by seq. */
|
|
1460
|
+
newestAccess;
|
|
1461
|
+
/** The first access entry the replay could not apply. */
|
|
1462
|
+
replayFault;
|
|
1463
|
+
/** The vault's entries so far, those before the walk included. */
|
|
1464
|
+
entries;
|
|
1465
|
+
/** The vault's log keys, as the log moved through them. */
|
|
1466
|
+
keys;
|
|
1467
|
+
/** The checkpoints walked, each to be checked against its signature and the prefix it signed once the walk ends: what it signed, and nothing it keeps beside. */
|
|
1468
|
+
checkpoints = [];
|
|
1469
|
+
/** The tallies the walk must come to as it passes them, oldest first (`expect`). */
|
|
1470
|
+
#expected = [];
|
|
1471
|
+
/** The first it did not come to, and why. */
|
|
1472
|
+
mismatch = null;
|
|
1473
|
+
/** The digest of the members and grants as the walk has them, until an access entry changes them. */
|
|
1474
|
+
#digest = null;
|
|
1475
|
+
/** The digest of the members and grants as the walk has them: the tally's it started from, until an access entry. */
|
|
1476
|
+
digest() {
|
|
1477
|
+
return this.#digest ??= accessDigest(this.tally().access);
|
|
1478
|
+
}
|
|
1479
|
+
/** The tally but for its members and grants, which `digest` stands for. */
|
|
1480
|
+
#unaccessed() {
|
|
1481
|
+
return {
|
|
1482
|
+
entries: this.entries,
|
|
1483
|
+
keys: [...this.keys],
|
|
1484
|
+
accounting: this.accounting.state(),
|
|
1485
|
+
replayFault: this.replayFault
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
/** `digest` is that of `from`'s members and grants, when the caller has it: a beat with no access entry hashes none. */
|
|
1489
|
+
constructor(from, digest) {
|
|
1490
|
+
this.#digest = digest ?? null;
|
|
1491
|
+
this.accounting = new KeyAccounting(from.accounting);
|
|
1492
|
+
this.replayed = {
|
|
1493
|
+
members: new Map(from.access.members.map((member) => [member.principal, {
|
|
1494
|
+
...member,
|
|
1495
|
+
accessSeq: BigInt(member.accessSeq)
|
|
1496
|
+
}])),
|
|
1497
|
+
held: /* @__PURE__ */ new Map()
|
|
1498
|
+
};
|
|
1499
|
+
for (const grant of from.access.grants) {
|
|
1500
|
+
const held = this.replayed.held.get(grant.principal) ?? /* @__PURE__ */ new Map();
|
|
1501
|
+
this.replayed.held.set(grant.principal, held);
|
|
1502
|
+
held.set(placeKey$1(grant), { ...grant });
|
|
1503
|
+
}
|
|
1504
|
+
this.newestAccess = new Map(from.access.newest.map(([principal, seq]) => [principal, BigInt(seq)]));
|
|
1505
|
+
this.replayFault = from.replayFault;
|
|
1506
|
+
this.entries = from.entries;
|
|
1507
|
+
this.keys = from.keys;
|
|
1508
|
+
}
|
|
1509
|
+
/**
|
|
1510
|
+
* Hold the walk to these tallies, each as the walk stands once it has
|
|
1511
|
+
* read every entry through the prefix's end: before the vault's next
|
|
1512
|
+
* entry, since only the vault's change a tally, or at `finish`.
|
|
1513
|
+
*/
|
|
1514
|
+
expect(expected) {
|
|
1515
|
+
this.#expected = [...expected].sort((a, b) => a.through < b.through ? -1 : 1);
|
|
1516
|
+
}
|
|
1517
|
+
/** The tallies expected through `through`, held to the walk. */
|
|
1518
|
+
finish(through) {
|
|
1519
|
+
this.#reach(through + 1n);
|
|
1520
|
+
}
|
|
1521
|
+
#reach(before) {
|
|
1522
|
+
while (this.#expected.length > 0 && this.#expected[0].through < before) {
|
|
1523
|
+
const expected = this.#expected.shift();
|
|
1524
|
+
if (this.mismatch !== null) continue;
|
|
1525
|
+
if ("unreadable" in expected) this.mismatch = {
|
|
1526
|
+
entry: expected.entry,
|
|
1527
|
+
reason: expected.unreadable
|
|
1528
|
+
};
|
|
1529
|
+
else if (tallyKey(this.#unaccessed(), this.digest()) !== expected.key) this.mismatch = {
|
|
1530
|
+
entry: expected.entry,
|
|
1531
|
+
reason: "the tally this checkpoint keeps does not follow from the log before it"
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
/**
|
|
1536
|
+
* One of the vault's entries, its MAC checked, in the log's order: a
|
|
1537
|
+
* fault when it is under a key the vault had moved past (`forward`).
|
|
1538
|
+
*/
|
|
1539
|
+
add(entry, current) {
|
|
1540
|
+
this.#reach(entry.seq);
|
|
1541
|
+
const moved = forward([entry], this.keys, current);
|
|
1542
|
+
if ("failedAtSeq" in moved) return moved;
|
|
1543
|
+
this.keys = moved.keys;
|
|
1544
|
+
this.entries += 1;
|
|
1545
|
+
this.accounting.add(entry);
|
|
1546
|
+
if (entry.decision !== "allow") return null;
|
|
1547
|
+
if (ACCESS.has(entry.action)) {
|
|
1548
|
+
this.#digest = null;
|
|
1549
|
+
this.replayFault ??= apply(this.replayed, entry);
|
|
1550
|
+
if (entry.subjectPrincipal !== null) this.newestAccess.set(entry.subjectPrincipal, entry.seq);
|
|
1551
|
+
} else if (entry.action === "audit.checkpoint") {
|
|
1552
|
+
const { seq, hash, signedAt, keyId, signature } = JSON.parse(entry.metadata);
|
|
1553
|
+
this.checkpoints.push({
|
|
1554
|
+
entry: {
|
|
1555
|
+
seq: entry.seq,
|
|
1556
|
+
keyId: entry.keyId,
|
|
1557
|
+
relatedSeq: entry.relatedSeq
|
|
1558
|
+
},
|
|
1559
|
+
checkpoint: {
|
|
1560
|
+
seq,
|
|
1561
|
+
hash,
|
|
1562
|
+
signedAt,
|
|
1563
|
+
keyId,
|
|
1564
|
+
signature
|
|
1565
|
+
}
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
return null;
|
|
1569
|
+
}
|
|
1570
|
+
/** Where the walk stands, to resume from. */
|
|
1571
|
+
tally() {
|
|
1572
|
+
const byPrincipal = (entries) => [...entries].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
1573
|
+
return {
|
|
1574
|
+
entries: this.entries,
|
|
1575
|
+
keys: [...this.keys],
|
|
1576
|
+
accounting: this.accounting.state(),
|
|
1577
|
+
access: {
|
|
1578
|
+
members: byPrincipal(this.replayed.members).map(([, member]) => ({
|
|
1579
|
+
...member,
|
|
1580
|
+
accessSeq: Number(member.accessSeq)
|
|
1581
|
+
})),
|
|
1582
|
+
grants: byPrincipal(this.replayed.held).flatMap(([, held]) => byPrincipal(held).map(([, grant]) => grant)),
|
|
1583
|
+
newest: byPrincipal(this.newestAccess).map(([principal, seq]) => [principal, Number(seq)])
|
|
1584
|
+
},
|
|
1585
|
+
replayFault: this.replayFault
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
};
|
|
1589
|
+
/** The action of the vault's entry that signs a prefix of the log. */
|
|
1590
|
+
const CHECKPOINT = "audit.checkpoint";
|
|
1591
|
+
const ACCESS = new Set(ACCESS_ACTIONS$1);
|
|
1592
|
+
/** JSON with every object's keys sorted: one text for one value, however it was built. */
|
|
1593
|
+
function canonical(value) {
|
|
1594
|
+
return JSON.stringify(value, (_, inner) => inner !== null && typeof inner === "object" && !Array.isArray(inner) ? Object.fromEntries(Object.entries(inner).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : inner);
|
|
1019
1595
|
}
|
|
1020
1596
|
//#endregion
|
|
1021
1597
|
//#region src/vault.ts
|
|
@@ -1025,9 +1601,13 @@ async function prepareVault(config, options = {}) {
|
|
|
1025
1601
|
...await keysOf(config.signingKeys),
|
|
1026
1602
|
options: {
|
|
1027
1603
|
keyBudgetMs: options.keyBudgetMs ?? KEY_BUDGET_MS,
|
|
1028
|
-
clockOffset: options.clockOffset ?? (() => 0)
|
|
1604
|
+
clockOffset: options.clockOffset ?? (() => 0),
|
|
1605
|
+
walks: {
|
|
1606
|
+
...WALKS,
|
|
1607
|
+
...options.walks
|
|
1608
|
+
}
|
|
1029
1609
|
},
|
|
1030
|
-
verified:
|
|
1610
|
+
verified: START,
|
|
1031
1611
|
reported: /* @__PURE__ */ new Set(),
|
|
1032
1612
|
since: null,
|
|
1033
1613
|
superseded: null,
|
|
@@ -1076,8 +1656,6 @@ const UNSEALED = {
|
|
|
1076
1656
|
};
|
|
1077
1657
|
/** Why a change to a tampered member is refused. */
|
|
1078
1658
|
const TAMPERED_SUBJECT = "this member's record failed the vault's integrity check: remove them to start over";
|
|
1079
|
-
/** The action of the vault's entry that signs a prefix of the log. */
|
|
1080
|
-
const CHECKPOINT = "audit.checkpoint";
|
|
1081
1659
|
/** The action of the vault's entry that holds the instance's settings, and changes them (`settings`). */
|
|
1082
1660
|
const SETTINGS = "settings.change";
|
|
1083
1661
|
/** The settings before anyone changed them: people set up service accounts anywhere they hold access. */
|
|
@@ -1122,12 +1700,6 @@ function declaredOwner(principal) {
|
|
|
1122
1700
|
const NOT_THEIRS = "a person gives a service account at most what they hold, where the instance lets them set one up, and only to one whose every grant they hold";
|
|
1123
1701
|
/** Who asks for checkpoints: the app's scheduled job. */
|
|
1124
1702
|
const SCHEDULER = "system:coffre-scheduler";
|
|
1125
|
-
/**
|
|
1126
|
-
* How often a checkpoint recomputes the chain from its first entry: the
|
|
1127
|
-
* first of each hour, by the vault's clock. The others resume from the
|
|
1128
|
-
* prefix the last one signed (`#recompute`).
|
|
1129
|
-
*/
|
|
1130
|
-
const FULL_RECOMPUTE_MS = 36e5;
|
|
1131
1703
|
/** A refusal and the entries that record it. */
|
|
1132
1704
|
var Refused = class {
|
|
1133
1705
|
refusal;
|
|
@@ -1229,6 +1801,24 @@ var VaultService = class {
|
|
|
1229
1801
|
async #now(db) {
|
|
1230
1802
|
return await now(db) + this.#prepared.options.clockOffset();
|
|
1231
1803
|
}
|
|
1804
|
+
/** A snapshot's clock, as the vault keeps time (`#now`). */
|
|
1805
|
+
#at(reads) {
|
|
1806
|
+
return reads.at + this.#prepared.options.clockOffset();
|
|
1807
|
+
}
|
|
1808
|
+
/** What a decision about `principals` reads, and `asked` besides, in one statement: under the head when `head` is set. */
|
|
1809
|
+
#snapshot(tx, principals, asked, head) {
|
|
1810
|
+
const { released, ...rest } = asked;
|
|
1811
|
+
return snapshot(tx, {
|
|
1812
|
+
...rest,
|
|
1813
|
+
principals,
|
|
1814
|
+
head,
|
|
1815
|
+
...released === void 0 ? {} : { released: {
|
|
1816
|
+
...released,
|
|
1817
|
+
windowMs: this.#config.bulkLimit.windowMs,
|
|
1818
|
+
clockOffsetMs: this.#prepared.options.clockOffset()
|
|
1819
|
+
} }
|
|
1820
|
+
});
|
|
1821
|
+
}
|
|
1232
1822
|
/**
|
|
1233
1823
|
* Decide in one transaction, with `principals`' rows locked first, then
|
|
1234
1824
|
* the log's head. A `Refused` or an `Outage` rolls back all but its own
|
|
@@ -1241,34 +1831,35 @@ var VaultService = class {
|
|
|
1241
1831
|
* alone takes the head later (`late`), once its key service has answered,
|
|
1242
1832
|
* which it must not wait on holding it, and decides again then (`#keys`).
|
|
1243
1833
|
*/
|
|
1244
|
-
async #decide(principals, decide, late = false) {
|
|
1834
|
+
async #decide(principals, decide, late = false, asked = {}) {
|
|
1245
1835
|
const superseded = await this.#settled();
|
|
1246
1836
|
if (superseded !== null) return {
|
|
1247
1837
|
ok: false,
|
|
1248
1838
|
refusal: refusal("wrong_kek", superseded)
|
|
1249
1839
|
};
|
|
1250
|
-
return this.#decideNow(principals, decide, late);
|
|
1840
|
+
return this.#decideNow(principals, decide, late, asked);
|
|
1251
1841
|
}
|
|
1252
1842
|
/** `#decide`, once the vault is settled. */
|
|
1253
|
-
async #decideNow(principals, decide, late = false) {
|
|
1843
|
+
async #decideNow(principals, decide, late = false, asked = {}) {
|
|
1254
1844
|
const reports = [];
|
|
1255
1845
|
for (let attempt = 1;; attempt += 1) try {
|
|
1256
|
-
return await this.#decideOnce(principals, decide, reports, late);
|
|
1846
|
+
return await this.#decideOnce(principals, decide, reports, late, asked);
|
|
1257
1847
|
} catch (error) {
|
|
1258
1848
|
if (attempt < 3 && (error instanceof Retry || isUniqueViolation(error))) continue;
|
|
1259
1849
|
throw error;
|
|
1260
1850
|
}
|
|
1261
1851
|
}
|
|
1262
|
-
async #decideOnce(principals, decide, reports, late) {
|
|
1852
|
+
async #decideOnce(principals, decide, reports, late, asked) {
|
|
1263
1853
|
try {
|
|
1264
1854
|
const result = await this.#db.transaction(async (tx) => {
|
|
1265
1855
|
await boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
1266
1856
|
const members = principals.length === 0 ? /* @__PURE__ */ new Map() : await lockMembers(tx, principals);
|
|
1267
|
-
|
|
1857
|
+
const reads = await this.#snapshot(tx, principals, asked, !late);
|
|
1268
1858
|
const d = {
|
|
1269
1859
|
tx,
|
|
1270
1860
|
members,
|
|
1271
|
-
at:
|
|
1861
|
+
at: this.#at(reads),
|
|
1862
|
+
reads,
|
|
1272
1863
|
log: [],
|
|
1273
1864
|
writes: [],
|
|
1274
1865
|
touched: /* @__PURE__ */ new Set(),
|
|
@@ -1282,7 +1873,8 @@ var VaultService = class {
|
|
|
1282
1873
|
let at = d.at;
|
|
1283
1874
|
const accessSeq = /* @__PURE__ */ new Map();
|
|
1284
1875
|
if (entries.length > 0) {
|
|
1285
|
-
|
|
1876
|
+
if (d.reads.head === null) throw new Error("a decision appends only under the head it read with");
|
|
1877
|
+
const appended = await this.#append(tx, entries, d.underLock, d.reads);
|
|
1286
1878
|
at = appended.occurredAt;
|
|
1287
1879
|
entries.forEach((entry, i) => {
|
|
1288
1880
|
if (isAccessEntry(entry)) accessSeq.set(entry.subjectPrincipal, appended.seqStart + BigInt(i));
|
|
@@ -1347,12 +1939,12 @@ var VaultService = class {
|
|
|
1347
1939
|
* MAC are passed over, and reported: otherwise whoever can insert a row
|
|
1348
1940
|
* could lock any member out.
|
|
1349
1941
|
*/
|
|
1350
|
-
async #integrity(db, principal, row, grants, reports) {
|
|
1942
|
+
async #integrity(db, principal, row, grants, reports, reads) {
|
|
1351
1943
|
if (row !== void 0 && !this.#sealed(row, grants)) {
|
|
1352
1944
|
this.#report(reports, principal, "mac", row.mac.toString("hex"));
|
|
1353
1945
|
return "mac";
|
|
1354
1946
|
}
|
|
1355
|
-
const newest = await this.#newestAccess(db, principal, reports);
|
|
1947
|
+
const newest = await this.#newestAccess(db, principal, reports, reads);
|
|
1356
1948
|
const seq = typeof newest !== "string" ? newest.seq : newest === "unreached" ? void 0 : null;
|
|
1357
1949
|
if (row === void 0 ? seq === null : seq === row.accessSeq) return null;
|
|
1358
1950
|
if (row === void 0 && await member(db, principal) !== void 0) throw new Retry();
|
|
@@ -1360,8 +1952,10 @@ var VaultService = class {
|
|
|
1360
1952
|
return "stale";
|
|
1361
1953
|
}
|
|
1362
1954
|
/** The newest access entry about `principal` that carries the vault's MAC (`newestAccess` in @coffre/db/grants). */
|
|
1363
|
-
#newestAccess(db, principal, reports) {
|
|
1364
|
-
return newestAccess(db, principal, this.#check(reports, principal));
|
|
1955
|
+
#newestAccess(db, principal, reports, reads) {
|
|
1956
|
+
if (reads === void 0 || !reads.grants.has(principal)) return newestAccess(db, principal, this.#check(reports, principal));
|
|
1957
|
+
const newest = reads.newestAccess.get(principal);
|
|
1958
|
+
return newestAuthentic((limit, before) => before === void 0 ? Promise.resolve(newest === void 0 ? [] : [newest]) : accessEntriesAbout(db, principal, limit, before), this.#check(reports, principal));
|
|
1365
1959
|
}
|
|
1366
1960
|
/** Entries in the vault's name told apart by their MACs, each it did not write reported, about `principal`. */
|
|
1367
1961
|
#check(reports, principal) {
|
|
@@ -1372,14 +1966,17 @@ var VaultService = class {
|
|
|
1372
1966
|
}
|
|
1373
1967
|
/** Whether `entry` carries the vault's MAC: under any of its keys before `since`, and only its current one from there. */
|
|
1374
1968
|
#authentic(entry) {
|
|
1375
|
-
const { logKeys, since } = this.#prepared;
|
|
1376
|
-
const keys = since !== null && entry.seq >= since ? logKeys.slice(0, 1) : logKeys;
|
|
1377
1969
|
return verifyEntries([entry], {
|
|
1378
1970
|
startSeq: entry.seq,
|
|
1379
1971
|
startPrevHash: entry.prevHash,
|
|
1380
|
-
keys
|
|
1972
|
+
keys: this.#keysFor(entry)
|
|
1381
1973
|
}).ok;
|
|
1382
1974
|
}
|
|
1975
|
+
/** The vault's log keys an entry may be under: any of them before `since`, and only its current one from there. */
|
|
1976
|
+
#keysFor(entry) {
|
|
1977
|
+
const { logKeys, since } = this.#prepared;
|
|
1978
|
+
return since !== null && entry.seq >= since ? logKeys.slice(0, 1) : logKeys;
|
|
1979
|
+
}
|
|
1383
1980
|
/**
|
|
1384
1981
|
* Whether `row`, with these grants, carries the MAC of the vault's row
|
|
1385
1982
|
* key, or of one it replaced before the rotation sealed every row again
|
|
@@ -1456,7 +2053,7 @@ var VaultService = class {
|
|
|
1456
2053
|
}
|
|
1457
2054
|
async #replaceEveryProject(d, principal) {
|
|
1458
2055
|
const row = d.members.get(principal);
|
|
1459
|
-
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
2056
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
|
|
1460
2057
|
if (row === void 0 || subject.status !== "active") return;
|
|
1461
2058
|
const everyProject = subject.stored.filter((grant) => grant.projectId === null && grant.environmentId === null);
|
|
1462
2059
|
if (everyProject.length === 0) return;
|
|
@@ -1608,16 +2205,31 @@ var VaultService = class {
|
|
|
1608
2205
|
* that an instance still running with the replaced KEK, as during a
|
|
1609
2206
|
* deploy, cannot write after the rotation.
|
|
1610
2207
|
*/
|
|
1611
|
-
#append(tx, entries, underLock = []) {
|
|
2208
|
+
async #append(tx, entries, underLock = [], held) {
|
|
1612
2209
|
const prepared = this.#prepared;
|
|
1613
|
-
|
|
1614
|
-
|
|
2210
|
+
const head = held?.head ?? void 0;
|
|
2211
|
+
const appended = await appendEntries(tx, prepared.logKey, entries, async (locked) => {
|
|
2212
|
+
const rotation = head === void 0 ? await latestVaultEntry(locked, [KEY_ROTATE]) : head.rotation ?? void 0;
|
|
1615
2213
|
if (rotation !== void 0 && !prepared.logKeys.some((key) => key.keyId === rotation.keyId)) {
|
|
1616
2214
|
prepared.superseded = `the log moved on to ${rotation.keyId}, a key this vault does not hold: a vault given a newer vault key replaced it, and a replaced key writes nothing more`;
|
|
1617
2215
|
throw new Error(prepared.superseded);
|
|
1618
2216
|
}
|
|
1619
2217
|
for (const check of underLock) await check(locked);
|
|
2218
|
+
}, head === void 0 ? void 0 : {
|
|
2219
|
+
head,
|
|
2220
|
+
last: head.last,
|
|
2221
|
+
at: held.at
|
|
1620
2222
|
});
|
|
2223
|
+
if (head !== void 0) held.head = {
|
|
2224
|
+
...head,
|
|
2225
|
+
nextSeq: appended.nextSeq,
|
|
2226
|
+
headHash: appended.headHash,
|
|
2227
|
+
last: {
|
|
2228
|
+
seq: appended.nextSeq - 1n,
|
|
2229
|
+
hash: appended.headHash
|
|
2230
|
+
}
|
|
2231
|
+
};
|
|
2232
|
+
return appended;
|
|
1621
2233
|
}
|
|
1622
2234
|
async unwrap(input) {
|
|
1623
2235
|
const { principal } = input;
|
|
@@ -1674,10 +2286,8 @@ var VaultService = class {
|
|
|
1674
2286
|
permission: input.purpose === "use" ? "secret.use" : "secret.read",
|
|
1675
2287
|
secrets: items.map((item) => item.secret),
|
|
1676
2288
|
places: sealed.map((seal, i) => seal?.holder ?? items[i].secret),
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
return sealed.map((seal) => seal !== null && ended.has(seal.seq) ? "ended" : null);
|
|
1680
|
-
},
|
|
2289
|
+
references: ends,
|
|
2290
|
+
vet: ends.length === 0 ? void 0 : async (_, reads) => sealed.map((seal) => seal !== null && reads.ended.has(seal.seq) ? "ended" : null),
|
|
1681
2291
|
remote,
|
|
1682
2292
|
entry,
|
|
1683
2293
|
input
|
|
@@ -1858,11 +2468,9 @@ var VaultService = class {
|
|
|
1858
2468
|
});
|
|
1859
2469
|
const owners = /* @__PURE__ */ new Map();
|
|
1860
2470
|
const check = async (d) => {
|
|
1861
|
-
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), call.input);
|
|
2471
|
+
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), call.input);
|
|
1862
2472
|
const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
|
|
1863
|
-
const environments =
|
|
1864
|
-
const parents = [...environments.values()].flatMap((environment) => environment.owner === null || environment.parentId === null ? [] : [environment.parentId]);
|
|
1865
|
-
for (const [id, parent] of await environmentsById(d.tx, parents)) environments.set(id, parent);
|
|
2473
|
+
const environments = d.reads.environments;
|
|
1866
2474
|
const gone = (place) => environments.get(place.environmentId)?.deleted ?? false;
|
|
1867
2475
|
owners.clear();
|
|
1868
2476
|
for (const [id, environment] of environments) if (environment.owner !== null) owners.set(id, ownerKey(environment.owner));
|
|
@@ -1878,7 +2486,7 @@ var VaultService = class {
|
|
|
1878
2486
|
}
|
|
1879
2487
|
return refuses(reader, call.permission, placeIn(at[i], environments), gone(secret) || gone(at[i]));
|
|
1880
2488
|
});
|
|
1881
|
-
if (call.vet !== void 0 && !codes.some((code) => code !== null)) (await call.vet(d.tx)).forEach((code, i) => codes[i] = code);
|
|
2489
|
+
if (call.vet !== void 0 && !codes.some((code) => code !== null)) (await call.vet(d.tx, d.reads)).forEach((code, i) => codes[i] = code);
|
|
1882
2490
|
const copying = action === "secret.read" && call.input.purpose === "copy";
|
|
1883
2491
|
if (action === "secret.read" && principal.startsWith("user:") && handsBack({
|
|
1884
2492
|
purpose: call.input.purpose ?? "",
|
|
@@ -1887,9 +2495,17 @@ var VaultService = class {
|
|
|
1887
2495
|
if (codes[i] === null && secret.sensitive === true) codes[i] = "sensitive";
|
|
1888
2496
|
});
|
|
1889
2497
|
let first = codes.find((code) => code !== null) ?? null;
|
|
1890
|
-
if (first === null && action === "secret.read" &&
|
|
2498
|
+
if (first === null && action === "secret.read" && this.#overBulkLimit(d)) first = "bulk_limit";
|
|
1891
2499
|
if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, i) => entry(secret, "deny", codes[i] ?? first)));
|
|
1892
2500
|
};
|
|
2501
|
+
const reads = {
|
|
2502
|
+
environments: [...secrets, ...secrets.map((secret, i) => call.places?.[i] ?? secret)].map((place) => place.environmentId),
|
|
2503
|
+
references: call.references ?? [],
|
|
2504
|
+
...action === "secret.read" ? { released: {
|
|
2505
|
+
principal,
|
|
2506
|
+
keys: secrets.map((secret) => secret.secretId)
|
|
2507
|
+
} } : {}
|
|
2508
|
+
};
|
|
1893
2509
|
const intentId = call.remote ? randomUUID() : null;
|
|
1894
2510
|
let intentSeq = null;
|
|
1895
2511
|
const outcomeEntry = (secret, item, decision, code) => {
|
|
@@ -1908,7 +2524,7 @@ var VaultService = class {
|
|
|
1908
2524
|
if (call.remote) {
|
|
1909
2525
|
const intent = await this.#decide([principal], async (d) => {
|
|
1910
2526
|
await check(d);
|
|
1911
|
-
const at =
|
|
2527
|
+
const at = d.at;
|
|
1912
2528
|
return { seq: (await this.#append(d.tx, [{
|
|
1913
2529
|
actor: principal,
|
|
1914
2530
|
action: "key.intent",
|
|
@@ -1928,21 +2544,25 @@ var VaultService = class {
|
|
|
1928
2544
|
version: secret.version
|
|
1929
2545
|
}))
|
|
1930
2546
|
})
|
|
1931
|
-
}])).seqStart };
|
|
1932
|
-
});
|
|
2547
|
+
}], [], d.reads)).seqStart };
|
|
2548
|
+
}, false, reads);
|
|
1933
2549
|
if (!intent.ok) return intent;
|
|
1934
2550
|
intentSeq = intent.seq;
|
|
1935
2551
|
}
|
|
1936
|
-
|
|
2552
|
+
const late = call.remote;
|
|
2553
|
+
const decided = async (d) => {
|
|
1937
2554
|
try {
|
|
1938
2555
|
await check(d);
|
|
1939
2556
|
} catch (error) {
|
|
1940
2557
|
if (error instanceof Refused) throw new Refused(error.refusal, secrets.map((secret, item) => outcomeEntry(secret, item, "deny", error.entries[item].code ?? error.refusal.code)));
|
|
1941
2558
|
throw error;
|
|
1942
2559
|
}
|
|
2560
|
+
};
|
|
2561
|
+
return this.#decide([principal], async (d) => {
|
|
2562
|
+
await decided(d);
|
|
1943
2563
|
const { vet } = call;
|
|
1944
|
-
if (vet !== void 0) d.underLock.push(async (locked) => {
|
|
1945
|
-
const codes = await vet(locked);
|
|
2564
|
+
if (late && vet !== void 0) d.underLock.push(async (locked) => {
|
|
2565
|
+
const codes = await vet(locked, d.reads);
|
|
1946
2566
|
const first = codes.find((code) => code !== null);
|
|
1947
2567
|
if (first === void 0 || first === null) return;
|
|
1948
2568
|
throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, "deny", codes[item] ?? first)));
|
|
@@ -1957,16 +2577,12 @@ var VaultService = class {
|
|
|
1957
2577
|
if (unanswered > 0) throw new Outage(new KekUnavailableError(`the key service did not answer for ${unanswered} of ${secrets.length} keys`, outcomes.some((outcome) => !outcome.ok && outcome.code === "kms_uncertain")), entries());
|
|
1958
2578
|
if (expired) throw new Outage(new KekUnavailableError("key operation exceeded its deadline"), entries());
|
|
1959
2579
|
if (done.length < outcomes.length) throw new Refused(refusal("bad_claim", MESSAGES.bad_claim), entries());
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
if (
|
|
1965
|
-
throw error;
|
|
2580
|
+
if (late) {
|
|
2581
|
+
d.reads = await this.#snapshot(d.tx, [principal], reads, true);
|
|
2582
|
+
d.at = this.#at(d.reads);
|
|
2583
|
+
await decided(d);
|
|
2584
|
+
if (secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]).some((place) => d.reads.environments.get(place.environmentId)?.deleted ?? false)) throw new Refused(refusal("deleted", MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, "deny", "deleted")));
|
|
1966
2585
|
}
|
|
1967
|
-
const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
|
|
1968
|
-
const now = await environmentsById(d.tx, places.map((place) => place.environmentId));
|
|
1969
|
-
if (places.some((place) => now.get(place.environmentId)?.deleted ?? false)) throw new Refused(refusal("deleted", MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, "deny", "deleted")));
|
|
1970
2586
|
const released = secrets.map((secret, i) => outcomeEntry(secret, i, "allow", null));
|
|
1971
2587
|
d.log.push(...released);
|
|
1972
2588
|
const answer = result(done);
|
|
@@ -1975,12 +2591,16 @@ var VaultService = class {
|
|
|
1975
2591
|
} finally {
|
|
1976
2592
|
for (const value of done) value.wipe();
|
|
1977
2593
|
}
|
|
1978
|
-
},
|
|
2594
|
+
}, late, reads);
|
|
1979
2595
|
}
|
|
1980
|
-
/**
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
2596
|
+
/**
|
|
2597
|
+
* Whether this read's keys would take the reader past the bulk limit: the
|
|
2598
|
+
* distinct keys released to them in the window, these among them. A key
|
|
2599
|
+
* read again exposes nothing new, so it counts once, whatever its version.
|
|
2600
|
+
* Counted under their row's lock, so exactly (`Reads`).
|
|
2601
|
+
*/
|
|
2602
|
+
#overBulkLimit(d) {
|
|
2603
|
+
return d.reads.released > this.#config.bulkLimit.count;
|
|
1984
2604
|
}
|
|
1985
2605
|
/**
|
|
1986
2606
|
* Make each holder a reference to its source: the actor writes the holder
|
|
@@ -2030,7 +2650,7 @@ var VaultService = class {
|
|
|
2030
2650
|
};
|
|
2031
2651
|
};
|
|
2032
2652
|
return this.#decide([principal], async (d) => {
|
|
2033
|
-
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
|
|
2653
|
+
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), input);
|
|
2034
2654
|
const natures$1 = await natures(d.tx, [...sources.values()].map((source) => source.secretId));
|
|
2035
2655
|
const environments = await environmentsById(d.tx, items.flatMap((item) => {
|
|
2036
2656
|
const source = sources.get(item.source.secretId);
|
|
@@ -2109,7 +2729,7 @@ var VaultService = class {
|
|
|
2109
2729
|
return seals.map((seal) => done.has(seal.seq) ? "ended" : null);
|
|
2110
2730
|
};
|
|
2111
2731
|
return this.#decide([principal], async (d) => {
|
|
2112
|
-
const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
|
|
2732
|
+
const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), input);
|
|
2113
2733
|
const environments = await environmentsById(d.tx, seals.map((seal) => seal.holder.environmentId));
|
|
2114
2734
|
const codes = seals.map((seal) => {
|
|
2115
2735
|
const holder = refuses(actor, "secret.write", placeIn(seal.holder, environments), false);
|
|
@@ -2210,7 +2830,7 @@ var VaultService = class {
|
|
|
2210
2830
|
*/
|
|
2211
2831
|
async #makeOwner(d, principal, reason) {
|
|
2212
2832
|
const row = d.members.get(principal);
|
|
2213
|
-
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
2833
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
|
|
2214
2834
|
if (subject.status === "active" && ownsInstance(subject.live)) return false;
|
|
2215
2835
|
const tampered = subject.status === "tampered";
|
|
2216
2836
|
if (tampered && await this.#logged(d.tx, principal) === void 0) return false;
|
|
@@ -2264,10 +2884,10 @@ var VaultService = class {
|
|
|
2264
2884
|
* What `principal` holds, their row checked first (`#integrity`): a row
|
|
2265
2885
|
* that fails holds nothing, and is `tampered`.
|
|
2266
2886
|
*/
|
|
2267
|
-
async #standing(db, principal, row, at, reports) {
|
|
2887
|
+
async #standing(db, principal, row, at, reports, reads) {
|
|
2268
2888
|
const none = NOTHING;
|
|
2269
|
-
const grants$1 = row === void 0 ? [] : await grants(db, principal);
|
|
2270
|
-
const fault = await this.#integrity(db, principal, row, grants$1, reports);
|
|
2889
|
+
const grants$1 = row === void 0 ? [] : reads?.grants.get(principal) ?? await grants(db, principal);
|
|
2890
|
+
const fault = await this.#integrity(db, principal, row, grants$1, reports, reads);
|
|
2271
2891
|
if (fault !== null) return {
|
|
2272
2892
|
principal,
|
|
2273
2893
|
status: "tampered",
|
|
@@ -2297,7 +2917,7 @@ var VaultService = class {
|
|
|
2297
2917
|
role: grant.role
|
|
2298
2918
|
}));
|
|
2299
2919
|
const { role, scope } = roleOf(principal, row);
|
|
2300
|
-
const { policy } = await this.#policyAt(db, reports, at);
|
|
2920
|
+
const { policy } = await this.#policyAt(db, reports, at, reads);
|
|
2301
2921
|
const service = !principal.startsWith("user:");
|
|
2302
2922
|
return {
|
|
2303
2923
|
principal,
|
|
@@ -2357,13 +2977,17 @@ var VaultService = class {
|
|
|
2357
2977
|
/** `access`, as the rows are now. */
|
|
2358
2978
|
async #read(principal, superseded) {
|
|
2359
2979
|
const read = async (db, reports) => {
|
|
2360
|
-
const
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
]
|
|
2365
|
-
|
|
2366
|
-
|
|
2980
|
+
const reads = await snapshot(db, {
|
|
2981
|
+
principals: [principal],
|
|
2982
|
+
members: true
|
|
2983
|
+
});
|
|
2984
|
+
const [row, held, at] = [
|
|
2985
|
+
reads.members.get(principal),
|
|
2986
|
+
reads.grants.get(principal),
|
|
2987
|
+
this.#at(reads)
|
|
2988
|
+
];
|
|
2989
|
+
const fault = await this.#integrity(db, principal, row, held, reports, reads);
|
|
2990
|
+
const { policy } = await this.#policyAt(db, reports, at, reads);
|
|
2367
2991
|
return this.#access(principal, row, held, at, fault !== null, policy);
|
|
2368
2992
|
};
|
|
2369
2993
|
const found = [];
|
|
@@ -2438,7 +3062,7 @@ var VaultService = class {
|
|
|
2438
3062
|
const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
|
|
2439
3063
|
if (Number.isNaN(expiresAt) || expiresAt !== null && expiresAt <= d.at) throw refused("invalid", "an end date must be in the future");
|
|
2440
3064
|
}
|
|
2441
|
-
const { policy } = await this.#policyAt(d.tx, d.reports, d.at);
|
|
3065
|
+
const { policy } = await this.#policyAt(d.tx, d.reports, d.at, d.reads);
|
|
2442
3066
|
for (const change of changes) {
|
|
2443
3067
|
if (change.role !== null && !isRole(change.role, policy)) throw refused("invalid", `no such role: ${change.role}`);
|
|
2444
3068
|
if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role, policy)) throw refused("invalid", `${change.role} can only be granted on a project`);
|
|
@@ -2454,13 +3078,13 @@ var VaultService = class {
|
|
|
2454
3078
|
environmentSlug: environment.slug
|
|
2455
3079
|
};
|
|
2456
3080
|
});
|
|
2457
|
-
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
3081
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
|
|
2458
3082
|
if (acting.status === "tampered") throw refused("tampered");
|
|
2459
3083
|
const manages = where.every((place) => mayManageAccess(acting.live, place));
|
|
2460
3084
|
if (!manages && !(SERVICE.test(principal) && PERSON.test(actor))) throw refused("not_allowed");
|
|
2461
3085
|
if (actor === principal && changes.some((change) => change.role !== null)) throw refused("not_allowed", "nobody grants themselves a role: ask another admin");
|
|
2462
3086
|
const row = d.members.get(principal);
|
|
2463
|
-
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
3087
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
|
|
2464
3088
|
if (subject.status === "tampered") throw refused("tampered", TAMPERED_SUBJECT);
|
|
2465
3089
|
const admitted = input.admit === true && row?.status !== "active" ? await this.#admitWith(d, acting, principal, row, input) : void 0;
|
|
2466
3090
|
if (admitted === void 0 && row?.status === "removed") throw refused("removed");
|
|
@@ -2560,7 +3184,7 @@ var VaultService = class {
|
|
|
2560
3184
|
const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [accessEntry(actor, "member.add", principal, "deny", input, asked, code)]);
|
|
2561
3185
|
return this.#decide([actor, principal], async (d) => {
|
|
2562
3186
|
validateCorrelation(input);
|
|
2563
|
-
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
3187
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
|
|
2564
3188
|
if (acting.status === "tampered") throw refused("tampered");
|
|
2565
3189
|
const service = SERVICE.test(principal) && (input.role ?? "member") === "member" && input.scope === void 0;
|
|
2566
3190
|
if (!runsInstance(acting.live)) await this.#setsUp(d, acting, service, refused);
|
|
@@ -2573,7 +3197,7 @@ var VaultService = class {
|
|
|
2573
3197
|
};
|
|
2574
3198
|
if (wanted !== void 0 && wanted.role !== "member" && !principal.startsWith("user:")) throw refused("invalid", "service accounts hold project grants only, never an instance role");
|
|
2575
3199
|
const row = d.members.get(principal);
|
|
2576
|
-
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
3200
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
|
|
2577
3201
|
if (subject.status === "tampered") throw refused("tampered", TAMPERED_SUBJECT);
|
|
2578
3202
|
d.touched.add(principal);
|
|
2579
3203
|
d.grants.set(principal, [...subject.stored]);
|
|
@@ -2674,10 +3298,10 @@ var VaultService = class {
|
|
|
2674
3298
|
validateCorrelation(input);
|
|
2675
3299
|
if (!PRINCIPAL.test(principal)) throw refused("invalid", `not a member: ${shownMember(principal)}`);
|
|
2676
3300
|
const row = d.members.get(principal);
|
|
2677
|
-
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
3301
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
|
|
2678
3302
|
if (acting.status === "tampered") throw refused("tampered");
|
|
2679
3303
|
const holder = acting.live;
|
|
2680
|
-
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
3304
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
|
|
2681
3305
|
const held = subject.stored;
|
|
2682
3306
|
if (this.#listed(principal)) throw refused("not_allowed", declaredOwner(principal));
|
|
2683
3307
|
if (subject.status === "tampered") {
|
|
@@ -2760,7 +3384,7 @@ var VaultService = class {
|
|
|
2760
3384
|
* the default: otherwise whoever can insert rows could open it again.
|
|
2761
3385
|
*/
|
|
2762
3386
|
async #settingsAt(db, reports) {
|
|
2763
|
-
const entry = await newestAuthentic((before) => newestVaultEntries(db, SETTINGS,
|
|
3387
|
+
const entry = await newestAuthentic((limit, before) => newestVaultEntries(db, SETTINGS, limit, before), this.#check(reports, null));
|
|
2764
3388
|
if (entry === "none") return DEFAULT_SETTINGS;
|
|
2765
3389
|
if (typeof entry === "string") return CLOSED_SETTINGS;
|
|
2766
3390
|
const { serviceAccounts } = JSON.parse(entry.metadata);
|
|
@@ -2781,20 +3405,21 @@ var VaultService = class {
|
|
|
2781
3405
|
* no role holds anything but Owner, otherwise whoever can insert rows
|
|
2782
3406
|
* could unprotect them. Its edits are then unknown, not none.
|
|
2783
3407
|
*/
|
|
2784
|
-
async #policyAt(db, reports, at) {
|
|
2785
|
-
const entry = await newestAuthentic((before) => newestVaultEntries(db, POLICY,
|
|
3408
|
+
async #policyAt(db, reports, at, reads) {
|
|
3409
|
+
const entry = await newestAuthentic((limit, before) => reads !== void 0 && before === void 0 ? Promise.resolve(reads.newestPolicy === null ? [] : [reads.newestPolicy]) : newestVaultEntries(db, POLICY, limit, before), this.#check(reports, null));
|
|
2786
3410
|
if (entry === "none") return {
|
|
2787
3411
|
policy: DEFAULT_POLICY,
|
|
2788
3412
|
edits: NO_EDITS,
|
|
2789
3413
|
readable: true
|
|
2790
3414
|
};
|
|
2791
3415
|
const edits = typeof entry === "string" ? null : readEdits(JSON.parse(entry.metadata));
|
|
2792
|
-
|
|
3416
|
+
if (edits === null) return {
|
|
2793
3417
|
policy: CLOSED_POLICY,
|
|
2794
3418
|
edits: NO_EDITS,
|
|
2795
3419
|
readable: false
|
|
2796
|
-
}
|
|
2797
|
-
|
|
3420
|
+
};
|
|
3421
|
+
return {
|
|
3422
|
+
policy: livePolicy(entry === reads?.newestPolicy ? reads.environments : await environmentsById(db, edits.protected.map((place) => place.environmentId)), edits, at),
|
|
2798
3423
|
edits,
|
|
2799
3424
|
readable: true
|
|
2800
3425
|
};
|
|
@@ -2819,11 +3444,11 @@ var VaultService = class {
|
|
|
2819
3444
|
const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [entry("deny", { change: describeChange(change) }, code)]);
|
|
2820
3445
|
return this.#decide([actor], async (d) => {
|
|
2821
3446
|
validateCorrelation(input);
|
|
2822
|
-
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
3447
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
|
|
2823
3448
|
if (acting.status === "tampered") throw refused("tampered");
|
|
2824
3449
|
if (acting.status !== "active") throw refused(acting.status === "removed" ? "removed" : "not_a_member");
|
|
2825
|
-
const newest =
|
|
2826
|
-
const { policy: before, edits, readable } = await this.#policyAt(d.tx, d.reports, d.at);
|
|
3450
|
+
const newest = d.reads.newestPolicy?.seq ?? null;
|
|
3451
|
+
const { policy: before, edits, readable } = await this.#policyAt(d.tx, d.reports, d.at, d.reads);
|
|
2827
3452
|
if (revision !== void 0 && policyRevision(newest, before) !== revision) throw refused("policy_changed");
|
|
2828
3453
|
const owner = acting.live.credentialScope == null && ownsInstance(acting.live);
|
|
2829
3454
|
if (!readable && !owner) throw refused("not_allowed", "the vault cannot read its own policy: only an Owner of the whole instance writes it again, from the roles as they ship");
|
|
@@ -2879,7 +3504,7 @@ var VaultService = class {
|
|
|
2879
3504
|
}
|
|
2880
3505
|
} else throw refused("invalid", "a change is to a role, an instance role, or protection");
|
|
2881
3506
|
const answer = {
|
|
2882
|
-
policy: await
|
|
3507
|
+
policy: livePolicy(await environmentsById(d.tx, next.protected.map((place) => place.environmentId)), next, d.at),
|
|
2883
3508
|
seq: newest === null ? null : Number(newest)
|
|
2884
3509
|
};
|
|
2885
3510
|
if (!readable || JSON.stringify(next) !== JSON.stringify(edits)) {
|
|
@@ -2911,7 +3536,7 @@ var VaultService = class {
|
|
|
2911
3536
|
const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [entry("deny", { serviceAccounts: input.settings?.serviceAccounts ?? null }, code)]);
|
|
2912
3537
|
return this.#decide([actor], async (d) => {
|
|
2913
3538
|
validateCorrelation(input);
|
|
2914
|
-
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
3539
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
|
|
2915
3540
|
if (acting.status === "tampered") throw refused("tampered");
|
|
2916
3541
|
if (!runsInstance(acting.live)) throw refused("not_allowed", "only admins and owners of the whole instance change its settings");
|
|
2917
3542
|
if (!isScope(input.settings?.serviceAccounts)) throw refused("invalid", "who sets up service accounts is a scope: { projects, environments }, each all, only or except");
|
|
@@ -3077,63 +3702,162 @@ var VaultService = class {
|
|
|
3077
3702
|
}
|
|
3078
3703
|
return checks;
|
|
3079
3704
|
}
|
|
3080
|
-
/**
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3705
|
+
/**
|
|
3706
|
+
* Where a walk of the log resumes: the first entry (`start`), a
|
|
3707
|
+
* checkpoint's entry that keeps a tally, or the newest such entry, the
|
|
3708
|
+
* first entry while there is none. The entry must be the vault's, by its
|
|
3709
|
+
* MAC, and its tally whole: the members and grants it keeps, or the
|
|
3710
|
+
* earlier checkpoint it names keeps, match the digest it carries.
|
|
3711
|
+
*
|
|
3712
|
+
* Its signature is left to the walk: the entry comes after the prefix it
|
|
3713
|
+
* signs, so a walk from there reads it, as it reads every other.
|
|
3714
|
+
*/
|
|
3715
|
+
async #resume(db, from) {
|
|
3716
|
+
if (from === "start") return FIRST_ENTRY;
|
|
3717
|
+
const entry = from === "newest" ? await latestTallied(db) : (await entriesAt(db, [from])).get(from);
|
|
3718
|
+
if (entry === void 0) return from === "newest" ? FIRST_ENTRY : {
|
|
3719
|
+
ok: false,
|
|
3720
|
+
failedAtSeq: Number(from),
|
|
3721
|
+
reason: "no checkpoint at this entry"
|
|
3087
3722
|
};
|
|
3723
|
+
return (await this.#resumeEach(db, [entry]))[0];
|
|
3724
|
+
}
|
|
3725
|
+
/** `#resume` for each of these checkpoint entries, the entries their members and grants are kept in read at once. */
|
|
3726
|
+
async #resumeEach(db, entries) {
|
|
3727
|
+
const reads = entries.map((entry) => this.#tallied(entry));
|
|
3728
|
+
const named = reads.flatMap((read) => typeof read !== "string" && "at" in read.tally.access ? [BigInt(read.tally.access.at)] : []);
|
|
3729
|
+
const carriers = new Map([...await entriesAt(db, named)].map(([seq, held]) => [seq, this.#tallied(held)]));
|
|
3730
|
+
return entries.map((entry, i) => {
|
|
3731
|
+
const broken = (reason) => ({
|
|
3732
|
+
ok: false,
|
|
3733
|
+
failedAtSeq: Number(entry.seq),
|
|
3734
|
+
reason
|
|
3735
|
+
});
|
|
3736
|
+
const read = reads[i];
|
|
3737
|
+
if (typeof read === "string") return broken(read);
|
|
3738
|
+
let carrier = entry.seq;
|
|
3739
|
+
let { access } = read.tally;
|
|
3740
|
+
if ("at" in access) {
|
|
3741
|
+
carrier = BigInt(access.at);
|
|
3742
|
+
const holder = carrier < entry.seq ? carriers.get(carrier) : void 0;
|
|
3743
|
+
if (holder === void 0 || typeof holder === "string" || "at" in holder.tally.access) return broken(`its tally keeps its members and grants in entry ${carrier}, which holds none`);
|
|
3744
|
+
access = holder.tally.access;
|
|
3745
|
+
}
|
|
3746
|
+
const kept = {
|
|
3747
|
+
members: access.members,
|
|
3748
|
+
grants: access.grants,
|
|
3749
|
+
newest: access.newest
|
|
3750
|
+
};
|
|
3751
|
+
if (keptDigest(access) !== read.tally.access.digest) return broken("its tally's members and grants do not match their digest");
|
|
3752
|
+
return {
|
|
3753
|
+
ok: true,
|
|
3754
|
+
entry: entry.seq,
|
|
3755
|
+
at: {
|
|
3756
|
+
seq: BigInt(read.seq),
|
|
3757
|
+
hash: Buffer.from(read.hash, "hex")
|
|
3758
|
+
},
|
|
3759
|
+
tally: {
|
|
3760
|
+
...read.tally,
|
|
3761
|
+
access: kept
|
|
3762
|
+
},
|
|
3763
|
+
digest: read.tally.access.digest,
|
|
3764
|
+
carrier,
|
|
3765
|
+
scrub: read.scrub === null ? null : {
|
|
3766
|
+
at: positionOf(read.scrub.at),
|
|
3767
|
+
end: positionOf(read.scrub.end)
|
|
3768
|
+
}
|
|
3769
|
+
};
|
|
3770
|
+
});
|
|
3771
|
+
}
|
|
3772
|
+
/** A checkpoint's entry that keeps a tally, as it says, or why it is not one the vault wrote. */
|
|
3773
|
+
#tallied(entry) {
|
|
3774
|
+
const not = "not a checkpoint of the vault's that keeps a tally";
|
|
3775
|
+
if (entry.author !== "vault" || entry.action !== "audit.checkpoint" || entry.decision !== "allow" || entry.relatedSeq === null) return not;
|
|
3776
|
+
const authentic = verifyEntries([entry], {
|
|
3777
|
+
startSeq: entry.seq,
|
|
3778
|
+
startPrevHash: entry.prevHash,
|
|
3779
|
+
keys: this.#keysFor(entry)
|
|
3780
|
+
});
|
|
3781
|
+
if (!authentic.ok) return withCause(authentic.reason);
|
|
3782
|
+
const read = metadataOf(entry);
|
|
3783
|
+
if (read?.tally?.v !== 1 || read.scrub === void 0 || typeof read.seq !== "number" || BigInt(read.seq) !== entry.relatedSeq) return not;
|
|
3784
|
+
return read;
|
|
3785
|
+
}
|
|
3786
|
+
/** Which of `heads` the log still holds where they were, in one read. */
|
|
3787
|
+
async #holding(db, heads) {
|
|
3788
|
+
const hashes = await hashesAt(db, [...new Set(heads.filter((head) => head.seq >= 0).map((head) => BigInt(head.seq)))]);
|
|
3789
|
+
return (head) => head.hash === GENESIS_HASH.toString("hex") || hashes.get(BigInt(head.seq))?.toString("hex") === head.hash;
|
|
3088
3790
|
}
|
|
3089
3791
|
/**
|
|
3090
|
-
*
|
|
3091
|
-
* its first entry at the first checkpoint of each hour, and otherwise from
|
|
3092
|
-
* the prefix the last checkpoint signed, if that one was signed this hour.
|
|
3792
|
+
* Sign the log up to its last entry, from the newest checkpoint's tally.
|
|
3093
3793
|
*
|
|
3094
|
-
*
|
|
3095
|
-
*
|
|
3096
|
-
*
|
|
3097
|
-
*
|
|
3098
|
-
*
|
|
3794
|
+
* In one snapshot, and without the log's lock, so that no append waits on
|
|
3795
|
+
* it: every member's row checked, since rows changed around the vault
|
|
3796
|
+
* write nothing to the log; the prefix the newest checkpoint signed still
|
|
3797
|
+
* there, its hash where it was, which a rewrite chained again changes, and
|
|
3798
|
+
* every entry of it, which a cut changes; the chain recomputed from there,
|
|
3799
|
+
* every hash from content and every vault entry by its MAC, no further
|
|
3800
|
+
* than one walk (`walks.checkpoint`); and a stretch of the prefixes signed
|
|
3801
|
+
* before rehashed (`#scrub`). Then, under the lock, the entries appended
|
|
3802
|
+
* since the snapshot, so that it signs the head.
|
|
3099
3803
|
*
|
|
3100
|
-
*
|
|
3101
|
-
*
|
|
3102
|
-
*
|
|
3103
|
-
*
|
|
3104
|
-
* prefix is, which a cut changes. Either failing, it recomputes from entry
|
|
3105
|
-
* 0, which names what broke. An entry edited in place, its hash left as
|
|
3106
|
-
* it was, is the one change left to the next full pass, within the hour.
|
|
3107
|
-
*
|
|
3108
|
-
* It resumes only from a checkpoint under the key the vault writes with
|
|
3109
|
-
* now: once the log reaches that key, no other writes again (`forward`).
|
|
3110
|
-
* After a rotation, the next pass is full.
|
|
3804
|
+
* A walk cut short signs where it got to, behind the head: the log after
|
|
3805
|
+
* an upgrade from 0.10.0, whose checkpoints kept no tally, is signed again
|
|
3806
|
+
* from the first entry, one walk a call, and `/readyz` stays red until the
|
|
3807
|
+
* checkpoints reach the newest heartbeat.
|
|
3111
3808
|
*/
|
|
3112
|
-
async #recompute(tx) {
|
|
3113
|
-
const { logKeys } = this.#prepared;
|
|
3114
|
-
const latest = await this.#latest(tx);
|
|
3115
|
-
const hour = (ms) => Math.floor(ms / FULL_RECOMPUTE_MS);
|
|
3116
|
-
if (!(latest !== null && latest.keyId === logKeys[0].keyId && latest.seq === BigInt(latest.checkpoint.seq) + 1n && hour(Date.parse(latest.checkpoint.signedAt)) === hour(await this.#now(tx)) && await carries(tx, latest.checkpoint) && await complete(tx, BigInt(latest.checkpoint.seq)))) return verifyChain(tx, logKeys, [], UNVERIFIED);
|
|
3117
|
-
const { seq, hash } = latest.checkpoint;
|
|
3118
|
-
return verifyChain(tx, logKeys, [], {
|
|
3119
|
-
nextSeq: BigInt(seq) + 1n,
|
|
3120
|
-
hash: Buffer.from(hash, "hex"),
|
|
3121
|
-
vaultEntries: 0,
|
|
3122
|
-
vaultKeys: [logKeys[0].keyId]
|
|
3123
|
-
});
|
|
3124
|
-
}
|
|
3125
3809
|
async checkpoint() {
|
|
3126
3810
|
await this.#settled();
|
|
3127
|
-
const { wrongKek } = this.#prepared;
|
|
3811
|
+
const { wrongKek, logKeys } = this.#prepared;
|
|
3812
|
+
const { walks } = this.#prepared.options;
|
|
3128
3813
|
const found = [];
|
|
3129
|
-
|
|
3814
|
+
let seen = null;
|
|
3815
|
+
const walked = await this.#db.transaction(async (tx) => {
|
|
3816
|
+
seen = (await newestCheckpointEntry(tx))?.seq ?? null;
|
|
3130
3817
|
await this.#sweep(tx, found);
|
|
3131
|
-
|
|
3818
|
+
const from = await this.#resume(tx, "newest");
|
|
3819
|
+
if (!from.ok) return from;
|
|
3820
|
+
const signed = headOf(from.at);
|
|
3821
|
+
if (!(await this.#holding(tx, [signed]))(signed)) return {
|
|
3822
|
+
ok: false,
|
|
3823
|
+
failedAtSeq: null,
|
|
3824
|
+
reason: `the log up to entry ${signed.seq} is not the prefix the last checkpoint signed`
|
|
3825
|
+
};
|
|
3826
|
+
const gap = await missingEntry(tx, from.at.seq);
|
|
3827
|
+
if (gap !== null) return {
|
|
3828
|
+
ok: false,
|
|
3829
|
+
failedAtSeq: Number(gap.found),
|
|
3830
|
+
reason: `sequence gap: expected seq ${gap.expected}, found ${gap.found}`
|
|
3831
|
+
};
|
|
3832
|
+
const walk = new Walk(from.tally, from.digest);
|
|
3833
|
+
const chain = await rehash(tx, logKeys, from.at, walk, { limit: walks.checkpoint });
|
|
3834
|
+
if (!chain.ok) return chain;
|
|
3835
|
+
const unsigned = await this.#checkpointFault(tx, walk.checkpoints);
|
|
3836
|
+
if (unsigned !== null) return unsigned;
|
|
3837
|
+
return {
|
|
3838
|
+
ok: true,
|
|
3839
|
+
from,
|
|
3840
|
+
tally: walk.tally(),
|
|
3841
|
+
digest: walk.digest(),
|
|
3842
|
+
at: chain.at,
|
|
3843
|
+
done: chain.done
|
|
3844
|
+
};
|
|
3132
3845
|
}, SNAPSHOT);
|
|
3846
|
+
let scrub = {
|
|
3847
|
+
ok: true,
|
|
3848
|
+
next: walked.ok ? walked.from.scrub : null
|
|
3849
|
+
};
|
|
3850
|
+
let stalled = null;
|
|
3851
|
+
if (walked.ok) try {
|
|
3852
|
+
scrub = await this.#db.transaction((tx) => this.#scrub(tx, walked.from), SNAPSHOT);
|
|
3853
|
+
} catch (error) {
|
|
3854
|
+
if (!isUnreachable(error) && !timedOut(error)) throw error;
|
|
3855
|
+
stalled = `the rehash of the past did not run, and goes on where it was next time: ${error.message}`;
|
|
3856
|
+
}
|
|
3133
3857
|
return this.#decide([], async (d) => {
|
|
3134
3858
|
for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
|
|
3135
3859
|
const head = await lockLogHead(d.tx);
|
|
3136
|
-
const latest = await
|
|
3860
|
+
const latest = await latestVaultEntry(d.tx, [CHECKPOINT]);
|
|
3137
3861
|
const refused = (code, detail, message = MESSAGES[code]) => new Refused(refusal(code, message), [{
|
|
3138
3862
|
actor: SCHEDULER,
|
|
3139
3863
|
action: CHECKPOINT,
|
|
@@ -3143,21 +3867,57 @@ var VaultService = class {
|
|
|
3143
3867
|
}]);
|
|
3144
3868
|
if (head.nextSeq === 0n) throw new Refused(refusal("invalid", "the log is empty"), []);
|
|
3145
3869
|
if (wrongKek !== null) throw refused("wrong_kek", { reason: wrongKek }, wrongKek);
|
|
3146
|
-
if (!
|
|
3147
|
-
failedAtSeq:
|
|
3148
|
-
reason:
|
|
3870
|
+
if (!walked.ok) throw refused("log_broken", {
|
|
3871
|
+
failedAtSeq: walked.failedAtSeq,
|
|
3872
|
+
reason: walked.reason
|
|
3149
3873
|
});
|
|
3150
|
-
if (
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
if (!held.ok) throw refused("log_broken", {
|
|
3154
|
-
failedAtSeq: held.failedAtSeq,
|
|
3155
|
-
reason: held.reason
|
|
3874
|
+
if (!scrub.ok) throw refused("log_broken", {
|
|
3875
|
+
failedAtSeq: scrub.failedAtSeq,
|
|
3876
|
+
reason: scrub.reason
|
|
3156
3877
|
});
|
|
3157
|
-
|
|
3878
|
+
const newest = latest === void 0 ? null : JSON.parse(latest.metadata);
|
|
3879
|
+
if (stalled !== null || ((await newestCheckpointEntry(d.tx))?.seq ?? null) !== seen) {
|
|
3880
|
+
const standing = await this.#standingRefusal(d.tx);
|
|
3881
|
+
if (standing !== null) throw refused("log_broken", standing);
|
|
3882
|
+
if (stalled === null && newest !== null) return { checkpoint: signedPart(newest) };
|
|
3883
|
+
}
|
|
3884
|
+
if (newest !== null && latest.seq === head.nextSeq - 1n && latest.relatedSeq === latest.seq - 1n) return { checkpoint: signedPart(newest) };
|
|
3885
|
+
if (newest !== null && !await carries(d.tx, newest)) throw refused("log_broken", { reason: `the log up to entry ${newest.seq} is not the prefix the last checkpoint signed` });
|
|
3886
|
+
const walk = new Walk(walked.tally, walked.digest);
|
|
3887
|
+
let { at } = walked;
|
|
3888
|
+
if (walked.done) {
|
|
3889
|
+
if (!await carries(d.tx, headOf(at))) throw refused("log_broken", {
|
|
3890
|
+
failedAtSeq: Number(at.seq),
|
|
3891
|
+
reason: `the log up to entry ${at.seq} changed while the vault walked it`
|
|
3892
|
+
});
|
|
3893
|
+
const rest = await rehash(d.tx, logKeys, at, walk, {});
|
|
3894
|
+
if (!rest.ok) throw refused("log_broken", {
|
|
3895
|
+
failedAtSeq: rest.failedAtSeq,
|
|
3896
|
+
reason: rest.reason
|
|
3897
|
+
});
|
|
3898
|
+
const unsigned = await this.#checkpointFault(d.tx, walk.checkpoints);
|
|
3899
|
+
if (unsigned !== null) throw refused("log_broken", {
|
|
3900
|
+
failedAtSeq: unsigned.failedAtSeq,
|
|
3901
|
+
reason: unsigned.reason
|
|
3902
|
+
});
|
|
3903
|
+
at = rest.at;
|
|
3904
|
+
if (at.seq !== head.nextSeq - 1n || !at.hash.equals(head.headHash)) throw refused("log_broken", { reason: "the chain head does not name the last entry" });
|
|
3905
|
+
} else if (!await carries(d.tx, headOf(at))) throw refused("log_broken", { reason: `the log up to entry ${at.seq} changed while the vault walked it` });
|
|
3906
|
+
if (newest !== null && latest.relatedSeq !== null && latest.relatedSeq >= at.seq) return { checkpoint: signedPart(newest) };
|
|
3907
|
+
this.#prepared.verified = further(this.#prepared.verified, at);
|
|
3908
|
+
const tally = walk.tally();
|
|
3909
|
+
const digest = walk.digest();
|
|
3910
|
+
const { from } = walked;
|
|
3911
|
+
const access = digest === from.digest && from.carrier !== null ? {
|
|
3912
|
+
digest,
|
|
3913
|
+
at: Number(from.carrier)
|
|
3914
|
+
} : {
|
|
3915
|
+
digest,
|
|
3916
|
+
...tally.access
|
|
3917
|
+
};
|
|
3158
3918
|
const signed = {
|
|
3159
|
-
seq: Number(
|
|
3160
|
-
hash:
|
|
3919
|
+
seq: Number(at.seq),
|
|
3920
|
+
hash: at.hash.toString("hex"),
|
|
3161
3921
|
signedAt: iso(d.at)
|
|
3162
3922
|
};
|
|
3163
3923
|
const checkpoint = {
|
|
@@ -3165,15 +3925,85 @@ var VaultService = class {
|
|
|
3165
3925
|
keyId: this.#prepared.signer.keyId,
|
|
3166
3926
|
signature: await this.#prepared.signer.sign(checkpointMessage(signed))
|
|
3167
3927
|
};
|
|
3928
|
+
const lap = scrub.next === null ? null : {
|
|
3929
|
+
at: headOf(scrub.next.at),
|
|
3930
|
+
end: headOf(scrub.next.end)
|
|
3931
|
+
};
|
|
3932
|
+
const kept = {
|
|
3933
|
+
...checkpoint,
|
|
3934
|
+
tally: {
|
|
3935
|
+
v: 1,
|
|
3936
|
+
...tally,
|
|
3937
|
+
access
|
|
3938
|
+
},
|
|
3939
|
+
scrub: lap
|
|
3940
|
+
};
|
|
3168
3941
|
d.log.push({
|
|
3169
3942
|
actor: SCHEDULER,
|
|
3170
3943
|
action: CHECKPOINT,
|
|
3171
3944
|
decision: "allow",
|
|
3172
|
-
|
|
3945
|
+
relatedSeq: at.seq,
|
|
3946
|
+
metadata: JSON.stringify(kept)
|
|
3173
3947
|
});
|
|
3174
|
-
return { checkpoint }
|
|
3948
|
+
return stalled === null ? { checkpoint } : {
|
|
3949
|
+
checkpoint,
|
|
3950
|
+
warning: stalled
|
|
3951
|
+
};
|
|
3175
3952
|
});
|
|
3176
3953
|
}
|
|
3954
|
+
/**
|
|
3955
|
+
* The prefixes signed before, rehashed a stretch at a time, in laps: a lap
|
|
3956
|
+
* runs from the first entry to the prefix the newest checkpoint signed
|
|
3957
|
+
* when it began, which it keeps (`end`), `walks.scrub` entries a call, each
|
|
3958
|
+
* hash recomputed from its entry and each link checked (`relink`); the
|
|
3959
|
+
* next lap runs to the prefix signed then. A checkpoint resumes from the
|
|
3960
|
+
* newest one's tally and never reads what it signed again, and an entry
|
|
3961
|
+
* before it edited in place, its stored hash left as it was, keeps the
|
|
3962
|
+
* chain: this is what finds it, within two laps at most, each the length
|
|
3963
|
+
* of the log when it began over `walks.scrub` beats, however fast the log
|
|
3964
|
+
* grows meanwhile. The lap as it goes on, null between laps.
|
|
3965
|
+
*/
|
|
3966
|
+
async #scrub(db, newest) {
|
|
3967
|
+
const lap = newest.scrub ?? {
|
|
3968
|
+
at: START,
|
|
3969
|
+
end: newest.at
|
|
3970
|
+
};
|
|
3971
|
+
if (lap.end.seq < 0n) return {
|
|
3972
|
+
ok: true,
|
|
3973
|
+
next: null
|
|
3974
|
+
};
|
|
3975
|
+
const relinked = await relink(db, lap.at, lap.end.seq, this.#prepared.options.walks.scrub);
|
|
3976
|
+
if (!relinked.ok) return relinked;
|
|
3977
|
+
if (relinked.at.seq < lap.end.seq) return {
|
|
3978
|
+
ok: true,
|
|
3979
|
+
next: {
|
|
3980
|
+
at: relinked.at,
|
|
3981
|
+
end: lap.end
|
|
3982
|
+
}
|
|
3983
|
+
};
|
|
3984
|
+
if (!relinked.at.hash.equals(lap.end.hash)) return {
|
|
3985
|
+
ok: false,
|
|
3986
|
+
failedAtSeq: Number(lap.end.seq),
|
|
3987
|
+
reason: `the log up to entry ${lap.end.seq} is not the prefix a checkpoint signed`
|
|
3988
|
+
};
|
|
3989
|
+
return {
|
|
3990
|
+
ok: true,
|
|
3991
|
+
next: null
|
|
3992
|
+
};
|
|
3993
|
+
}
|
|
3994
|
+
/**
|
|
3995
|
+
* The newest refusal for a broken log since the newest checkpoint was
|
|
3996
|
+
* signed, by the vault's MAC, as it said: it stands, for every
|
|
3997
|
+
* verification and checkpoint, until a checkpoint is signed, which takes
|
|
3998
|
+
* the check that found it running again and passing. A refusal of
|
|
3999
|
+
* another kind since, a wrong KEK, hides none.
|
|
4000
|
+
*/
|
|
4001
|
+
async #standingRefusal(db) {
|
|
4002
|
+
const signed = await latestVaultEntry(db, [CHECKPOINT]);
|
|
4003
|
+
const refused = await newestRefusal(db, CHECKPOINT, "log_broken", signed?.seq ?? -1n);
|
|
4004
|
+
if (refused === void 0 || !this.#authentic(refused)) return null;
|
|
4005
|
+
return JSON.parse(refused.metadata);
|
|
4006
|
+
}
|
|
3177
4007
|
async about() {
|
|
3178
4008
|
await this.#settled();
|
|
3179
4009
|
const { signers, since } = this.#prepared;
|
|
@@ -3202,65 +4032,181 @@ var VaultService = class {
|
|
|
3202
4032
|
checks
|
|
3203
4033
|
};
|
|
3204
4034
|
}
|
|
3205
|
-
verifyLog(input) {
|
|
3206
|
-
return this.#verifyAll(input.upTo ?? null);
|
|
3207
|
-
}
|
|
3208
4035
|
/**
|
|
3209
|
-
* The
|
|
3210
|
-
* each by its MAC, the keys it moved through, and what the
|
|
3211
|
-
* need of it
|
|
3212
|
-
* heads that must still be there,
|
|
3213
|
-
* members and grants
|
|
4036
|
+
* The log from a checkpoint's tally, in one snapshot: the vault's
|
|
4037
|
+
* entries since, each by its MAC, the keys it moved through, and what the
|
|
4038
|
+
* other checks need of it (`Walk`); then every checkpoint walked, the
|
|
4039
|
+
* heads that must still be there, and, for a walk that ends at the
|
|
4040
|
+
* present, the key batches accounted for and the members and grants
|
|
4041
|
+
* replayed against their rows.
|
|
3214
4042
|
*
|
|
3215
4043
|
* With `upTo`, the head the app verified to, the links and hashes of the
|
|
3216
|
-
* chain are the app's to recompute, as it just has, over its own
|
|
3217
|
-
* through that head; `
|
|
3218
|
-
* the vault reads only its own entries, each checked against its
|
|
3219
|
-
* and MAC over the link it names. Without `upTo`, nobody else
|
|
3220
|
-
* the chain: the vault reads it all, and rehashes every link
|
|
4044
|
+
* chain are the app's to recompute, as it just has, over its own
|
|
4045
|
+
* snapshot, through that head; holding `upTo` ties this snapshot to that
|
|
4046
|
+
* one. So the vault reads only its own entries, each checked against its
|
|
4047
|
+
* own hash and MAC over the link it names. Without `upTo`, nobody else
|
|
4048
|
+
* recomputed the chain: the vault reads it all, and rehashes every link,
|
|
4049
|
+
* no further than one walk (`walks.verify`).
|
|
3221
4050
|
*/
|
|
3222
|
-
async
|
|
4051
|
+
async verifyLog(input) {
|
|
3223
4052
|
await this.#settled();
|
|
4053
|
+
const upTo = input.upTo ?? null;
|
|
4054
|
+
const { logKeys } = this.#prepared;
|
|
4055
|
+
const { walks } = this.#prepared.options;
|
|
3224
4056
|
return this.#db.transaction(async (tx) => {
|
|
3225
4057
|
const remembered = this.#prepared.verified;
|
|
3226
|
-
const
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
const
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
4058
|
+
const from = await this.#resume(tx, input.from === void 0 ? "newest" : input.from === "start" ? "start" : BigInt(input.from));
|
|
4059
|
+
if (!from.ok) return from;
|
|
4060
|
+
const to = input.to === void 0 ? null : BigInt(input.to);
|
|
4061
|
+
const ends = to === null ? null : (await entriesAt(tx, [to])).get(to)?.relatedSeq ?? -1n;
|
|
4062
|
+
if (ends !== null && (ends <= from.at.seq || upTo !== null && BigInt(upTo.seq) !== ends)) return {
|
|
4063
|
+
ok: false,
|
|
4064
|
+
failedAtSeq: Number(to),
|
|
4065
|
+
reason: "not a checkpoint after the one the walk starts from, whose prefix ends where the app verified to"
|
|
4066
|
+
};
|
|
4067
|
+
if (!(await this.#holding(tx, [headOf(from.at)]))(headOf(from.at))) return {
|
|
4068
|
+
ok: false,
|
|
4069
|
+
failedAtSeq: Number(from.entry),
|
|
4070
|
+
reason: `the log up to entry ${from.at.seq} is not the prefix this checkpoint signed: it was rewritten`
|
|
4071
|
+
};
|
|
4072
|
+
const gap = await missingEntry(tx, from.at.seq);
|
|
4073
|
+
if (gap !== null) return {
|
|
4074
|
+
ok: false,
|
|
4075
|
+
failedAtSeq: Number(gap.found),
|
|
4076
|
+
reason: `sequence gap: expected seq ${gap.expected}, found ${gap.found}`
|
|
4077
|
+
};
|
|
4078
|
+
const walk = new Walk(from.tally, from.digest);
|
|
4079
|
+
const passed = (await talliedAfter(tx, from.entry ?? -1n, walks.verify)).filter(({ seq }) => to === null || seq <= to);
|
|
4080
|
+
if (to !== null && passed.at(-1)?.seq !== to) return {
|
|
4081
|
+
ok: false,
|
|
4082
|
+
failedAtSeq: Number(to),
|
|
4083
|
+
reason: `a stretch passes no more than ${walks.verify} checkpoints: end one sooner`
|
|
4084
|
+
};
|
|
4085
|
+
const holding = /* @__PURE__ */ new Map();
|
|
4086
|
+
const carried = async (carrier) => {
|
|
4087
|
+
if (!holding.has(carrier)) {
|
|
4088
|
+
const entry = (await entriesAt(tx, [carrier])).get(carrier);
|
|
4089
|
+
const read = entry === void 0 ? "missing" : this.#tallied(entry);
|
|
4090
|
+
holding.set(carrier, typeof read === "string" || "at" in read.tally.access ? null : keptDigest(read.tally.access));
|
|
4091
|
+
}
|
|
4092
|
+
return holding.get(carrier);
|
|
4093
|
+
};
|
|
4094
|
+
const expected = [];
|
|
4095
|
+
let endSigned = null;
|
|
4096
|
+
for (let after = from.entry ?? -1n, last = passed.at(-1)?.seq; last !== void 0 && after < last;) {
|
|
4097
|
+
const batch = await talliedWithin(tx, after, last, walks.verify + 1);
|
|
4098
|
+
if (batch.length === 0) break;
|
|
4099
|
+
for (const entry of batch) {
|
|
4100
|
+
const read = this.#tallied(entry);
|
|
4101
|
+
const point = {
|
|
4102
|
+
entry: entry.seq,
|
|
4103
|
+
through: entry.relatedSeq
|
|
4104
|
+
};
|
|
4105
|
+
if (typeof read === "string") {
|
|
4106
|
+
expected.push({
|
|
4107
|
+
...point,
|
|
4108
|
+
unreadable: read
|
|
4109
|
+
});
|
|
4110
|
+
continue;
|
|
4111
|
+
}
|
|
4112
|
+
if (entry.seq === to) endSigned = read.hash;
|
|
4113
|
+
const { access } = read.tally;
|
|
4114
|
+
if (!("at" in access)) holding.set(entry.seq, keptDigest(access));
|
|
4115
|
+
const kept = "at" in access ? BigInt(access.at) < entry.seq ? await carried(BigInt(access.at)) : null : holding.get(entry.seq);
|
|
4116
|
+
expected.push(kept === access.digest ? {
|
|
4117
|
+
...point,
|
|
4118
|
+
key: expectedKey(read.tally)
|
|
4119
|
+
} : {
|
|
4120
|
+
...point,
|
|
4121
|
+
unreadable: kept === null ? `its tally keeps its members and grants in entry ${"at" in access ? access.at : entry.seq}, which holds none` : "its tally's members and grants do not match their digest"
|
|
4122
|
+
});
|
|
4123
|
+
}
|
|
4124
|
+
after = batch.at(-1).seq;
|
|
4125
|
+
}
|
|
4126
|
+
walk.expect(expected);
|
|
4127
|
+
let through = from.at.seq;
|
|
4128
|
+
let reached = headOf(from.at).hash;
|
|
4129
|
+
let present = false;
|
|
4130
|
+
if (upTo === null) {
|
|
4131
|
+
const chain = await rehash(tx, logKeys, from.at, walk, ends === null ? { limit: walks.verify } : { to: ends });
|
|
4132
|
+
if (!chain.ok) return chain;
|
|
4133
|
+
[through, reached, present] = [
|
|
4134
|
+
chain.at.seq,
|
|
4135
|
+
headOf(chain.at).hash,
|
|
4136
|
+
chain.done && ends === null
|
|
4137
|
+
];
|
|
4138
|
+
this.#prepared.verified = further(this.#prepared.verified, chain.at);
|
|
4139
|
+
} else {
|
|
4140
|
+
const own = await ownEntries(tx, logKeys, from.at.seq, walk, ends === null ? { limit: walks.verify } : { to: ends });
|
|
4141
|
+
if (!own.ok) return own;
|
|
4142
|
+
if (!own.done && input.from === void 0) return {
|
|
4143
|
+
ok: false,
|
|
4144
|
+
failedAtSeq: null,
|
|
4145
|
+
reason: `the vault verifies no more than ${walks.verify} of its entries a call, and its checkpoints are catching up: update the app`
|
|
4146
|
+
};
|
|
4147
|
+
const short = own.at < BigInt(upTo.seq) ? own.at : BigInt(upTo.seq) - 1n;
|
|
4148
|
+
[through, reached, present] = [
|
|
4149
|
+
own.done ? BigInt(upTo.seq) : short,
|
|
4150
|
+
upTo.hash,
|
|
4151
|
+
own.done && ends === null
|
|
4152
|
+
];
|
|
4153
|
+
}
|
|
4154
|
+
walk.finish(ends ?? through);
|
|
4155
|
+
const rememberedHead = remembered.seq >= 0n ? headOf(remembered) : null;
|
|
4156
|
+
const heads = [...upTo === null ? [] : [upTo], ...rememberedHead === null ? [] : [rememberedHead]];
|
|
4157
|
+
const holds = await this.#holding(tx, [...heads, ...walk.checkpoints.map(({ checkpoint }) => checkpoint)]);
|
|
3237
4158
|
if (upTo !== null && !holds(upTo)) return {
|
|
3238
4159
|
ok: false,
|
|
3239
4160
|
failedAtSeq: upTo.seq,
|
|
3240
4161
|
reason: "not the entry the app verified up to: the log changed between the two checks"
|
|
3241
4162
|
};
|
|
3242
|
-
const unsigned = await this.#checkpointFault(
|
|
4163
|
+
const unsigned = await this.#checkpointFault(tx, walk.checkpoints, holds);
|
|
3243
4164
|
if (unsigned !== null) return unsigned;
|
|
3244
4165
|
if (rememberedHead !== null && !holds(rememberedHead)) return {
|
|
3245
4166
|
ok: false,
|
|
3246
4167
|
failedAtSeq: rememberedHead.seq,
|
|
3247
4168
|
reason: "changed since the vault last verified it"
|
|
3248
4169
|
};
|
|
4170
|
+
if (walk.mismatch !== null) return {
|
|
4171
|
+
ok: false,
|
|
4172
|
+
failedAtSeq: Number(walk.mismatch.entry),
|
|
4173
|
+
reason: walk.mismatch.reason
|
|
4174
|
+
};
|
|
4175
|
+
if (ends !== null) {
|
|
4176
|
+
if (upTo === null && through !== ends || endSigned !== reached) return {
|
|
4177
|
+
ok: false,
|
|
4178
|
+
failedAtSeq: Number(to),
|
|
4179
|
+
reason: `the log up to entry ${ends} is not the prefix this checkpoint signed: it was rewritten`
|
|
4180
|
+
};
|
|
4181
|
+
return {
|
|
4182
|
+
ok: true,
|
|
4183
|
+
entries: walk.entries,
|
|
4184
|
+
through: Number(ends)
|
|
4185
|
+
};
|
|
4186
|
+
}
|
|
4187
|
+
const standing = await this.#standingRefusal(tx);
|
|
4188
|
+
if (standing !== null) return {
|
|
4189
|
+
ok: false,
|
|
4190
|
+
failedAtSeq: standing.failedAtSeq ?? null,
|
|
4191
|
+
reason: `the last checkpoint was refused: ${standing.reason ?? "the log is broken"}`
|
|
4192
|
+
};
|
|
4193
|
+
const verified = {
|
|
4194
|
+
ok: true,
|
|
4195
|
+
entries: walk.entries,
|
|
4196
|
+
through: Number(through)
|
|
4197
|
+
};
|
|
4198
|
+
if (!present) return verified;
|
|
3249
4199
|
const at = await this.#now(tx);
|
|
3250
|
-
const accounting =
|
|
4200
|
+
const accounting = walk.accounting.result(at);
|
|
3251
4201
|
if (!accounting.ok) return accounting;
|
|
3252
4202
|
const [rows, held] = await Promise.all([allMembers(tx), grants(tx)]);
|
|
3253
|
-
const fault = this.#unsealed(rows, held,
|
|
4203
|
+
const fault = this.#unsealed(rows, held, walk.newestAccess) ?? walk.replayFault ?? replayFault(walk.replayed, rows, held, at);
|
|
3254
4204
|
if (fault !== null) return {
|
|
3255
4205
|
ok: false,
|
|
3256
4206
|
failedAtSeq: null,
|
|
3257
4207
|
reason: describeAccessFault(fault),
|
|
3258
4208
|
fault
|
|
3259
4209
|
};
|
|
3260
|
-
const verified = {
|
|
3261
|
-
ok: true,
|
|
3262
|
-
entries: findings.entries
|
|
3263
|
-
};
|
|
3264
4210
|
return accounting.pending === 0 ? verified : {
|
|
3265
4211
|
...verified,
|
|
3266
4212
|
pending: accounting.pending
|
|
@@ -3268,55 +4214,15 @@ var VaultService = class {
|
|
|
3268
4214
|
}, SNAPSHOT);
|
|
3269
4215
|
}
|
|
3270
4216
|
/**
|
|
3271
|
-
*
|
|
3272
|
-
*
|
|
3273
|
-
*
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
const { logKeys } = this.#prepared;
|
|
3277
|
-
let keys = [];
|
|
3278
|
-
for (let after = -1n;;) {
|
|
3279
|
-
const batch = await vaultEntriesAfter(tx, after, VERIFY_BATCH);
|
|
3280
|
-
if (batch.length === 0) return { ok: true };
|
|
3281
|
-
for (const entry of batch) {
|
|
3282
|
-
const result = verifyEntries([entry], {
|
|
3283
|
-
startSeq: entry.seq,
|
|
3284
|
-
startPrevHash: entry.prevHash,
|
|
3285
|
-
keys: logKeys
|
|
3286
|
-
});
|
|
3287
|
-
if (!result.ok) return {
|
|
3288
|
-
ok: false,
|
|
3289
|
-
failedAtSeq: Number(result.failedAtSeq),
|
|
3290
|
-
reason: withCause(result.reason)
|
|
3291
|
-
};
|
|
3292
|
-
}
|
|
3293
|
-
const moved = forward(batch, keys, logKeys[0].keyId);
|
|
3294
|
-
if ("failedAtSeq" in moved) return {
|
|
3295
|
-
ok: false,
|
|
3296
|
-
failedAtSeq: Number(moved.failedAtSeq),
|
|
3297
|
-
reason: moved.reason
|
|
3298
|
-
};
|
|
3299
|
-
keys = moved.keys;
|
|
3300
|
-
for (const entry of batch) findings.add(entry);
|
|
3301
|
-
after = batch[batch.length - 1].seq;
|
|
3302
|
-
}
|
|
3303
|
-
}
|
|
3304
|
-
/** The whole chain from its first entry: every link and hash, the vault's MACs; the furthest verified is kept for checkpoints. */
|
|
3305
|
-
async #rehashed(tx, findings) {
|
|
3306
|
-
const { verification, anchor } = await verifyChain(tx, this.#prepared.logKeys, [], UNVERIFIED, (batch) => {
|
|
3307
|
-
for (const entry of batch) if (entry.author === "vault") findings.add(entry);
|
|
3308
|
-
});
|
|
3309
|
-
if (!verification.ok) return verification;
|
|
3310
|
-
this.#prepared.verified = further(this.#prepared.verified, anchor);
|
|
3311
|
-
return { ok: true };
|
|
3312
|
-
}
|
|
3313
|
-
/**
|
|
3314
|
-
* Every checkpoint, not only the newest: each signed by the key whose log
|
|
3315
|
-
* key wrote its entry, over a prefix the log still holds (`holds`), entry
|
|
3316
|
-
* for entry. Its entry's MAC is checked by now, and one under a key the
|
|
3317
|
-
* vault replaced comes before the rotation.
|
|
4217
|
+
* Every checkpoint walked, not only the newest: each signed by the key
|
|
4218
|
+
* whose log key wrote its entry, over a prefix the log still holds,
|
|
4219
|
+
* entry for entry, which one that keeps a tally names as related. Its
|
|
4220
|
+
* entry's MAC is checked by now, and one under a key the vault replaced
|
|
4221
|
+
* comes before the rotation.
|
|
3318
4222
|
*/
|
|
3319
|
-
async #checkpointFault(
|
|
4223
|
+
async #checkpointFault(db, checkpoints, known) {
|
|
4224
|
+
if (checkpoints.length === 0) return null;
|
|
4225
|
+
const holds = known ?? await this.#holding(db, checkpoints.map(({ checkpoint }) => checkpoint));
|
|
3320
4226
|
const { logKeys, signers } = this.#prepared;
|
|
3321
4227
|
const verifiers = /* @__PURE__ */ new Map();
|
|
3322
4228
|
const signed = (checkpoint, by) => {
|
|
@@ -3324,14 +4230,13 @@ var VaultService = class {
|
|
|
3324
4230
|
if (verifier === void 0) verifiers.set(by.keyId, verifier = checkpointVerifier(by.publicKey));
|
|
3325
4231
|
return verifier.then((verify) => verify(checkpoint));
|
|
3326
4232
|
};
|
|
3327
|
-
const { checkpoints } = findings;
|
|
3328
4233
|
for (let from = 0; from < checkpoints.length; from += SIGNATURES_AT_ONCE) {
|
|
3329
4234
|
const chunk = checkpoints.slice(from, from + SIGNATURES_AT_ONCE);
|
|
3330
4235
|
const faults = await Promise.all(chunk.map(async ({ entry, checkpoint }) => {
|
|
3331
4236
|
const by = signers[logKeys.findIndex((key) => key.keyId === entry.keyId)];
|
|
3332
4237
|
if (by?.keyId !== checkpoint.keyId) return `a checkpoint signed under ${checkpoint.keyId}, in an entry written under ${entry.keyId}: each key signs only its own`;
|
|
3333
4238
|
if (!await signed(checkpoint, by)) return "a checkpoint the vault did not sign";
|
|
3334
|
-
if (BigInt(checkpoint.seq) >= entry.seq || !holds(checkpoint)) return `the log up to entry ${checkpoint.seq} is not the prefix this checkpoint signed: it was rewritten`;
|
|
4239
|
+
if (BigInt(checkpoint.seq) >= entry.seq || !holds(checkpoint) || entry.relatedSeq !== null && entry.relatedSeq !== BigInt(checkpoint.seq)) return `the log up to entry ${checkpoint.seq} is not the prefix this checkpoint signed: it was rewritten`;
|
|
3335
4240
|
return null;
|
|
3336
4241
|
}));
|
|
3337
4242
|
const at = faults.findIndex((fault) => fault !== null);
|
|
@@ -3345,8 +4250,8 @@ var VaultService = class {
|
|
|
3345
4250
|
}
|
|
3346
4251
|
/**
|
|
3347
4252
|
* The first member whose row fails its MAC, or names an older access
|
|
3348
|
-
* entry than the log's newest about them (`newest`, from the
|
|
3349
|
-
*
|
|
4253
|
+
* entry than the log's newest about them (`newest`, from the walk, where
|
|
4254
|
+
* each entry's MAC was checked, on from the tally).
|
|
3350
4255
|
*/
|
|
3351
4256
|
#unsealed(rows, held, newest) {
|
|
3352
4257
|
for (const row of rows) {
|
|
@@ -3365,44 +4270,71 @@ var VaultService = class {
|
|
|
3365
4270
|
return null;
|
|
3366
4271
|
}
|
|
3367
4272
|
};
|
|
4273
|
+
/** Whether a statement was cancelled for taking too long: Postgres's `query_canceled`, a statement timeout among them. */
|
|
4274
|
+
function timedOut(error) {
|
|
4275
|
+
for (let cause = error; typeof cause === "object" && cause !== null; cause = cause.cause) if (cause.code === "57014") return true;
|
|
4276
|
+
return false;
|
|
4277
|
+
}
|
|
4278
|
+
const FIRST_ENTRY = {
|
|
4279
|
+
ok: true,
|
|
4280
|
+
entry: null,
|
|
4281
|
+
at: START,
|
|
4282
|
+
tally: EMPTY_TALLY,
|
|
4283
|
+
digest: accessDigest(EMPTY_TALLY.access),
|
|
4284
|
+
carrier: null,
|
|
4285
|
+
scrub: null
|
|
4286
|
+
};
|
|
3368
4287
|
/**
|
|
3369
|
-
*
|
|
3370
|
-
*
|
|
3371
|
-
*
|
|
3372
|
-
*
|
|
4288
|
+
* A walk rehashes, MACs and replays some 40 µs of CPU an entry, and 95
|
|
4289
|
+
* with V8's optimizer off, as on a cold isolate: 10,000 entries is a beat
|
|
4290
|
+
* of catching up, about a second, and a team's instance writes fewer than
|
|
4291
|
+
* a thousand a day. The rehash of the past is a hash an entry, 14 µs warm
|
|
4292
|
+
* and 55 cold, beside two reads: 100 entries keep it near 6 ms a beat.
|
|
3373
4293
|
*/
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
held: /* @__PURE__ */ new Map()
|
|
3379
|
-
};
|
|
3380
|
-
/** The first access entry the replay could not apply. */
|
|
3381
|
-
replayFault = null;
|
|
3382
|
-
/** Each member's newest allowed access entry, by seq. */
|
|
3383
|
-
newestAccess = /* @__PURE__ */ new Map();
|
|
3384
|
-
/** Every allowed checkpoint, with its entry's seq and key: tens of thousands a year, so nothing more of it. */
|
|
3385
|
-
checkpoints = [];
|
|
3386
|
-
/** The vault's entries read. */
|
|
3387
|
-
entries = 0;
|
|
3388
|
-
add(entry) {
|
|
3389
|
-
this.entries += 1;
|
|
3390
|
-
this.accounting.add(entry);
|
|
3391
|
-
if (entry.decision !== "allow") return;
|
|
3392
|
-
if (ACCESS.has(entry.action)) {
|
|
3393
|
-
this.replayFault ??= apply(this.replayed, entry);
|
|
3394
|
-
if (entry.subjectPrincipal !== null) this.newestAccess.set(entry.subjectPrincipal, entry.seq);
|
|
3395
|
-
} else if (entry.action === CHECKPOINT) this.checkpoints.push({
|
|
3396
|
-
entry: {
|
|
3397
|
-
seq: entry.seq,
|
|
3398
|
-
keyId: entry.keyId
|
|
3399
|
-
},
|
|
3400
|
-
checkpoint: JSON.parse(entry.metadata)
|
|
3401
|
-
});
|
|
3402
|
-
}
|
|
4294
|
+
const WALKS = {
|
|
4295
|
+
checkpoint: 1e4,
|
|
4296
|
+
scrub: 100,
|
|
4297
|
+
verify: 1e4
|
|
3403
4298
|
};
|
|
3404
|
-
|
|
3405
|
-
|
|
4299
|
+
/** The digest of the members and grants a checkpoint keeps whole, beside the digest it says they have. */
|
|
4300
|
+
function keptDigest({ members, grants, newest }) {
|
|
4301
|
+
return accessDigest({
|
|
4302
|
+
members,
|
|
4303
|
+
grants,
|
|
4304
|
+
newest
|
|
4305
|
+
});
|
|
4306
|
+
}
|
|
4307
|
+
/** Each checkpoint entry's metadata, parsed once however many times it is asked about: one may hold every member and grant. */
|
|
4308
|
+
const parsed = /* @__PURE__ */ new WeakMap();
|
|
4309
|
+
function metadataOf(entry) {
|
|
4310
|
+
if (!parsed.has(entry)) parsed.set(entry, JSON.parse(entry.metadata));
|
|
4311
|
+
return parsed.get(entry);
|
|
4312
|
+
}
|
|
4313
|
+
/** A head, as an entry keeps it, as a walk goes on from it. */
|
|
4314
|
+
function positionOf(head) {
|
|
4315
|
+
return {
|
|
4316
|
+
seq: BigInt(head.seq),
|
|
4317
|
+
hash: Buffer.from(head.hash, "hex")
|
|
4318
|
+
};
|
|
4319
|
+
}
|
|
4320
|
+
/** A position as the log's heads are named, in hex. */
|
|
4321
|
+
function headOf(position) {
|
|
4322
|
+
return {
|
|
4323
|
+
seq: Number(position.seq),
|
|
4324
|
+
hash: position.hash.toString("hex")
|
|
4325
|
+
};
|
|
4326
|
+
}
|
|
4327
|
+
/** What a checkpoint's signature covers, and nothing it keeps beside it. */
|
|
4328
|
+
function signedPart({ seq, hash, signedAt, keyId, signature }) {
|
|
4329
|
+
return {
|
|
4330
|
+
seq,
|
|
4331
|
+
hash,
|
|
4332
|
+
signedAt,
|
|
4333
|
+
keyId,
|
|
4334
|
+
signature
|
|
4335
|
+
};
|
|
4336
|
+
}
|
|
4337
|
+
/** Checkpoint signatures checked at once. */
|
|
3406
4338
|
const SIGNATURES_AT_ONCE = 256;
|
|
3407
4339
|
/** Settle every operation before releasing the member lock, including cancelled requests. */
|
|
3408
4340
|
async function settle(operations, budgetMs) {
|
|
@@ -3729,7 +4661,7 @@ function oneRead(group) {
|
|
|
3729
4661
|
}
|
|
3730
4662
|
/** Whether `entry` changes a member's access: what their row's `access_seq` names. */
|
|
3731
4663
|
function isAccessEntry(entry) {
|
|
3732
|
-
return entry.decision === "allow" && entry.subjectPrincipal !== void 0 && entry.subjectPrincipal !== null && ACCESS_ACTIONS.includes(entry.action);
|
|
4664
|
+
return entry.decision === "allow" && entry.subjectPrincipal !== void 0 && entry.subjectPrincipal !== null && ACCESS_ACTIONS$1.includes(entry.action);
|
|
3733
4665
|
}
|
|
3734
4666
|
/** An entry about a member's access. */
|
|
3735
4667
|
function accessEntry(actor, action, principal, decision, correlation, detail, code = null) {
|
|
@@ -3833,8 +4765,7 @@ function readEdits(stored) {
|
|
|
3833
4765
|
* keeps its slug, which a scoped instance role's filters read. The edits
|
|
3834
4766
|
* keep them all: no write is needed.
|
|
3835
4767
|
*/
|
|
3836
|
-
|
|
3837
|
-
const rows = await environmentsById(db, edits.protected.map((place) => place.environmentId));
|
|
4768
|
+
function livePolicy(rows, edits, at) {
|
|
3838
4769
|
const live = edits.protected.filter((place) => {
|
|
3839
4770
|
const row = rows.get(place.environmentId);
|
|
3840
4771
|
return row === void 0 ? at - place.at < UNWRITTEN_FORK_MS : !row.deleted;
|