@coffre/vault 0.0.0 → 0.1.1
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/LICENSE +21 -0
- package/README.md +25 -3
- package/dist/cloudflare.d.ts +55 -0
- package/dist/cloudflare.js +73 -0
- package/dist/index-qXbB_vlp.d.ts +40 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/node.d.ts +48 -0
- package/dist/node.js +142 -0
- package/dist/vault-DvTsSAOX.js +2225 -0
- package/package.json +51 -5
- package/src/accounting.ts +110 -0
- package/src/checkpoint.ts +25 -0
- package/src/cloudflare-workers.d.ts +14 -0
- package/src/cloudflare.ts +89 -0
- package/src/config.ts +146 -0
- package/src/index.ts +6 -0
- package/src/local.ts +48 -0
- package/src/log.ts +140 -0
- package/src/node.ts +141 -0
- package/src/replay.ts +150 -0
- package/src/rows.ts +67 -0
- package/src/store.ts +420 -0
- package/src/vault.ts +1564 -0
package/src/vault.ts
ADDED
|
@@ -0,0 +1,1564 @@
|
|
|
1
|
+
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
allows,
|
|
5
|
+
assignableToEnvironment,
|
|
6
|
+
isRole,
|
|
7
|
+
isSyncPrincipal,
|
|
8
|
+
mayManageAccess,
|
|
9
|
+
type Holdings,
|
|
10
|
+
type Permission,
|
|
11
|
+
type Role,
|
|
12
|
+
} from '@coffre/core/access';
|
|
13
|
+
import { verifyEntries, type LogKey, type StoredEntry } from '@coffre/core/audit';
|
|
14
|
+
import { checkContext, type SecretContext } from '@coffre/core/envelope';
|
|
15
|
+
import {
|
|
16
|
+
DEK_BYTES,
|
|
17
|
+
KekBadClaimError,
|
|
18
|
+
KekCancelledError,
|
|
19
|
+
KekUnavailableError,
|
|
20
|
+
LocalKekProvider,
|
|
21
|
+
type KeyOperation,
|
|
22
|
+
type KekProvider,
|
|
23
|
+
type WrappedDek,
|
|
24
|
+
} from '@coffre/core/kek';
|
|
25
|
+
import {
|
|
26
|
+
checkpointMessage,
|
|
27
|
+
verifyCheckpoint,
|
|
28
|
+
describeAccessFault,
|
|
29
|
+
type Access,
|
|
30
|
+
type AccessChange,
|
|
31
|
+
type AccessFault,
|
|
32
|
+
type AdmitInput,
|
|
33
|
+
type Checkpoint,
|
|
34
|
+
type Grant,
|
|
35
|
+
type GrantChange,
|
|
36
|
+
type LogHead,
|
|
37
|
+
type LogVerification,
|
|
38
|
+
type Outcome,
|
|
39
|
+
type Refusal,
|
|
40
|
+
type RefusalCode,
|
|
41
|
+
type RemoveInput,
|
|
42
|
+
type RewrapInput,
|
|
43
|
+
type SecretRef,
|
|
44
|
+
type SetAccessInput,
|
|
45
|
+
type UnwrapInput,
|
|
46
|
+
type Vault,
|
|
47
|
+
type VerifyLogInput,
|
|
48
|
+
type WrapInput,
|
|
49
|
+
type WrappedKey,
|
|
50
|
+
} from '@coffre/core/vault';
|
|
51
|
+
import type { Database, Queryable, Transaction } from '@coffre/db';
|
|
52
|
+
import { isUniqueViolation, SNAPSHOT } from '@coffre/db/dialect';
|
|
53
|
+
import { appendEntries, lockLogHead, type NewEntry } from '@coffre/db/log';
|
|
54
|
+
|
|
55
|
+
import { verifyAccounting } from './accounting.ts';
|
|
56
|
+
import { signer, type Signer } from './checkpoint.ts';
|
|
57
|
+
import type { ResolvedVaultConfig } from './config.ts';
|
|
58
|
+
import { carries, further, UNVERIFIED, vaultLogKey, VERIFY_BATCH, verifyChain, type Anchor } from './log.ts';
|
|
59
|
+
import { apply, replay, type LoggedMember, type Replayed } from './replay.ts';
|
|
60
|
+
import { memberMac, rowKey, sameGrants, sealed } from './rows.ts';
|
|
61
|
+
import * as store from './store.ts';
|
|
62
|
+
import { ACCESS_ACTIONS, type GrantRow, type Member } from './store.ts';
|
|
63
|
+
|
|
64
|
+
export type VaultOptions = {
|
|
65
|
+
/**
|
|
66
|
+
* How long every key operation of one call may take, in milliseconds;
|
|
67
|
+
* 5 seconds unless set. Past it, a call fails as an outage.
|
|
68
|
+
*/
|
|
69
|
+
keyBudgetMs?: number;
|
|
70
|
+
/** Milliseconds added to the database's clock where the vault decides by it. Tests move time with it. */
|
|
71
|
+
clockOffset?: () => number;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A vault's configuration made ready to use, once per process, or once per
|
|
76
|
+
* isolate on Workers: its signer, its log key, and how far it has verified
|
|
77
|
+
* the log. Everything else is in the database, so any number of instances
|
|
78
|
+
* share one set of members, one log and one bulk count.
|
|
79
|
+
*/
|
|
80
|
+
export type PreparedVault = {
|
|
81
|
+
config: ResolvedVaultConfig;
|
|
82
|
+
signer: Signer;
|
|
83
|
+
logKey: LogKey;
|
|
84
|
+
options: Required<VaultOptions>;
|
|
85
|
+
/** How far the log is verified; `verifyChain` in log.ts. The furthest any call got to. */
|
|
86
|
+
verified: Anchor;
|
|
87
|
+
/** Root admins known to have a member row; rows are never deleted. */
|
|
88
|
+
rooted: Set<string>;
|
|
89
|
+
/** What member rows are sealed under; rows.ts. */
|
|
90
|
+
rowKey: Buffer;
|
|
91
|
+
/** Tampering this process has logged already, so that a forged row is one entry, not one per request. */
|
|
92
|
+
reported: Set<string>;
|
|
93
|
+
/** Whether its KEKs open what they wrapped, once asked: `#kekMismatch`. */
|
|
94
|
+
kekCheck: Promise<string | null> | null;
|
|
95
|
+
/** Why not, once decided that they do not. */
|
|
96
|
+
wrongKek: string | null;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export async function prepareVault(config: ResolvedVaultConfig, options: VaultOptions = {}): Promise<PreparedVault> {
|
|
100
|
+
return {
|
|
101
|
+
config,
|
|
102
|
+
signer: await signer(config.signingKey),
|
|
103
|
+
logKey: vaultLogKey(config.signingKey),
|
|
104
|
+
options: { keyBudgetMs: options.keyBudgetMs ?? KEY_BUDGET_MS, clockOffset: options.clockOffset ?? (() => 0) },
|
|
105
|
+
verified: UNVERIFIED,
|
|
106
|
+
rooted: new Set(),
|
|
107
|
+
rowKey: rowKey(config.signingKey),
|
|
108
|
+
reported: new Set(),
|
|
109
|
+
kekCheck: null,
|
|
110
|
+
wrongKek: null,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The vault over `db`. Cheap: on Workers, one per call, over that call's connections. */
|
|
115
|
+
export function openVault(db: Database, prepared: PreparedVault): Vault {
|
|
116
|
+
return new VaultService(db, prepared);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Every key operation of one call, together: a removal waits at most this long for a read at KMS. */
|
|
120
|
+
const KEY_BUDGET_MS = 5_000;
|
|
121
|
+
|
|
122
|
+
/** How long a decision waits for a lock: above the key budget, so a removal outwaits a read in flight. */
|
|
123
|
+
const LOCK_TIMEOUT_MS = 15_000;
|
|
124
|
+
|
|
125
|
+
const PRINCIPAL = /^(user|token|sync):[^\s:][^\s]*$/;
|
|
126
|
+
|
|
127
|
+
/** Who acts for the vault itself, as when it gives a root admin a member row. */
|
|
128
|
+
const VAULT_ACTOR = 'system:vault';
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A KEK's check: a known value, the size of a data key, wrapped under it in
|
|
132
|
+
* a context no secret has (the nil UUID), and kept in a `key.check` entry.
|
|
133
|
+
* Opening it again tells the vault its KEK is the one that wrapped the
|
|
134
|
+
* data, without opening any data.
|
|
135
|
+
*/
|
|
136
|
+
const KEY_CHECK = 'key.check';
|
|
137
|
+
const KEY_CHECK_VALUE = createHash('sha256').update('coffre.kek.check.v1').digest();
|
|
138
|
+
const NIL = '00000000-0000-0000-0000-000000000000';
|
|
139
|
+
const KEY_CHECK_CONTEXT: SecretContext = { projectId: NIL, environmentId: NIL, secretId: NIL };
|
|
140
|
+
|
|
141
|
+
/** How many stored keys a KEK with no check yet is tried on: one that opens proves it. */
|
|
142
|
+
const KEY_CHECK_SAMPLE = 3;
|
|
143
|
+
|
|
144
|
+
/** A new member row, before the decision seals it (`#seal`). */
|
|
145
|
+
const UNSEALED = { accessSeq: 0n, mac: Buffer.alloc(32) };
|
|
146
|
+
|
|
147
|
+
/** Why a change to a tampered member is refused. */
|
|
148
|
+
const TAMPERED_SUBJECT = "this member's record failed the vault's integrity check: remove them to start over";
|
|
149
|
+
|
|
150
|
+
/** The action of the vault's entry that signs a prefix of the log. */
|
|
151
|
+
const CHECKPOINT = 'audit.checkpoint';
|
|
152
|
+
|
|
153
|
+
/** Who asks for checkpoints: the app's scheduled job. */
|
|
154
|
+
const SCHEDULER = 'system:coffre-scheduler';
|
|
155
|
+
|
|
156
|
+
/** A refusal and the entries that record it. */
|
|
157
|
+
class Refused {
|
|
158
|
+
readonly refusal: Refusal;
|
|
159
|
+
readonly entries: NewEntry[];
|
|
160
|
+
|
|
161
|
+
constructor(refusal: Refusal, entries: NewEntry[]) {
|
|
162
|
+
this.refusal = refusal;
|
|
163
|
+
this.entries = entries;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A decision that read a member as absent who has been admitted since: it is made again. */
|
|
168
|
+
class Retry {}
|
|
169
|
+
|
|
170
|
+
/** What each `vault.tampered` entry reports, to mark it logged once committed. */
|
|
171
|
+
const REPORTED = new WeakMap<NewEntry, string>();
|
|
172
|
+
|
|
173
|
+
/** A key service that did not answer, and the entries that record what it did do. */
|
|
174
|
+
class Outage {
|
|
175
|
+
readonly error: unknown;
|
|
176
|
+
readonly entries: NewEntry[];
|
|
177
|
+
|
|
178
|
+
constructor(error: unknown, entries: NewEntry[]) {
|
|
179
|
+
this.error = error;
|
|
180
|
+
this.entries = entries;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function refusal(code: RefusalCode, message: string): Refusal {
|
|
185
|
+
return { code, message };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const MESSAGES: Record<RefusalCode, string> = {
|
|
189
|
+
removed: 'this member was removed',
|
|
190
|
+
not_a_member: 'not a member',
|
|
191
|
+
no_grant: 'no grant covers this',
|
|
192
|
+
expired: 'the grant that covered this has expired',
|
|
193
|
+
bulk_limit: 'too many secrets read in too short a time',
|
|
194
|
+
bad_claim: 'the key does not belong to this secret',
|
|
195
|
+
not_allowed: 'not allowed to change this',
|
|
196
|
+
root_admin: 'root admins are set in the vault configuration',
|
|
197
|
+
invalid: 'not something the rules allow',
|
|
198
|
+
log_broken: 'the vault log does not hold from the last checkpoint',
|
|
199
|
+
wrong_kek: "this vault's KEK does not open the data it holds",
|
|
200
|
+
tampered: "this member's record failed the vault's integrity check",
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* A decision in progress: its transaction, the member rows it locked, its
|
|
205
|
+
* time, and what it will commit. Changes to members and grants wait until
|
|
206
|
+
* the entries are appended, so each row carries its entry's time and the
|
|
207
|
+
* log replays to it exactly (replay.ts).
|
|
208
|
+
*/
|
|
209
|
+
type Decision = {
|
|
210
|
+
tx: Transaction;
|
|
211
|
+
members: Map<string, Member>;
|
|
212
|
+
/** The database's clock, read once the members are locked. */
|
|
213
|
+
at: number;
|
|
214
|
+
log: NewEntry[];
|
|
215
|
+
writes: ((at: number) => Promise<void>)[];
|
|
216
|
+
/** Members whose row or grants the writes change: sealed again once they have run. */
|
|
217
|
+
touched: Set<string>;
|
|
218
|
+
/**
|
|
219
|
+
* What each member this decision changes holds once its writes have run:
|
|
220
|
+
* the grants it verified against their row's MAC, with its own changes
|
|
221
|
+
* applied as they run. `#seal` seals this set, never a fresh read, which
|
|
222
|
+
* could hold a grant inserted around the vault while it decided.
|
|
223
|
+
*/
|
|
224
|
+
grants: Map<string, GrantRow[]>;
|
|
225
|
+
/** `vault.tampered` entries, committed with the decision whatever it decides. */
|
|
226
|
+
reports: NewEntry[];
|
|
227
|
+
/** Run once the entries are appended, with the seq each was given. */
|
|
228
|
+
after: ((seqOf: (entry: NewEntry) => number) => void)[];
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/** The actions of the entries the vault writes about keys. */
|
|
232
|
+
type KeyAction = 'secret.read' | 'key.wrap' | 'key.rewrap';
|
|
233
|
+
|
|
234
|
+
/** What ties an entry to the app's request, and to the one action it is part of. */
|
|
235
|
+
type Correlation = { requestId?: string | null; operationId?: string | null };
|
|
236
|
+
|
|
237
|
+
/** Why a member's row is not the one the vault last wrote; rows.ts. */
|
|
238
|
+
type Fault = 'mac' | 'stale';
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* What someone holds, read once per decision; `fault` when their row fails
|
|
242
|
+
* its check, and they hold nothing. `stored` is the grants as read, which
|
|
243
|
+
* the check verified when `fault` is null.
|
|
244
|
+
*/
|
|
245
|
+
type Standing = { principal: string; status: Access['status']; live: Holdings; all: Holdings; fault: Fault | null; stored: GrantRow[] };
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* How one key operation of a call came out: its value; or a bad claim, a
|
|
249
|
+
* key that does not open as the secret it was presented as; or no answer
|
|
250
|
+
* from the key service.
|
|
251
|
+
*/
|
|
252
|
+
type KeyOutcome<T> = { ok: true; value: T } | { ok: false; code: string; error?: unknown };
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The one implementation of `Vault`. Every decision is one transaction on
|
|
256
|
+
* the shared database: it locks the rows of the members it is about, reads
|
|
257
|
+
* what it needs, decides, appends its entries under the log's lock, and
|
|
258
|
+
* only then changes members and grants. Locks come in one order
|
|
259
|
+
* everywhere, a member row, then the log's head, then the app's rows, so
|
|
260
|
+
* any number of vault instances and app servers decide side by side
|
|
261
|
+
* without a cycle (docs/design/single-database.md, question 2).
|
|
262
|
+
*
|
|
263
|
+
* Key operations run inside the decision, after the check, for a call the
|
|
264
|
+
* rules allow: a KMS logs each one, and should never show a key opened for
|
|
265
|
+
* a read coffre refused. With a key service, the call's intent is logged
|
|
266
|
+
* first, in its own transaction, so a vault that dies at KMS leaves a
|
|
267
|
+
* record that pairs with what KMS logged; and every key's outcome is
|
|
268
|
+
* logged, a partial outage included.
|
|
269
|
+
*/
|
|
270
|
+
class VaultService implements Vault {
|
|
271
|
+
readonly #db: Database;
|
|
272
|
+
readonly #prepared: PreparedVault;
|
|
273
|
+
readonly #config: ResolvedVaultConfig;
|
|
274
|
+
|
|
275
|
+
constructor(db: Database, prepared: PreparedVault) {
|
|
276
|
+
this.#db = db;
|
|
277
|
+
this.#prepared = prepared;
|
|
278
|
+
this.#config = prepared.config;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async #now(db: Queryable): Promise<number> {
|
|
282
|
+
return (await store.now(db)) + this.#prepared.options.clockOffset();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Decide in one transaction, with `principals`' rows locked first. A
|
|
287
|
+
* `Refused` or an `Outage` rolls back all but its own entries, which
|
|
288
|
+
* commit on their own: then the refusal is the answer, and the outage
|
|
289
|
+
* fails the call.
|
|
290
|
+
*/
|
|
291
|
+
async #decide<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>): Promise<Outcome<T>> {
|
|
292
|
+
const reports: NewEntry[] = [];
|
|
293
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
294
|
+
try {
|
|
295
|
+
return await this.#decideOnce(principals, decide, reports);
|
|
296
|
+
} catch (error) {
|
|
297
|
+
// A member who had no row when this decision locked theirs, and has
|
|
298
|
+
// one now: another decision admitted them meanwhile. Again, with it.
|
|
299
|
+
if (attempt < 3 && (error instanceof Retry || isUniqueViolation(error))) continue;
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[]): Promise<Outcome<T>> {
|
|
306
|
+
try {
|
|
307
|
+
const result = await this.#db.transaction(async (tx) => {
|
|
308
|
+
await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
309
|
+
const members = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals);
|
|
310
|
+
const d: Decision = {
|
|
311
|
+
tx, members, at: await this.#now(tx), log: [], writes: [], touched: new Set(), grants: new Map(), reports, after: [],
|
|
312
|
+
};
|
|
313
|
+
const result = await decide(d);
|
|
314
|
+
const entries = [...reports, ...d.log];
|
|
315
|
+
let at = d.at;
|
|
316
|
+
// Each member's newest access entry, which their row names (rows.ts).
|
|
317
|
+
const accessSeq = new Map<string, bigint>();
|
|
318
|
+
if (entries.length > 0) {
|
|
319
|
+
const appended = await appendEntries(tx, this.#prepared.logKey, entries);
|
|
320
|
+
at = appended.occurredAt;
|
|
321
|
+
entries.forEach((entry, i) => {
|
|
322
|
+
if (isAccessEntry(entry)) accessSeq.set(entry.subjectPrincipal!, appended.seqStart + BigInt(i));
|
|
323
|
+
});
|
|
324
|
+
const seqs = new Map(entries.map((entry, i) => [entry, Number(appended.seqStart) + i]));
|
|
325
|
+
for (const then of d.after) then((entry) => seqs.get(entry)!);
|
|
326
|
+
}
|
|
327
|
+
for (const write of d.writes) await write(at);
|
|
328
|
+
for (const principal of new Set([...d.touched, ...accessSeq.keys()])) {
|
|
329
|
+
await this.#seal(d, principal, accessSeq.get(principal));
|
|
330
|
+
}
|
|
331
|
+
return result;
|
|
332
|
+
});
|
|
333
|
+
this.#reported(reports);
|
|
334
|
+
return { ok: true, ...result };
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if (!(error instanceof Refused || error instanceof Outage)) throw error;
|
|
337
|
+
const entries = [...reports, ...error.entries];
|
|
338
|
+
if (entries.length > 0) {
|
|
339
|
+
await this.#db.transaction(async (tx) => {
|
|
340
|
+
await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
341
|
+
await appendEntries(tx, this.#prepared.logKey, entries);
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
this.#reported(reports);
|
|
345
|
+
if (error instanceof Outage) throw error.error;
|
|
346
|
+
return { ok: false, refusal: error.refusal };
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Seal `principal`'s row again over what this decision left them holding,
|
|
352
|
+
* naming `accessSeq`, their newest access entry, when it wrote one. The
|
|
353
|
+
* table must hold exactly that: a grant written around the vault while it
|
|
354
|
+
* decided (its row lock does not stop one) is refused, with the decision,
|
|
355
|
+
* rather than sealed in.
|
|
356
|
+
*/
|
|
357
|
+
async #seal(d: Decision, principal: string, accessSeq: bigint | undefined): Promise<void> {
|
|
358
|
+
const row = await store.member(d.tx, principal);
|
|
359
|
+
if (row === undefined) return;
|
|
360
|
+
const decided = d.grants.get(principal);
|
|
361
|
+
if (decided === undefined) throw new Error(`sealing ${principal} without the grants this decision verified`);
|
|
362
|
+
if (!sameGrants(await store.grants(d.tx, principal), decided)) {
|
|
363
|
+
this.#report(d.reports, principal, 'mac', row.mac.toString('hex'));
|
|
364
|
+
throw new Refused(refusal('tampered', MESSAGES.tampered), []);
|
|
365
|
+
}
|
|
366
|
+
const next = { ...row, accessSeq: accessSeq ?? row.accessSeq };
|
|
367
|
+
await store.updateMember(d.tx, principal, { accessSeq: next.accessSeq, mac: memberMac(this.#prepared.rowKey, next, decided) });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Whether `row`, with these grants, is the row the vault last wrote: its
|
|
372
|
+
* MAC holds, and it names the newest access entry the log has about the
|
|
373
|
+
* member. A genuine row put back from before a later change passes the
|
|
374
|
+
* first and fails the second. Entries in the vault's name that fail their
|
|
375
|
+
* MAC are passed over, and reported: otherwise whoever can insert a row
|
|
376
|
+
* could lock any member out.
|
|
377
|
+
*/
|
|
378
|
+
async #integrity(db: Queryable, principal: string, row: Member | undefined, grants: readonly GrantRow[], reports: NewEntry[]): Promise<Fault | null> {
|
|
379
|
+
if (row !== undefined && !sealed(this.#prepared.rowKey, row, grants)) {
|
|
380
|
+
this.#report(reports, principal, 'mac', row.mac.toString('hex'));
|
|
381
|
+
return 'mac';
|
|
382
|
+
}
|
|
383
|
+
const newest = await this.#newestAccessSeq(db, principal, reports);
|
|
384
|
+
// No row, and no entry: someone never admitted. No row, but entries: one
|
|
385
|
+
// deleted, unless it was admitted since the row was read.
|
|
386
|
+
if (row === undefined ? newest === null : newest === row.accessSeq) return null;
|
|
387
|
+
if (row === undefined && (await store.member(db, principal)) !== undefined) throw new Retry();
|
|
388
|
+
this.#report(reports, principal, 'stale', `${row?.accessSeq ?? 'none'}<${newest}`);
|
|
389
|
+
return 'stale';
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** The seq of the newest access entry about `principal` that carries the vault's MAC, or null. */
|
|
393
|
+
async #newestAccessSeq(db: Queryable, principal: string, reports: NewEntry[]): Promise<bigint | null> {
|
|
394
|
+
for (const entry of await store.accessEntriesAbout(db, principal, 32)) {
|
|
395
|
+
if (this.#authentic(entry)) return entry.seq;
|
|
396
|
+
this.#report(reports, principal, 'forged_entry', String(entry.seq), entry.seq);
|
|
397
|
+
}
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
#authentic(entry: StoredEntry): boolean {
|
|
402
|
+
return verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys: [this.#prepared.logKey] }).ok;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** A `vault.tampered` entry, once per process for each thing found: `#reported` marks it once committed. */
|
|
406
|
+
#report(reports: NewEntry[], principal: string, code: Fault | 'forged_entry', detail: string, relatedSeq: bigint | null = null): void {
|
|
407
|
+
const key = `${principal}|${code}|${detail}`;
|
|
408
|
+
if (this.#prepared.reported.has(key) || reports.some((entry) => REPORTED.get(entry) === key)) return;
|
|
409
|
+
const entry: NewEntry = {
|
|
410
|
+
actor: VAULT_ACTOR,
|
|
411
|
+
action: 'vault.tampered',
|
|
412
|
+
decision: 'deny',
|
|
413
|
+
code,
|
|
414
|
+
subjectPrincipal: principal,
|
|
415
|
+
relatedSeq,
|
|
416
|
+
metadata: '{}',
|
|
417
|
+
};
|
|
418
|
+
REPORTED.set(entry, key);
|
|
419
|
+
reports.push(entry);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Mark these reports as logged, once their transaction has committed. */
|
|
423
|
+
#reported(reports: readonly NewEntry[]): void {
|
|
424
|
+
for (const entry of reports) {
|
|
425
|
+
const key = REPORTED.get(entry);
|
|
426
|
+
if (key !== undefined) this.#prepared.reported.add(key);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Reports found outside a decision, committed on their own. */
|
|
431
|
+
async #record(reports: NewEntry[]): Promise<void> {
|
|
432
|
+
if (reports.length === 0) return;
|
|
433
|
+
await this.#db.transaction((tx) => appendEntries(tx, this.#prepared.logKey, reports));
|
|
434
|
+
this.#reported(reports);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// --- keys -------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
// Raw DEKs are cleared on every path. JSON and base64 leave strings that
|
|
440
|
+
// cannot be wiped, so this is best-effort memory hygiene.
|
|
441
|
+
async unwrap(input: UnwrapInput): Promise<Outcome<{ keys: string[] }>> {
|
|
442
|
+
const { principal } = input;
|
|
443
|
+
validateText(input.purpose);
|
|
444
|
+
const loaded = await this.#versions(input, 'secret.read', { purpose: input.purpose });
|
|
445
|
+
if (!loaded.ok) return loaded;
|
|
446
|
+
const items = loaded.versions;
|
|
447
|
+
validateItems(items, 'wrapped');
|
|
448
|
+
const versionIds = new Map(items.map((item) => [item.secret, item.id]));
|
|
449
|
+
const entry = (secret: SecretRef, decision: 'allow' | 'deny', code: string | null): NewEntry => ({
|
|
450
|
+
...keyEntry('secret.read', principal, secret, decision, code, input, { purpose: input.purpose }),
|
|
451
|
+
secretVersionId: versionIds.get(secret),
|
|
452
|
+
});
|
|
453
|
+
const remote = items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
|
|
454
|
+
return this.#keys(
|
|
455
|
+
{ action: 'secret.read', principal, permission: 'secret.read', secrets: items.map((item) => item.secret), remote, entry, input },
|
|
456
|
+
() =>
|
|
457
|
+
items.map(({ secret, wrapped }) => async (operation: KeyOperation) => {
|
|
458
|
+
const key = await this.#open(wrapped, secret, operation);
|
|
459
|
+
return key && { key, wipe: () => key.fill(0) };
|
|
460
|
+
}),
|
|
461
|
+
(opened) => ({
|
|
462
|
+
keys: opened.map(({ key }) => {
|
|
463
|
+
try {
|
|
464
|
+
return base64(key);
|
|
465
|
+
} finally {
|
|
466
|
+
key.fill(0);
|
|
467
|
+
}
|
|
468
|
+
}),
|
|
469
|
+
}),
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async wrap(input: WrapInput): Promise<Outcome<{ wrapped: WrappedKey[]; seqs: number[] }>> {
|
|
474
|
+
const { principal, items } = input;
|
|
475
|
+
validateItems(items, 'key');
|
|
476
|
+
validateText(principal);
|
|
477
|
+
validateCorrelation(input);
|
|
478
|
+
const entry = (secret: SecretRef, decision: 'allow' | 'deny', code: string | null): NewEntry =>
|
|
479
|
+
keyEntry('key.wrap', principal, secret, decision, code, input);
|
|
480
|
+
return this.#keys(
|
|
481
|
+
{
|
|
482
|
+
action: 'key.wrap',
|
|
483
|
+
principal,
|
|
484
|
+
permission: 'secret.write',
|
|
485
|
+
secrets: items.map((item) => item.secret),
|
|
486
|
+
remote: this.#remote(this.#config.keks.primary),
|
|
487
|
+
entry,
|
|
488
|
+
input,
|
|
489
|
+
},
|
|
490
|
+
() =>
|
|
491
|
+
items.map(({ secret, key }) => async (operation: KeyOperation) => {
|
|
492
|
+
const dek = Buffer.from(key, 'base64');
|
|
493
|
+
try {
|
|
494
|
+
return { wrapped: serialisable(await this.#config.keks.wrap(dek, context(secret), operation)), wipe: () => {} };
|
|
495
|
+
} finally {
|
|
496
|
+
dek.fill(0);
|
|
497
|
+
}
|
|
498
|
+
}),
|
|
499
|
+
(done) => ({ wrapped: done.map(({ wrapped }) => wrapped), seqs: [] as number[] }),
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async rewrap(input: RewrapInput): Promise<Outcome<{ wrapped: WrappedKey[]; seqs: number[] }>> {
|
|
504
|
+
const { principal } = input;
|
|
505
|
+
const loaded = await this.#versions(input, 'key.rewrap');
|
|
506
|
+
if (!loaded.ok) return loaded;
|
|
507
|
+
// RPC can preserve shared objects; each item needs its own source.
|
|
508
|
+
const items = input.items.map((item, i) => ({ secret: { ...item.secret }, wrapped: loaded.versions[i].wrapped }));
|
|
509
|
+
validateItems(items, 'wrapped');
|
|
510
|
+
const sameSecret = (secret: SecretRef, source: SecretRef) =>
|
|
511
|
+
secret.projectId === source.projectId && secret.environmentId === source.environmentId && secret.secretId === source.secretId;
|
|
512
|
+
if (items.some((item, i) => !sameSecret(item.secret, loaded.versions[i].secret))) {
|
|
513
|
+
return this.#badVersions(principal, loaded.versions.map((source) => ({
|
|
514
|
+
...keyEntry('key.rewrap', principal, source.secret, 'deny', 'bad_claim', input), secretVersionId: source.id,
|
|
515
|
+
})));
|
|
516
|
+
}
|
|
517
|
+
const sources = new Map(items.map((item, i) => [item.secret, loaded.versions[i]]));
|
|
518
|
+
const entry = (secret: SecretRef, decision: 'allow' | 'deny', code: string | null): NewEntry => ({
|
|
519
|
+
...keyEntry('key.rewrap', principal, secret, decision, code, input, { from: sources.get(secret)!.secret.version }),
|
|
520
|
+
secretVersionId: sources.get(secret)!.id,
|
|
521
|
+
});
|
|
522
|
+
const remote =
|
|
523
|
+
this.#remote(this.#config.keks.primary) || items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
|
|
524
|
+
return this.#keys(
|
|
525
|
+
{ action: 'key.rewrap', principal, permission: 'secret.write', secrets: items.map((item) => item.secret), remote, entry, input },
|
|
526
|
+
() =>
|
|
527
|
+
items.map(({ secret, wrapped }) => async (operation: KeyOperation) => {
|
|
528
|
+
const key = await this.#open(wrapped, secret, operation);
|
|
529
|
+
if (key === null) return null;
|
|
530
|
+
try {
|
|
531
|
+
return { wrapped: serialisable(await this.#config.keks.wrap(key, context(secret), operation)), wipe: () => {} };
|
|
532
|
+
} finally {
|
|
533
|
+
key.fill(0);
|
|
534
|
+
}
|
|
535
|
+
}),
|
|
536
|
+
(done) => ({ wrapped: done.map(({ wrapped }) => wrapped), seqs: [] as number[] }),
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Immutable versions need no lock; their ids determine the whole batch before any key call. */
|
|
541
|
+
async #versions(
|
|
542
|
+
input: Correlation & { principal: string; items: { secretVersionId: string }[] },
|
|
543
|
+
action: KeyAction,
|
|
544
|
+
detail: Record<string, unknown> = {},
|
|
545
|
+
): Promise<Outcome<{ versions: store.SecretVersion[] }>> {
|
|
546
|
+
validateText(input.principal);
|
|
547
|
+
validateCorrelation(input);
|
|
548
|
+
const ids = input.items.map((item) => item.secretVersionId);
|
|
549
|
+
for (const id of ids) {
|
|
550
|
+
if (typeof id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id)) {
|
|
551
|
+
throw new Error('secret version id must be a lowercase UUID');
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const found = new Map((await store.versions(this.#db, ids)).map((version) => [version.id, version]));
|
|
555
|
+
if (ids.some((id) => !found.has(id))) {
|
|
556
|
+
return this.#badVersions(input.principal, ids.map((id) => {
|
|
557
|
+
const version = found.get(id);
|
|
558
|
+
if (version === undefined) return {
|
|
559
|
+
actor: input.principal, action, decision: 'deny', code: 'bad_claim',
|
|
560
|
+
operationId: input.operationId, requestId: input.requestId,
|
|
561
|
+
metadata: JSON.stringify({ ...detail, secretVersionId: id }),
|
|
562
|
+
};
|
|
563
|
+
return { ...keyEntry(action, input.principal, version.secret, 'deny', 'bad_claim', input, detail), secretVersionId: id };
|
|
564
|
+
}));
|
|
565
|
+
}
|
|
566
|
+
return { ok: true, versions: ids.map((id) => found.get(id)!) };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async #badVersions(principal: string, entries: NewEntry[]): Promise<Outcome<never>> {
|
|
570
|
+
if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
|
|
571
|
+
return this.#decide([principal], async () => {
|
|
572
|
+
throw new Refused(refusal('bad_claim', MESSAGES.bad_claim), entries);
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* The data key, or null when it does not open as this secret's: a claim
|
|
578
|
+
* that is not what it says. A key service that cannot answer is an outage,
|
|
579
|
+
* not a verdict on the claim, and throws.
|
|
580
|
+
*/
|
|
581
|
+
async #open(wrapped: WrappedKey, secret: SecretRef, operation: KeyOperation): Promise<Buffer | null> {
|
|
582
|
+
try {
|
|
583
|
+
return await this.#config.keks.unwrap(unwrappable(wrapped), context(secret), operation);
|
|
584
|
+
} catch (error) {
|
|
585
|
+
if (error instanceof KekBadClaimError) return null;
|
|
586
|
+
throw error;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Whether a key operation under `kek` leaves the process: a key service, not a key in memory. */
|
|
591
|
+
#remote(kek: KekProvider | undefined): boolean {
|
|
592
|
+
return kek !== undefined && !(kek instanceof LocalKekProvider);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* One call's key operations, decided as one. With a key service, the
|
|
597
|
+
* intent commits first; then, under the reader's row, the check, every
|
|
598
|
+
* operation within the budget, and each key's outcome. With a key in
|
|
599
|
+
* memory there is nothing to pair with outside, and it is all one short
|
|
600
|
+
* transaction.
|
|
601
|
+
*/
|
|
602
|
+
async #keys<T extends { wipe: () => void }, R>(
|
|
603
|
+
call: {
|
|
604
|
+
action: KeyAction;
|
|
605
|
+
principal: string;
|
|
606
|
+
permission: Permission;
|
|
607
|
+
secrets: readonly SecretRef[];
|
|
608
|
+
remote: boolean;
|
|
609
|
+
entry: (secret: SecretRef, decision: 'allow' | 'deny', code: string | null) => NewEntry;
|
|
610
|
+
input: Correlation & { purpose?: string };
|
|
611
|
+
},
|
|
612
|
+
/** One per secret; null for a bad claim, an error for an outage or an unexpected fault. */
|
|
613
|
+
operations: () => ((operation: KeyOperation) => Promise<T | null>)[],
|
|
614
|
+
result: (done: T[]) => R,
|
|
615
|
+
): Promise<Outcome<R>> {
|
|
616
|
+
const { action, principal, secrets, entry } = call;
|
|
617
|
+
for (const secret of secrets) entry(secret, 'allow', null);
|
|
618
|
+
const wrongKek = await this.#kekMismatch();
|
|
619
|
+
if (wrongKek !== null) {
|
|
620
|
+
return this.#decide([], async () => {
|
|
621
|
+
throw new Refused(refusal('wrong_kek', wrongKek), secrets.map((secret) => entry(secret, 'deny', 'wrong_kek')));
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
|
|
625
|
+
const check = async (d: Decision) => {
|
|
626
|
+
const reader = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports);
|
|
627
|
+
const codes = secrets.map((secret) => refuses(reader, call.permission, secret));
|
|
628
|
+
let first = codes.find((code) => code !== null) ?? null;
|
|
629
|
+
if (first === null && action === 'secret.read' && (await this.#overBulkLimit(d, principal, secrets.length))) first = 'bulk_limit';
|
|
630
|
+
if (first !== null) {
|
|
631
|
+
throw new Refused(
|
|
632
|
+
refusal(first, MESSAGES[first]),
|
|
633
|
+
secrets.map((secret, i) => entry(secret, 'deny', codes[i] ?? first)),
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
// The intent's own identity; `operationId` is the app's, for the whole action.
|
|
639
|
+
const intentId = call.remote ? randomUUID() : null;
|
|
640
|
+
let intentSeq: bigint | null = null;
|
|
641
|
+
const outcomeEntry = (secret: SecretRef, item: number, decision: 'allow' | 'deny', code: string | null): NewEntry => {
|
|
642
|
+
const outcome = entry(secret, decision, code);
|
|
643
|
+
return intentSeq === null ? outcome : {
|
|
644
|
+
...outcome,
|
|
645
|
+
relatedSeq: intentSeq,
|
|
646
|
+
metadata: JSON.stringify({
|
|
647
|
+
...JSON.parse(outcome.metadata ?? '{}'),
|
|
648
|
+
intent: intentId,
|
|
649
|
+
item,
|
|
650
|
+
...(['key_error', 'kms_uncertain'].includes(code ?? '') ? { uncertain: true } : {}),
|
|
651
|
+
}),
|
|
652
|
+
};
|
|
653
|
+
};
|
|
654
|
+
if (call.remote) {
|
|
655
|
+
const intent = await this.#decide([principal], async (d) => {
|
|
656
|
+
await check(d);
|
|
657
|
+
await lockLogHead(d.tx);
|
|
658
|
+
const at = await this.#now(d.tx);
|
|
659
|
+
const appended = await appendEntries(d.tx, this.#prepared.logKey, [{
|
|
660
|
+
actor: principal,
|
|
661
|
+
action: 'key.intent',
|
|
662
|
+
decision: 'allow',
|
|
663
|
+
operationId: call.input.operationId ?? null,
|
|
664
|
+
requestId: call.input.requestId ?? null,
|
|
665
|
+
metadata: JSON.stringify({
|
|
666
|
+
intent: intentId,
|
|
667
|
+
operation: action,
|
|
668
|
+
// Member and outcome locks can each wait before accounting is overdue.
|
|
669
|
+
expiresAt: at + this.#prepared.options.keyBudgetMs + 2 * LOCK_TIMEOUT_MS,
|
|
670
|
+
...(call.input.purpose === undefined ? {} : { purpose: call.input.purpose }),
|
|
671
|
+
keys: secrets.map((secret, item) => ({ item, subject: secret.path, secretId: secret.secretId, version: secret.version })),
|
|
672
|
+
}),
|
|
673
|
+
}]);
|
|
674
|
+
return { seq: appended.seqStart };
|
|
675
|
+
});
|
|
676
|
+
if (!intent.ok) return intent;
|
|
677
|
+
intentSeq = intent.seq;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return this.#decide([principal], async (d) => {
|
|
681
|
+
try {
|
|
682
|
+
await check(d);
|
|
683
|
+
} catch (error) {
|
|
684
|
+
if (error instanceof Refused) {
|
|
685
|
+
throw new Refused(error.refusal, secrets.map((secret, item) =>
|
|
686
|
+
outcomeEntry(secret, item, 'deny', error.entries[item].code ?? error.refusal.code)));
|
|
687
|
+
}
|
|
688
|
+
throw error;
|
|
689
|
+
}
|
|
690
|
+
const { outcomes, expired } = await settle(operations(), this.#prepared.options.keyBudgetMs);
|
|
691
|
+
const done = outcomes.flatMap((outcome) => (outcome.ok ? [outcome.value] : []));
|
|
692
|
+
try {
|
|
693
|
+
const entries = () => secrets.map((secret, i) =>
|
|
694
|
+
outcomeEntry(secret, i, 'deny', outcomes[i].ok ? 'withheld' : outcomes[i].code));
|
|
695
|
+
const fault = outcomes.find((outcome) => !outcome.ok && outcome.code === 'key_error');
|
|
696
|
+
if (fault !== undefined && !fault.ok) throw new Outage(fault.error, entries());
|
|
697
|
+
const unanswered = outcomes.filter((outcome) => !outcome.ok && outcome.code !== 'bad_claim').length;
|
|
698
|
+
if (unanswered > 0) {
|
|
699
|
+
throw new Outage(
|
|
700
|
+
new KekUnavailableError(`the key service did not answer for ${unanswered} of ${secrets.length} keys`,
|
|
701
|
+
outcomes.some((outcome) => !outcome.ok && outcome.code === 'kms_uncertain')),
|
|
702
|
+
entries(),
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
if (expired) throw new Outage(new KekUnavailableError('key operation exceeded its deadline'), entries());
|
|
706
|
+
if (done.length < outcomes.length) {
|
|
707
|
+
throw new Refused(refusal('bad_claim', MESSAGES.bad_claim), entries());
|
|
708
|
+
}
|
|
709
|
+
const released = secrets.map((secret, i) => outcomeEntry(secret, i, 'allow', null));
|
|
710
|
+
d.log.push(...released);
|
|
711
|
+
const answer = result(done);
|
|
712
|
+
// A wrap's answer names its entries, which the app's writes refer to.
|
|
713
|
+
if (action !== 'secret.read') d.after.push((seqOf) => Object.assign(answer as object, { seqs: released.map(seqOf) }));
|
|
714
|
+
return answer;
|
|
715
|
+
} finally {
|
|
716
|
+
for (const value of done) value.wipe();
|
|
717
|
+
}
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** Whether `n` more keys would take `principal` past the bulk limit; counted under their row's lock, so exactly. */
|
|
722
|
+
async #overBulkLimit(d: Decision, principal: string, n: number): Promise<boolean> {
|
|
723
|
+
const { count, windowMs } = this.#config.bulkLimit;
|
|
724
|
+
return (await store.releasesSince(d.tx, principal, d.at - windowMs)) + n > count;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// --- who holds what -----------------------------------------------------------
|
|
728
|
+
|
|
729
|
+
#isRootAdmin(principal: string): boolean {
|
|
730
|
+
return principal.startsWith('user:') && this.#config.rootAdmins.includes(principal.slice('user:'.length));
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Give a root admin a member row the first time anyone asks about them:
|
|
735
|
+
* the app's sign-ins and sessions point at it, and their reads queue on
|
|
736
|
+
* it like anyone's. Logged, and made in a transaction of its own, under
|
|
737
|
+
* the log's lock: a decision that held the head and then waited for a
|
|
738
|
+
* member row would take the locks out of order. The row never makes
|
|
739
|
+
* anyone a root admin; the configuration does.
|
|
740
|
+
*/
|
|
741
|
+
async #rootRow(principal: string): Promise<void> {
|
|
742
|
+
if (this.#prepared.rooted.has(principal)) return;
|
|
743
|
+
await this.#db.transaction(async (tx) => {
|
|
744
|
+
await lockLogHead(tx);
|
|
745
|
+
if ((await store.member(tx, principal)) !== undefined) return;
|
|
746
|
+
const appended = await appendEntries(tx, this.#prepared.logKey, [
|
|
747
|
+
{
|
|
748
|
+
actor: VAULT_ACTOR,
|
|
749
|
+
action: 'member.add',
|
|
750
|
+
decision: 'allow',
|
|
751
|
+
subjectPrincipal: principal,
|
|
752
|
+
metadata: JSON.stringify({ owner: false, rootAdmin: true }),
|
|
753
|
+
},
|
|
754
|
+
]);
|
|
755
|
+
const { occurredAt: at, seqStart } = appended;
|
|
756
|
+
const row = {
|
|
757
|
+
principal,
|
|
758
|
+
status: 'active' as const,
|
|
759
|
+
owner: false,
|
|
760
|
+
generation: 0,
|
|
761
|
+
createdAt: at,
|
|
762
|
+
createdBy: VAULT_ACTOR,
|
|
763
|
+
statusChangedAt: at,
|
|
764
|
+
statusChangedBy: VAULT_ACTOR,
|
|
765
|
+
accessSeq: seqStart,
|
|
766
|
+
};
|
|
767
|
+
await store.insertMember(tx, { ...row, mac: memberMac(this.#prepared.rowKey, row, []) });
|
|
768
|
+
});
|
|
769
|
+
this.#prepared.rooted.add(principal);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* What `principal` holds, their row checked first (`#integrity`): a row
|
|
774
|
+
* that fails holds nothing, and is `tampered`. A root admin's come from the
|
|
775
|
+
* configuration, which no row can change.
|
|
776
|
+
*/
|
|
777
|
+
async #standing(db: Queryable, principal: string, row: Member | undefined, at: number, reports: NewEntry[]): Promise<Standing> {
|
|
778
|
+
const none = { isRootAdmin: false, isOwner: false, grants: [] };
|
|
779
|
+
if (this.#isRootAdmin(principal)) {
|
|
780
|
+
const root = { isRootAdmin: true, isOwner: true, grants: [] };
|
|
781
|
+
return { principal, status: 'active', live: root, all: root, fault: null, stored: [] };
|
|
782
|
+
}
|
|
783
|
+
const grants = row === undefined ? [] : await store.grants(db, principal);
|
|
784
|
+
const fault = await this.#integrity(db, principal, row, grants, reports);
|
|
785
|
+
if (fault !== null) return { principal, status: 'tampered', live: none, all: none, fault, stored: grants };
|
|
786
|
+
if (row?.status !== 'active') return { principal, status: row?.status ?? 'unknown', live: none, all: none, fault, stored: grants };
|
|
787
|
+
const held = grants.map((grant) => ({ ...grant, role: grant.role as Role }));
|
|
788
|
+
const isOwner = row.owner && principal.startsWith('user:');
|
|
789
|
+
return {
|
|
790
|
+
principal,
|
|
791
|
+
status: 'active',
|
|
792
|
+
live: { isRootAdmin: false, isOwner, grants: held.filter((grant) => live(grant, at)) },
|
|
793
|
+
all: { isRootAdmin: false, isOwner, grants: held },
|
|
794
|
+
fault,
|
|
795
|
+
stored: grants,
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
#access(principal: string, row: Member | undefined, held: readonly GrantRow[], at: number, tampered: boolean): Access {
|
|
800
|
+
if (this.#isRootAdmin(principal)) {
|
|
801
|
+
return { principal, status: 'active', generation: row?.generation ?? 0, isRootAdmin: true, isOwner: true, grants: [], since: null, by: null };
|
|
802
|
+
}
|
|
803
|
+
const active = !tampered && row?.status === 'active';
|
|
804
|
+
return {
|
|
805
|
+
principal,
|
|
806
|
+
status: tampered ? 'tampered' : (row?.status ?? 'unknown'),
|
|
807
|
+
generation: row?.generation ?? 0,
|
|
808
|
+
isRootAdmin: false,
|
|
809
|
+
isOwner: active && row.owner && principal.startsWith('user:'),
|
|
810
|
+
grants: active ? held.filter((grant) => live(grant, at)).map(view) : [],
|
|
811
|
+
since: row ? iso(row.statusChangedAt) : null,
|
|
812
|
+
by: row?.statusChangedBy ?? null,
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* What `principal` holds now. Read without locks, the fast way; a row that
|
|
818
|
+
* seems to fail its check is read again in one snapshot before anyone is
|
|
819
|
+
* called tampered, since a change committed between two of the reads
|
|
820
|
+
* looks like one.
|
|
821
|
+
*/
|
|
822
|
+
async access(principal: string): Promise<Access> {
|
|
823
|
+
if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
|
|
824
|
+
const read = async (db: Queryable, reports: NewEntry[]) => {
|
|
825
|
+
const [row, held, at] = await Promise.all([store.member(db, principal), store.grants(db, principal), this.#now(db)]);
|
|
826
|
+
const fault = this.#isRootAdmin(principal) ? null : await this.#integrity(db, principal, row, held, reports);
|
|
827
|
+
return this.#access(principal, row, held, at, fault !== null);
|
|
828
|
+
};
|
|
829
|
+
const found: NewEntry[] = [];
|
|
830
|
+
const quick = await read(this.#db, found).catch((error: unknown) => {
|
|
831
|
+
if (error instanceof Retry) return null;
|
|
832
|
+
throw error;
|
|
833
|
+
});
|
|
834
|
+
if (quick !== null && quick.status !== 'tampered') {
|
|
835
|
+
await this.#record(found);
|
|
836
|
+
return quick;
|
|
837
|
+
}
|
|
838
|
+
const reports: NewEntry[] = [];
|
|
839
|
+
const access = await this.#db.transaction((tx) => read(tx, reports), SNAPSHOT);
|
|
840
|
+
await this.#record(reports);
|
|
841
|
+
return access;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Every member's row checked as `access` checks it, each finding
|
|
846
|
+
* reported: their newest access entries read in one query, and the slow
|
|
847
|
+
* way only for a row that does not match. Lists of members read the rows
|
|
848
|
+
* without the vault, so this is what finds a row changed around it before
|
|
849
|
+
* its member next asks for anything. The checkpoint runs it in one
|
|
850
|
+
* snapshot: a change committed between two of its reads, such as a lapsed
|
|
851
|
+
* grant cleared, would otherwise pair a row with grants it was never
|
|
852
|
+
* sealed over.
|
|
853
|
+
*/
|
|
854
|
+
async #sweep(db: Queryable, reports: NewEntry[]): Promise<void> {
|
|
855
|
+
const rows = await store.allMembers(db);
|
|
856
|
+
const held = await store.grants(db);
|
|
857
|
+
const newest = await store.newestAccessEntries(db);
|
|
858
|
+
for (const row of rows) {
|
|
859
|
+
if (this.#isRootAdmin(row.principal)) continue;
|
|
860
|
+
const grants = held.filter((grant) => grant.principal === row.principal);
|
|
861
|
+
const entry = newest.get(row.principal);
|
|
862
|
+
const holds = entry !== undefined && this.#authentic(entry) && sealed(this.#prepared.rowKey, row, grants) && entry.seq === row.accessSeq;
|
|
863
|
+
if (!holds) await this.#integrity(db, row.principal, row, grants, reports);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// --- changing access ----------------------------------------------------------
|
|
868
|
+
|
|
869
|
+
setAccess(input: SetAccessInput): Promise<Outcome<{ changes: AccessChange[] }>> {
|
|
870
|
+
const { actor, principal } = input;
|
|
871
|
+
const action = input.changes.every((change) => change.role === null) ? 'access.revoke' : 'access.grant';
|
|
872
|
+
// One place refused is shown where it is, to whoever reads that place's log; an invalid one may be no place at all.
|
|
873
|
+
const [only] = input.changes.length === 1 ? input.changes : [];
|
|
874
|
+
const refused = (code: RefusalCode, message = MESSAGES[code]) =>
|
|
875
|
+
new Refused(refusal(code, message), [{
|
|
876
|
+
...accessEntry(actor, action, principal, 'deny', input, { changes: input.changes }, code),
|
|
877
|
+
...(only === undefined || code === 'invalid' ? {} : { projectId: only.projectId, environmentId: only.environmentId }),
|
|
878
|
+
}]);
|
|
879
|
+
return this.#decide([actor, principal], async (d) => {
|
|
880
|
+
validateCorrelation(input);
|
|
881
|
+
if (!PRINCIPAL.test(principal)) throw refused('invalid', `not a principal: ${principal}`);
|
|
882
|
+
if (this.#isRootAdmin(principal)) throw refused('root_admin');
|
|
883
|
+
const places = new Set<string>();
|
|
884
|
+
for (const change of input.changes) {
|
|
885
|
+
const key = `${change.projectId}/${change.environmentId ?? ''}`;
|
|
886
|
+
if (places.has(key)) throw refused('invalid', 'each place may be changed once per call');
|
|
887
|
+
places.add(key);
|
|
888
|
+
if (change.role !== null && !isRole(change.role)) throw refused('invalid', `no such role: ${change.role}`);
|
|
889
|
+
if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role)) {
|
|
890
|
+
throw refused('invalid', `${change.role} can only be granted on a project`);
|
|
891
|
+
}
|
|
892
|
+
const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
|
|
893
|
+
if (Number.isNaN(expiresAt) || (expiresAt !== null && expiresAt <= d.at)) {
|
|
894
|
+
throw refused('invalid', 'an end date must be in the future');
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
const known = await store.places(
|
|
898
|
+
d.tx,
|
|
899
|
+
input.changes.map((change) => change.projectId),
|
|
900
|
+
input.changes.flatMap((change) => (change.environmentId === null ? [] : [change.environmentId])),
|
|
901
|
+
);
|
|
902
|
+
for (const { projectId, environmentId } of input.changes) {
|
|
903
|
+
if (!known.projects.has(projectId) || (environmentId !== null && known.environments.get(environmentId) !== projectId)) {
|
|
904
|
+
throw refused('invalid', `no such place: ${environmentId === null ? projectId : `${projectId}/${environmentId}`}`);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
|
|
908
|
+
if (acting.status === 'tampered') throw refused('tampered');
|
|
909
|
+
if (!input.changes.every((change) => mayManageAccess(acting.live, principal, change))) throw refused('not_allowed');
|
|
910
|
+
|
|
911
|
+
const row = d.members.get(principal);
|
|
912
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
913
|
+
if (subject.status === 'tampered') throw refused('tampered', TAMPERED_SUBJECT);
|
|
914
|
+
if (row?.status === 'removed') throw refused('removed');
|
|
915
|
+
if (row === undefined) {
|
|
916
|
+
// A sync is a member from its first grant; anyone else is admitted first.
|
|
917
|
+
if (!isSyncPrincipal(principal) || input.changes.every((change) => change.role === null)) {
|
|
918
|
+
throw refused('not_a_member');
|
|
919
|
+
}
|
|
920
|
+
d.log.push(accessEntry(actor, 'member.add', principal, 'allow', input, { owner: false }));
|
|
921
|
+
d.writes.push(async (at) => {
|
|
922
|
+
await store.insertMember(d.tx, {
|
|
923
|
+
principal,
|
|
924
|
+
status: 'active',
|
|
925
|
+
owner: false,
|
|
926
|
+
generation: 0,
|
|
927
|
+
createdAt: at,
|
|
928
|
+
createdBy: actor,
|
|
929
|
+
statusChangedAt: at,
|
|
930
|
+
statusChangedBy: actor,
|
|
931
|
+
...UNSEALED,
|
|
932
|
+
});
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
// The grants the check verified, not a second read: what the decision changes, and seals.
|
|
936
|
+
const held = subject.stored;
|
|
937
|
+
d.grants.set(principal, [...held]);
|
|
938
|
+
const changes = input.changes.map((change) => this.#apply(d, actor, principal, held, change, input));
|
|
939
|
+
if (d.writes.length > 0) d.touched.add(principal);
|
|
940
|
+
return { changes };
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/** One place's change, logged when it changes what is live. */
|
|
945
|
+
#apply(
|
|
946
|
+
d: Decision,
|
|
947
|
+
actor: string,
|
|
948
|
+
principal: string,
|
|
949
|
+
held: readonly GrantRow[],
|
|
950
|
+
change: GrantChange,
|
|
951
|
+
correlation: Correlation,
|
|
952
|
+
): AccessChange {
|
|
953
|
+
const existing = held.find((grant) => grant.projectId === change.projectId && grant.environmentId === change.environmentId);
|
|
954
|
+
const current = existing !== undefined && live(existing, d.at) ? existing : undefined;
|
|
955
|
+
const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
|
|
956
|
+
const place = { projectId: change.projectId, environmentId: change.environmentId };
|
|
957
|
+
const entry = (action: string, role: string | null) =>
|
|
958
|
+
d.log.push({
|
|
959
|
+
...accessEntry(actor, action, principal, 'allow', correlation, {
|
|
960
|
+
role,
|
|
961
|
+
expiresAt: expiresAt === null ? null : iso(expiresAt),
|
|
962
|
+
previousRole: current?.role ?? null,
|
|
963
|
+
}),
|
|
964
|
+
...place,
|
|
965
|
+
});
|
|
966
|
+
// A lapsed grant is cleared with no entry: it changes nothing anyone holds.
|
|
967
|
+
const clear = () => {
|
|
968
|
+
if (existing === undefined) return;
|
|
969
|
+
d.writes.push(async () => {
|
|
970
|
+
await store.deleteGrant(d.tx, principal, place);
|
|
971
|
+
d.grants.set(principal, d.grants.get(principal)!.filter((grant) => grant !== existing));
|
|
972
|
+
});
|
|
973
|
+
};
|
|
974
|
+
|
|
975
|
+
if (change.role === null) {
|
|
976
|
+
clear();
|
|
977
|
+
if (current === undefined) return 'unchanged';
|
|
978
|
+
entry('access.revoke', null);
|
|
979
|
+
return 'revoked';
|
|
980
|
+
}
|
|
981
|
+
if (current !== undefined && current.role === change.role && current.expiresAt === expiresAt) return 'unchanged';
|
|
982
|
+
const role = change.role;
|
|
983
|
+
clear();
|
|
984
|
+
d.writes.push(async (at) => {
|
|
985
|
+
const grant = { principal, ...place, role, expiresAt, grantedAt: at, grantedBy: actor };
|
|
986
|
+
await store.insertGrant(d.tx, grant);
|
|
987
|
+
d.grants.get(principal)!.push(grant);
|
|
988
|
+
});
|
|
989
|
+
entry('access.grant', role);
|
|
990
|
+
return current === undefined ? 'created' : 'updated';
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
admit(input: AdmitInput): Promise<Outcome<{ created: boolean; owner: boolean; generation: number }>> {
|
|
994
|
+
const { actor, principal } = input;
|
|
995
|
+
const refused = (code: RefusalCode, message = MESSAGES[code]) =>
|
|
996
|
+
new Refused(refusal(code, message), [
|
|
997
|
+
accessEntry(actor, 'member.add', principal, 'deny', input, { owner: input.owner ?? null }, code),
|
|
998
|
+
]);
|
|
999
|
+
return this.#decide([actor, principal], async (d) => {
|
|
1000
|
+
validateCorrelation(input);
|
|
1001
|
+
const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
|
|
1002
|
+
if (acting.status === 'tampered') throw refused('tampered');
|
|
1003
|
+
if (!acting.live.isOwner) throw refused('not_allowed', 'only owners may add or restore members');
|
|
1004
|
+
if (!PRINCIPAL.test(principal) || isSyncPrincipal(principal)) throw refused('invalid', `not a member: ${principal}`);
|
|
1005
|
+
if (this.#isRootAdmin(principal)) throw refused('root_admin');
|
|
1006
|
+
if (input.owner === true && !principal.startsWith('user:')) {
|
|
1007
|
+
throw refused('invalid', 'service accounts cannot be owners');
|
|
1008
|
+
}
|
|
1009
|
+
const row = d.members.get(principal);
|
|
1010
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
1011
|
+
if (subject.status === 'tampered') throw refused('tampered', TAMPERED_SUBJECT);
|
|
1012
|
+
d.touched.add(principal);
|
|
1013
|
+
d.grants.set(principal, [...subject.stored]);
|
|
1014
|
+
const entry = (action: string, owner: boolean) =>
|
|
1015
|
+
d.log.push(accessEntry(actor, action, principal, 'allow', input, { owner }));
|
|
1016
|
+
|
|
1017
|
+
if (row === undefined || row.status === 'removed') {
|
|
1018
|
+
// Coming back is a fresh start: no owner role unless given again.
|
|
1019
|
+
const owner = input.owner ?? false;
|
|
1020
|
+
entry(row === undefined ? 'member.add' : 'member.restore', owner);
|
|
1021
|
+
d.writes.push(async (at) => {
|
|
1022
|
+
if (row === undefined) {
|
|
1023
|
+
await store.insertMember(d.tx, {
|
|
1024
|
+
principal,
|
|
1025
|
+
status: 'active',
|
|
1026
|
+
owner,
|
|
1027
|
+
generation: 0,
|
|
1028
|
+
createdAt: at,
|
|
1029
|
+
createdBy: actor,
|
|
1030
|
+
statusChangedAt: at,
|
|
1031
|
+
statusChangedBy: actor,
|
|
1032
|
+
...UNSEALED,
|
|
1033
|
+
});
|
|
1034
|
+
} else {
|
|
1035
|
+
await store.updateMember(d.tx, principal, { status: 'active', owner, statusChangedAt: at, statusChangedBy: actor });
|
|
1036
|
+
}
|
|
1037
|
+
});
|
|
1038
|
+
// A removal moved the generation on already; coming back keeps it.
|
|
1039
|
+
return { created: true, owner, generation: row?.generation ?? 0 };
|
|
1040
|
+
}
|
|
1041
|
+
const owner = input.owner ?? row.owner;
|
|
1042
|
+
if (owner !== row.owner) {
|
|
1043
|
+
entry('member.owner', owner);
|
|
1044
|
+
d.writes.push(() => store.updateMember(d.tx, principal, { owner }));
|
|
1045
|
+
}
|
|
1046
|
+
return { created: false, owner, generation: row.generation };
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
remove(input: RemoveInput): Promise<Outcome<{ revoked: Grant[]; generation: number }>> {
|
|
1051
|
+
const { actor, principal } = input;
|
|
1052
|
+
const refused = (code: RefusalCode, message = MESSAGES[code]) =>
|
|
1053
|
+
new Refused(refusal(code, message), [accessEntry(actor, 'member.remove', principal, 'deny', input, {}, code)]);
|
|
1054
|
+
return this.#decide([actor, principal], async (d) => {
|
|
1055
|
+
validateCorrelation(input);
|
|
1056
|
+
if (this.#isRootAdmin(principal)) throw refused('root_admin');
|
|
1057
|
+
const row = d.members.get(principal);
|
|
1058
|
+
const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
|
|
1059
|
+
if (acting.status === 'tampered') throw refused('tampered');
|
|
1060
|
+
const holder = acting.live;
|
|
1061
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
1062
|
+
const held = subject.stored;
|
|
1063
|
+
if (subject.status === 'tampered') {
|
|
1064
|
+
if (!holder.isOwner) throw refused('not_allowed', 'only owners may remove a member whose record failed its check');
|
|
1065
|
+
return this.#startOver(d, actor, principal, row, subject.fault!, input, refused);
|
|
1066
|
+
}
|
|
1067
|
+
// Owners remove anyone. Removing a sync only takes access away, so
|
|
1068
|
+
// whoever may take away one of its grants, or manage it at its
|
|
1069
|
+
// source, may remove it, and anyone may remove one that holds nothing.
|
|
1070
|
+
const places = input.source === undefined ? held : [...held, input.source];
|
|
1071
|
+
const may =
|
|
1072
|
+
holder.isOwner ||
|
|
1073
|
+
(isSyncPrincipal(principal) &&
|
|
1074
|
+
(held.length === 0 || places.some((place) => mayManageAccess(holder, principal, { ...place, role: null }))));
|
|
1075
|
+
if (!may) throw refused('not_allowed', 'only owners may remove members');
|
|
1076
|
+
if (row?.status !== 'active') throw refused(row === undefined ? 'not_a_member' : 'removed');
|
|
1077
|
+
|
|
1078
|
+
const revoked = held.filter((grant) => live(grant, d.at));
|
|
1079
|
+
for (const grant of revoked) {
|
|
1080
|
+
d.log.push({
|
|
1081
|
+
...accessEntry(actor, 'access.revoke', principal, 'allow', input, {
|
|
1082
|
+
role: null,
|
|
1083
|
+
expiresAt: null,
|
|
1084
|
+
previousRole: grant.role,
|
|
1085
|
+
}),
|
|
1086
|
+
projectId: grant.projectId,
|
|
1087
|
+
environmentId: grant.environmentId,
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
const generation = row.generation + 1;
|
|
1091
|
+
d.log.push(accessEntry(actor, 'member.remove', principal, 'allow', input, { revoked: revoked.length, generation }));
|
|
1092
|
+
d.touched.add(principal);
|
|
1093
|
+
d.writes.push(async (at) => {
|
|
1094
|
+
await store.deleteGrants(d.tx, principal);
|
|
1095
|
+
d.grants.set(principal, []);
|
|
1096
|
+
await store.updateMember(d.tx, principal, {
|
|
1097
|
+
status: 'removed',
|
|
1098
|
+
owner: false,
|
|
1099
|
+
generation,
|
|
1100
|
+
statusChangedAt: at,
|
|
1101
|
+
statusChangedBy: actor,
|
|
1102
|
+
});
|
|
1103
|
+
});
|
|
1104
|
+
return { revoked: revoked.map(view), generation };
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* Remove a member whose row failed its check, from what the log says of
|
|
1110
|
+
* them rather than what the row does: their grants go, whatever they were,
|
|
1111
|
+
* and their generation moves past both the row's and the log's, so no
|
|
1112
|
+
* session or token from any earlier membership comes back with a row put
|
|
1113
|
+
* back. Admitted again, they start from nothing, as any removed member.
|
|
1114
|
+
*/
|
|
1115
|
+
async #startOver(
|
|
1116
|
+
d: Decision,
|
|
1117
|
+
actor: string,
|
|
1118
|
+
principal: string,
|
|
1119
|
+
row: Member | undefined,
|
|
1120
|
+
fault: Fault,
|
|
1121
|
+
correlation: Correlation,
|
|
1122
|
+
refused: (code: RefusalCode, message?: string) => Refused,
|
|
1123
|
+
): Promise<{ revoked: Grant[]; generation: number }> {
|
|
1124
|
+
const logged = await this.#logged(d.tx, principal);
|
|
1125
|
+
if (logged === undefined) throw refused('not_a_member', 'the log never admitted them: their row was written around the vault');
|
|
1126
|
+
const generation = Math.max(row?.generation ?? 0, logged.generation) + 1;
|
|
1127
|
+
d.log.push(accessEntry(actor, 'member.remove', principal, 'allow', correlation, { revoked: 0, generation, tampered: fault }));
|
|
1128
|
+
d.touched.add(principal);
|
|
1129
|
+
d.writes.push(async (at) => {
|
|
1130
|
+
await store.deleteGrants(d.tx, principal);
|
|
1131
|
+
d.grants.set(principal, []);
|
|
1132
|
+
const fresh = {
|
|
1133
|
+
status: 'removed' as const,
|
|
1134
|
+
owner: false,
|
|
1135
|
+
generation,
|
|
1136
|
+
createdAt: logged.createdAt,
|
|
1137
|
+
createdBy: logged.createdBy,
|
|
1138
|
+
statusChangedAt: at,
|
|
1139
|
+
statusChangedBy: actor,
|
|
1140
|
+
};
|
|
1141
|
+
if (row === undefined) await store.insertMember(d.tx, { principal, ...fresh, ...UNSEALED });
|
|
1142
|
+
else await store.updateMember(d.tx, principal, fresh);
|
|
1143
|
+
});
|
|
1144
|
+
return { revoked: [], generation };
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/** `principal` as the log says they are: their authenticated access entries, replayed. */
|
|
1148
|
+
async #logged(db: Queryable, principal: string): Promise<LoggedMember | undefined> {
|
|
1149
|
+
const state: Replayed = { members: new Map(), held: new Map() };
|
|
1150
|
+
const entries = (await store.accessEntriesAbout(db, principal, 100_000)).filter((entry) => this.#authentic(entry));
|
|
1151
|
+
for (const entry of entries.reverse()) apply(state, entry);
|
|
1152
|
+
return state.members.get(principal);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// --- the KEKs ----------------------------------------------------------------
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Null when every KEK the vault is given opens what it wrapped; otherwise
|
|
1159
|
+
* why not, naming the KEK, never its key. Decided once per process, before
|
|
1160
|
+
* its first key operation or checkpoint. A key service that cannot answer
|
|
1161
|
+
* leaves it undecided: the error is thrown, and the next call asks again.
|
|
1162
|
+
*/
|
|
1163
|
+
#kekMismatch(): Promise<string | null> {
|
|
1164
|
+
const prepared = this.#prepared;
|
|
1165
|
+
if (prepared.kekCheck === null) {
|
|
1166
|
+
const check = this.#checkKeks();
|
|
1167
|
+
prepared.kekCheck = check;
|
|
1168
|
+
check.then(
|
|
1169
|
+
(wrong) => void (prepared.wrongKek = wrong),
|
|
1170
|
+
() => {
|
|
1171
|
+
if (prepared.kekCheck === check) prepared.kekCheck = null;
|
|
1172
|
+
},
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
return prepared.kekCheck;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Each KEK opens its check value, or, with none recorded yet (a fresh
|
|
1180
|
+
* database, a new KEK, data from before checks), opens one of the newest
|
|
1181
|
+
* keys it wrapped, if there are any; then its check value is recorded.
|
|
1182
|
+
* A KEK that opens neither is not the one that wrapped the data.
|
|
1183
|
+
*/
|
|
1184
|
+
async #checkKeks(): Promise<string | null> {
|
|
1185
|
+
const checks = new Map<string, WrappedDek>();
|
|
1186
|
+
for (const entry of await store.vaultEntriesOf(this.#db, [KEY_CHECK], -1n, VERIFY_BATCH)) {
|
|
1187
|
+
// A check in the vault's name that the vault did not write proves nothing either way.
|
|
1188
|
+
if (!this.#authentic(entry)) continue;
|
|
1189
|
+
const wrapped = JSON.parse(entry.metadata) as WrappedKey;
|
|
1190
|
+
checks.set(`${wrapped.kekProvider}:${wrapped.kekId}`, unwrappable(wrapped));
|
|
1191
|
+
}
|
|
1192
|
+
const budget = this.#prepared.options.keyBudgetMs;
|
|
1193
|
+
for (const kek of this.#config.keks.all) {
|
|
1194
|
+
const operation = { deadline: Date.now() + budget, signal: AbortSignal.timeout(budget) };
|
|
1195
|
+
const opens = async (wrapped: WrappedDek, context: SecretContext, expected?: Buffer) => {
|
|
1196
|
+
try {
|
|
1197
|
+
const key = await kek.unwrap(wrapped, context, operation);
|
|
1198
|
+
const right = expected === undefined || (key.length === expected.length && timingSafeEqual(key, expected));
|
|
1199
|
+
key.fill(0);
|
|
1200
|
+
return right;
|
|
1201
|
+
} catch (error) {
|
|
1202
|
+
if (error instanceof KekBadClaimError) return false;
|
|
1203
|
+
throw error;
|
|
1204
|
+
}
|
|
1205
|
+
};
|
|
1206
|
+
const mismatch = `this vault's ${kek.provider} KEK ${kek.keyId} does not open the data it holds: it is not the key that wrapped it`;
|
|
1207
|
+
const check = checks.get(`${kek.provider}:${kek.keyId}`);
|
|
1208
|
+
if (check !== undefined) {
|
|
1209
|
+
if (!(await opens(check, KEY_CHECK_CONTEXT, KEY_CHECK_VALUE))) return mismatch;
|
|
1210
|
+
continue;
|
|
1211
|
+
}
|
|
1212
|
+
const samples = await store.wrappedUnder(this.#db, kek.provider, kek.keyId, KEY_CHECK_SAMPLE);
|
|
1213
|
+
let proof: { secretId: string; version: number } | null = null;
|
|
1214
|
+
for (const { projectId, environmentId, secretId, version, ...wrapped } of samples) {
|
|
1215
|
+
if (await opens(wrapped, { projectId, environmentId, secretId })) {
|
|
1216
|
+
proof = { secretId, version };
|
|
1217
|
+
break;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
if (samples.length > 0 && proof === null) return mismatch;
|
|
1221
|
+
const wrapped = await kek.wrap(Buffer.from(KEY_CHECK_VALUE), KEY_CHECK_CONTEXT, operation);
|
|
1222
|
+
// The key it opened, if any, is in the log, as every key the vault opens is.
|
|
1223
|
+
await this.#db.transaction((tx) =>
|
|
1224
|
+
appendEntries(tx, this.#prepared.logKey, [
|
|
1225
|
+
{ actor: VAULT_ACTOR, action: KEY_CHECK, decision: 'allow', metadata: JSON.stringify({ ...serialisable(wrapped), proof }) },
|
|
1226
|
+
]),
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
return null;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// --- checkpoints and the log --------------------------------------------------
|
|
1233
|
+
|
|
1234
|
+
/** The last checkpoint the vault signed, and the entry that holds it: its newest allowed `audit.checkpoint`. */
|
|
1235
|
+
async #latest(db: Queryable): Promise<{ checkpoint: Checkpoint; seq: bigint } | null> {
|
|
1236
|
+
const row = await store.latestVaultEntry(db, [CHECKPOINT]);
|
|
1237
|
+
return row === undefined ? null : { checkpoint: JSON.parse(row.metadata) as Checkpoint, seq: row.seq };
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
async checkpoint(): Promise<Outcome<{ checkpoint: Checkpoint }>> {
|
|
1241
|
+
// A KEK found wrong turns readiness red: the checkpoint is refused. It does no key work of its
|
|
1242
|
+
// own, so it writes nothing more and never waits on a key service; a check under way, or none yet, is no verdict.
|
|
1243
|
+
const { wrongKek } = this.#prepared;
|
|
1244
|
+
// Then, in one snapshot and without the log's lock, so that no append
|
|
1245
|
+
// waits on it: every member's row checked, since rows changed around the
|
|
1246
|
+
// vault write nothing to the log; and the whole chain recomputed from its
|
|
1247
|
+
// first entry, every hash from content and every vault entry by its MAC,
|
|
1248
|
+
// since an entry cut from the middle leaves the hashes around a later
|
|
1249
|
+
// checkpoint as they were.
|
|
1250
|
+
const found: NewEntry[] = [];
|
|
1251
|
+
const whole = await this.#db.transaction(async (tx) => {
|
|
1252
|
+
await this.#sweep(tx, found);
|
|
1253
|
+
return verifyChain(tx, this.#prepared.logKey, [], UNVERIFIED);
|
|
1254
|
+
}, SNAPSHOT);
|
|
1255
|
+
return this.#decide([], async (d) => {
|
|
1256
|
+
for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
|
|
1257
|
+
// Checkpoints one at a time, each against the one before.
|
|
1258
|
+
const head = await lockLogHead(d.tx);
|
|
1259
|
+
const latest = await this.#latest(d.tx);
|
|
1260
|
+
const refused = (code: RefusalCode, detail: Record<string, unknown>, message = MESSAGES[code]) =>
|
|
1261
|
+
new Refused(refusal(code, message), [
|
|
1262
|
+
{ actor: SCHEDULER, action: CHECKPOINT, decision: 'deny', code, metadata: JSON.stringify(detail) },
|
|
1263
|
+
]);
|
|
1264
|
+
// Logging the refusal would give the next call something to sign.
|
|
1265
|
+
if (head.nextSeq === 0n) throw new Refused(refusal('invalid', 'the log is empty'), []);
|
|
1266
|
+
if (wrongKek !== null) throw refused('wrong_kek', { reason: wrongKek }, wrongKek);
|
|
1267
|
+
if (!whole.verification.ok) {
|
|
1268
|
+
throw refused('log_broken', { failedAtSeq: whole.verification.failedAtSeq, reason: whole.verification.reason });
|
|
1269
|
+
}
|
|
1270
|
+
// Nothing since the last one: it is still the newest prefix.
|
|
1271
|
+
if (latest !== null && latest.seq === head.nextSeq - 1n) return { checkpoint: latest.checkpoint };
|
|
1272
|
+
// Under the lock, the rest: the prefix signed last still where it was,
|
|
1273
|
+
// and every entry since the snapshot. A rewrite is never signed over.
|
|
1274
|
+
if (latest !== null && !(await carries(d.tx, latest.checkpoint))) {
|
|
1275
|
+
throw refused('log_broken', { reason: `the log up to entry ${latest.checkpoint.seq} is not the prefix the last checkpoint signed` });
|
|
1276
|
+
}
|
|
1277
|
+
const { verification: held, anchor: verified } = await verifyChain(d.tx, this.#prepared.logKey, [], whole.anchor);
|
|
1278
|
+
if (!held.ok) throw refused('log_broken', { failedAtSeq: held.failedAtSeq, reason: held.reason });
|
|
1279
|
+
// It signs the entry it verified to, which the head, locked, must name.
|
|
1280
|
+
if (verified.nextSeq !== head.nextSeq || !verified.hash.equals(head.headHash)) {
|
|
1281
|
+
throw refused('log_broken', { reason: 'the chain head does not name the last entry' });
|
|
1282
|
+
}
|
|
1283
|
+
const signed = { seq: Number(verified.nextSeq - 1n), hash: verified.hash.toString('hex'), signedAt: iso(d.at) };
|
|
1284
|
+
const checkpoint = {
|
|
1285
|
+
...signed,
|
|
1286
|
+
keyId: this.#prepared.signer.keyId,
|
|
1287
|
+
signature: await this.#prepared.signer.sign(checkpointMessage(signed)),
|
|
1288
|
+
};
|
|
1289
|
+
d.log.push({ actor: SCHEDULER, action: CHECKPOINT, decision: 'allow', metadata: JSON.stringify(checkpoint) });
|
|
1290
|
+
return { checkpoint };
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
async about(): Promise<{ publicKey: string; rootAdmins: string[] }> {
|
|
1295
|
+
return { publicKey: this.#prepared.signer.publicKey, rootAdmins: this.#config.rootAdmins.map((email) => `user:${email}`) };
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
verifyLog(input: VerifyLogInput): Promise<LogVerification> {
|
|
1299
|
+
return this.#verifyAll([], input.upTo ?? null);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/** `shown`, and the chain from `anchor`; the furthest verified is kept for the next check. */
|
|
1303
|
+
async #verify(db: Queryable, shown: readonly StoredEntry[], anchor: Anchor): Promise<LogVerification> {
|
|
1304
|
+
const { verification, anchor: reached } = await verifyChain(db, this.#prepared.logKey, shown, anchor);
|
|
1305
|
+
if (verification.ok) this.#prepared.verified = further(this.#prepared.verified, reached);
|
|
1306
|
+
return verification;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/**
|
|
1310
|
+
* `shown`, and the chain from its first entry, in one snapshot: every
|
|
1311
|
+
* link and hash, the vault's MACs; then the heads that must still be
|
|
1312
|
+
* there: the one the app verified up to (`upTo`), so both authors are
|
|
1313
|
+
* checked over the same entries, the one the app last recorded from a
|
|
1314
|
+
* checkpoint (`through`), and the last checkpoint's; and the members and
|
|
1315
|
+
* grants replayed from it.
|
|
1316
|
+
*/
|
|
1317
|
+
#verifyAll(shown: readonly StoredEntry[], upTo: LogHead | null): Promise<LogVerification> {
|
|
1318
|
+
return this.#db.transaction(async (tx) => {
|
|
1319
|
+
const remembered = this.#prepared.verified;
|
|
1320
|
+
const verification = await this.#verify(tx, shown, UNVERIFIED);
|
|
1321
|
+
if (!verification.ok) return verification;
|
|
1322
|
+
if (upTo !== null && !(await carries(tx, upTo))) {
|
|
1323
|
+
return { ok: false, failedAtSeq: upTo.seq, reason: 'not the entry the app verified up to: the log changed between the two checks' };
|
|
1324
|
+
}
|
|
1325
|
+
const unsigned = await this.#checkpointFault(tx);
|
|
1326
|
+
if (unsigned !== null) return unsigned;
|
|
1327
|
+
// What this vault last verified must still be there: whoever holds
|
|
1328
|
+
// its key can seal a rewrite, but cannot put back the head it saw. A
|
|
1329
|
+
// checkpoint names an earlier break, so it is reported first.
|
|
1330
|
+
if (remembered.nextSeq > 0n) {
|
|
1331
|
+
const seq = remembered.nextSeq - 1n;
|
|
1332
|
+
if (!(await carries(tx, { seq: Number(seq), hash: remembered.hash.toString('hex') }))) {
|
|
1333
|
+
return { ok: false, failedAtSeq: Number(seq), reason: 'changed since the vault last verified it' };
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
const at = await this.#now(tx);
|
|
1337
|
+
const accounting = await verifyAccounting(tx, at);
|
|
1338
|
+
if (!accounting.ok) return accounting;
|
|
1339
|
+
const fault = (await this.#unsealed(tx)) ?? (await replay(tx, at));
|
|
1340
|
+
if (fault !== null) return { ok: false, failedAtSeq: null, reason: describeAccessFault(fault), fault };
|
|
1341
|
+
return accounting.pending === 0 ? verification : { ...verification, pending: accounting.pending };
|
|
1342
|
+
}, SNAPSHOT);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/**
|
|
1346
|
+
* Every checkpoint, not only the newest: each signed by the vault's key,
|
|
1347
|
+
* over a prefix the log still holds, entry for entry. The chain is
|
|
1348
|
+
* verified by now, so each checkpoint entry is the vault's.
|
|
1349
|
+
*/
|
|
1350
|
+
async #checkpointFault(db: Queryable): Promise<Extract<LogVerification, { ok: false }> | null> {
|
|
1351
|
+
for (let after = -1n; ; ) {
|
|
1352
|
+
const batch = await store.vaultEntriesOf(db, [CHECKPOINT], after, VERIFY_BATCH);
|
|
1353
|
+
for (const entry of batch) {
|
|
1354
|
+
const checkpoint = JSON.parse(entry.metadata) as Checkpoint;
|
|
1355
|
+
const broken = (reason: string) => ({ ok: false as const, failedAtSeq: Number(entry.seq), reason });
|
|
1356
|
+
if (!(await verifyCheckpoint(checkpoint, this.#prepared.signer.publicKey))) return broken('a checkpoint the vault did not sign');
|
|
1357
|
+
if (BigInt(checkpoint.seq) >= entry.seq || !(await carries(db, checkpoint))) {
|
|
1358
|
+
return broken(`the log up to entry ${checkpoint.seq} is not the prefix this checkpoint signed: it was rewritten`);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
if (batch.length < VERIFY_BATCH) return null;
|
|
1362
|
+
after = batch[batch.length - 1].seq;
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/**
|
|
1367
|
+
* The first member whose row fails its MAC, or names an older access
|
|
1368
|
+
* entry than the log's newest about them. The chain is verified by now,
|
|
1369
|
+
* so every entry read here carries the vault's MAC.
|
|
1370
|
+
*/
|
|
1371
|
+
async #unsealed(db: Queryable): Promise<AccessFault | null> {
|
|
1372
|
+
const [rows, held, newest] = await Promise.all([store.allMembers(db), store.grants(db), store.newestAccessEntries(db)]);
|
|
1373
|
+
for (const row of rows) {
|
|
1374
|
+
const grants = held.filter((grant) => grant.principal === row.principal);
|
|
1375
|
+
if (!sealed(this.#prepared.rowKey, row, grants)) return { kind: 'tampered-member', principal: row.principal, why: 'mac' };
|
|
1376
|
+
if (newest.get(row.principal)?.seq !== row.accessSeq) return { kind: 'tampered-member', principal: row.principal, why: 'stale' };
|
|
1377
|
+
}
|
|
1378
|
+
return null;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/** Settle every operation before releasing the member lock, including cancelled requests. */
|
|
1383
|
+
async function settle<T extends { wipe: () => void }>(
|
|
1384
|
+
operations: ((operation: KeyOperation) => Promise<T | null>)[],
|
|
1385
|
+
budgetMs: number,
|
|
1386
|
+
): Promise<{ outcomes: KeyOutcome<T>[]; expired: boolean }> {
|
|
1387
|
+
const controller = new AbortController();
|
|
1388
|
+
const operation = { deadline: Date.now() + budgetMs, signal: controller.signal };
|
|
1389
|
+
const timer = setTimeout(() => controller.abort(), budgetMs);
|
|
1390
|
+
try {
|
|
1391
|
+
const outcomes = await Promise.all(operations.map(async (work): Promise<KeyOutcome<T>> => {
|
|
1392
|
+
try {
|
|
1393
|
+
if (operation.signal.aborted || Date.now() >= operation.deadline) throw new KekCancelledError();
|
|
1394
|
+
const value = await work(operation);
|
|
1395
|
+
return value === null ? { ok: false, code: 'bad_claim' } : { ok: true, value };
|
|
1396
|
+
} catch (error) {
|
|
1397
|
+
if (error instanceof KekUnavailableError) {
|
|
1398
|
+
return { ok: false, code: error.uncertain ? 'kms_uncertain' : error instanceof KekCancelledError ? 'cancelled' : 'kms_unavailable' };
|
|
1399
|
+
}
|
|
1400
|
+
return { ok: false, code: 'key_error', error };
|
|
1401
|
+
}
|
|
1402
|
+
}));
|
|
1403
|
+
return { outcomes, expired: controller.signal.aborted || Date.now() >= operation.deadline };
|
|
1404
|
+
} finally {
|
|
1405
|
+
clearTimeout(timer);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function validateText(value: string): void {
|
|
1410
|
+
if (typeof value !== 'string' || /[\uD800-\uDFFF]/u.test(value)) throw new Error('key request strings must be well-formed Unicode');
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
1414
|
+
|
|
1415
|
+
/** The caller's ids go into the log as they are: a request id is text, an operation id a lowercase UUID. */
|
|
1416
|
+
function validateCorrelation(input: Correlation): void {
|
|
1417
|
+
if (input.requestId != null) validateText(input.requestId);
|
|
1418
|
+
if (input.operationId != null && (typeof input.operationId !== 'string' || !UUID.test(input.operationId))) {
|
|
1419
|
+
throw new Error(`operationId must be a lowercase UUID, got: ${String(input.operationId)}`);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/** Validate the entire batch before any provider sees a key. */
|
|
1424
|
+
function validateItems(items: readonly { secret: SecretRef; key?: string; wrapped?: WrappedKey }[], field: 'key' | 'wrapped'): void {
|
|
1425
|
+
for (const { secret, key, wrapped } of items) {
|
|
1426
|
+
if (field === 'key' && typeof key !== 'string') throw new Error('DEK must be base64');
|
|
1427
|
+
if (field === 'wrapped' && (wrapped === undefined || wrapped === null)) throw new Error('wrapped key is required');
|
|
1428
|
+
checkContext(context(secret));
|
|
1429
|
+
if (!Number.isSafeInteger(secret.version) || secret.version < 1) throw new Error('secret version must be a positive integer');
|
|
1430
|
+
if (typeof secret.path !== 'string' || /[\uD800-\uDFFF]/u.test(secret.path)) throw new Error('secret path must be a well-formed string');
|
|
1431
|
+
if (key !== undefined) {
|
|
1432
|
+
const dek = Buffer.from(key, 'base64');
|
|
1433
|
+
try {
|
|
1434
|
+
if (dek.length !== DEK_BYTES) throw new Error(`DEK must be ${DEK_BYTES} bytes, got ${dek.length}`);
|
|
1435
|
+
if (base64(dek) !== key) throw new Error('DEK must be canonical base64');
|
|
1436
|
+
} finally {
|
|
1437
|
+
dek.fill(0);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
if (wrapped !== undefined) {
|
|
1441
|
+
for (const field of [wrapped.kekProvider, wrapped.kekId, wrapped.kekVersion]) {
|
|
1442
|
+
if (typeof field !== 'string' || field.length === 0 || /[\uD800-\uDFFF]/u.test(field)) throw new Error('wrapped key metadata must be nonempty well-formed strings');
|
|
1443
|
+
}
|
|
1444
|
+
const bytes = Buffer.from(wrapped.bytes, 'base64');
|
|
1445
|
+
if (bytes.length === 0 || base64(bytes) !== wrapped.bytes) throw new Error('wrapped key must be nonempty canonical base64');
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/** Why `reader` may not do `permission` on `secret`, or null if they may. */
|
|
1451
|
+
function refuses(reader: Standing, permission: Permission, secret: SecretRef): RefusalCode | null {
|
|
1452
|
+
if (reader.status === 'tampered') return 'tampered';
|
|
1453
|
+
if (reader.status === 'removed') return 'removed';
|
|
1454
|
+
if (reader.status === 'unknown') return 'not_a_member';
|
|
1455
|
+
const where = { projectId: secret.projectId, environmentId: secret.environmentId };
|
|
1456
|
+
if (allows(reader.live, permission, where)) return null;
|
|
1457
|
+
// Would a grant that has lapsed have covered it?
|
|
1458
|
+
return allows(reader.all, permission, where) ? 'expired' : 'no_grant';
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
/**
|
|
1462
|
+
* An entry about a key. A new secret's row is not committed when its key is
|
|
1463
|
+
* wrapped, so a wrap names the secret in its payload; the others name it.
|
|
1464
|
+
*/
|
|
1465
|
+
function keyEntry(
|
|
1466
|
+
action: KeyAction,
|
|
1467
|
+
principal: string,
|
|
1468
|
+
secret: SecretRef,
|
|
1469
|
+
decision: 'allow' | 'deny',
|
|
1470
|
+
code: string | null,
|
|
1471
|
+
correlation: Correlation,
|
|
1472
|
+
detail: Record<string, unknown> = {},
|
|
1473
|
+
): NewEntry {
|
|
1474
|
+
return {
|
|
1475
|
+
actor: principal,
|
|
1476
|
+
action,
|
|
1477
|
+
decision,
|
|
1478
|
+
code,
|
|
1479
|
+
projectId: secret.projectId,
|
|
1480
|
+
environmentId: secret.environmentId,
|
|
1481
|
+
secretId: action === 'key.wrap' ? null : secret.secretId,
|
|
1482
|
+
operationId: correlation.operationId ?? null,
|
|
1483
|
+
requestId: correlation.requestId ?? null,
|
|
1484
|
+
metadata: JSON.stringify({
|
|
1485
|
+
subject: secret.path,
|
|
1486
|
+
...(action === 'key.wrap' ? { secretId: secret.secretId } : {}),
|
|
1487
|
+
version: secret.version,
|
|
1488
|
+
...detail,
|
|
1489
|
+
}),
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
/** Whether `entry` changes a member's access: what their row's `access_seq` names. */
|
|
1494
|
+
function isAccessEntry(entry: NewEntry): boolean {
|
|
1495
|
+
return (
|
|
1496
|
+
entry.decision === 'allow' &&
|
|
1497
|
+
entry.subjectPrincipal !== undefined &&
|
|
1498
|
+
entry.subjectPrincipal !== null &&
|
|
1499
|
+
(ACCESS_ACTIONS as readonly string[]).includes(entry.action)
|
|
1500
|
+
);
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/** An entry about a member's access. */
|
|
1504
|
+
function accessEntry(
|
|
1505
|
+
actor: string,
|
|
1506
|
+
action: string,
|
|
1507
|
+
principal: string,
|
|
1508
|
+
decision: 'allow' | 'deny',
|
|
1509
|
+
correlation: Correlation,
|
|
1510
|
+
detail: Record<string, unknown>,
|
|
1511
|
+
code: RefusalCode | null = null,
|
|
1512
|
+
): NewEntry {
|
|
1513
|
+
return {
|
|
1514
|
+
actor,
|
|
1515
|
+
action,
|
|
1516
|
+
decision,
|
|
1517
|
+
code,
|
|
1518
|
+
// A refusal may be about something that is no principal at all.
|
|
1519
|
+
subjectPrincipal: PRINCIPAL.test(principal) ? principal : null,
|
|
1520
|
+
operationId: correlation.operationId ?? null,
|
|
1521
|
+
requestId: correlation.requestId ?? null,
|
|
1522
|
+
metadata: JSON.stringify(PRINCIPAL.test(principal) ? detail : { subject: principal, ...detail }),
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
function live(grant: GrantRow, at: number): boolean {
|
|
1527
|
+
return grant.expiresAt === null || grant.expiresAt > at;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
function view(grant: GrantRow): Grant {
|
|
1531
|
+
return {
|
|
1532
|
+
projectId: grant.projectId,
|
|
1533
|
+
environmentId: grant.environmentId,
|
|
1534
|
+
role: grant.role as Role,
|
|
1535
|
+
expiresAt: grant.expiresAt === null ? null : iso(grant.expiresAt),
|
|
1536
|
+
grantedAt: iso(grant.grantedAt),
|
|
1537
|
+
grantedBy: grant.grantedBy,
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
function iso(ms: number): string {
|
|
1542
|
+
return new Date(ms).toISOString();
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
function context(secret: SecretRef): SecretContext {
|
|
1546
|
+
return { projectId: secret.projectId, environmentId: secret.environmentId, secretId: secret.secretId };
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
function unwrappable(wrapped: WrappedKey) {
|
|
1550
|
+
return { ...wrapped, bytes: Buffer.from(wrapped.bytes, 'base64') };
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
function serialisable(wrapped: { kekProvider: string; kekId: string; kekVersion: string; bytes: Buffer }): WrappedKey {
|
|
1554
|
+
return { ...wrapped, bytes: base64(wrapped.bytes) };
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
/**
|
|
1558
|
+
* Bytes as base64. Through `Buffer.from`, a view of the same memory, because
|
|
1559
|
+
* Workers' types declare their own `Buffer` and a bare one loses its
|
|
1560
|
+
* `toString(encoding)` to them.
|
|
1561
|
+
*/
|
|
1562
|
+
function base64(bytes: Uint8Array): string {
|
|
1563
|
+
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64');
|
|
1564
|
+
}
|