@coffre/vault 0.14.4 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/dist/cloudflare.d.ts +343 -9
- package/dist/cloudflare.js +802 -6
- package/dist/{index-CIq4i_KT.d.ts → index-DQ-FKVF3.d.ts} +4 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/log-entry-BYWqj3O5.d.ts +364 -0
- package/dist/node.d.ts +182 -7
- package/dist/node.js +358 -16
- package/dist/{src-NM5NsYFs.js → src-DheG92EM.js} +9 -2
- package/dist/vault-kmtojYnW.js +14623 -0
- package/package.json +4 -5
- package/src/bulk.ts +89 -0
- package/src/changes.ts +478 -0
- package/src/checkpointer-node.ts +342 -0
- package/src/checkpointer-workers.ts +514 -0
- package/src/checkpointer.ts +1165 -0
- package/src/cloudflare.ts +207 -13
- package/src/config.ts +15 -3
- package/src/credentials.postgres.ts +78 -0
- package/src/credentials.sqlite.ts +240 -0
- package/src/credentials.ts +630 -0
- package/src/cutover.ts +275 -0
- package/src/decide.ts +437 -2
- package/src/inputs.ts +499 -0
- package/src/local.ts +20 -4
- package/src/log-checkpointer.ts +116 -0
- package/src/log-entry.ts +113 -0
- package/src/log-replay.ts +188 -0
- package/src/log-store.ts +695 -0
- package/src/node.ts +73 -9
- package/src/objects.ts +141 -0
- package/src/reads.ts +502 -0
- package/src/retention.ts +229 -0
- package/src/snapshot.postgres.ts +39 -9
- package/src/snapshot.sqlite.ts +28 -3
- package/src/snapshot.ts +72 -12
- package/src/store.ts +862 -37
- package/src/use.postgres.ts +111 -0
- package/src/use.sqlite.ts +542 -0
- package/src/use.ts +1060 -0
- package/src/vault.ts +1513 -240
- package/src/verifier.ts +136 -0
- package/src/witness-workers.ts +163 -0
- package/dist/vault-BBdXsny3.js +0 -5077
package/src/vault.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EXCHANGE_SKEW_SECONDS, RunProofRefused, UPLOADED_KEYS, verifyProof, verifyRunToken, WORKLOAD_SIGN_IN_PATH, type RunProof, type WorkloadProfile } from '@coffre/core/identity';
|
|
1
2
|
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
3
|
|
|
3
4
|
import {
|
|
@@ -5,7 +6,6 @@ import {
|
|
|
5
6
|
assignableToEnvironment,
|
|
6
7
|
DEFAULT_POLICY,
|
|
7
8
|
definitionProblem,
|
|
8
|
-
EVERY_ENVIRONMENT,
|
|
9
9
|
EVERYWHERE,
|
|
10
10
|
FIXED_ROLES,
|
|
11
11
|
givesService,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
INSTANCE_ROLES,
|
|
15
15
|
isBuiltinRole,
|
|
16
16
|
isCredentialScope,
|
|
17
|
+
type CredentialScope,
|
|
17
18
|
isInstanceRole,
|
|
18
19
|
isRole,
|
|
19
20
|
isScope,
|
|
@@ -29,7 +30,6 @@ import {
|
|
|
29
30
|
ROLES,
|
|
30
31
|
runsInstance,
|
|
31
32
|
setsUpServices,
|
|
32
|
-
UNWRITTEN_FORK_MS,
|
|
33
33
|
type Holdings,
|
|
34
34
|
type InstanceRole,
|
|
35
35
|
type Permission,
|
|
@@ -39,8 +39,11 @@ import {
|
|
|
39
39
|
type RoleDefinition,
|
|
40
40
|
type Scope,
|
|
41
41
|
type ServiceAccount,
|
|
42
|
+
type ServiceSetting,
|
|
43
|
+
readScope,
|
|
42
44
|
} from '@coffre/core/access';
|
|
43
45
|
import { GENESIS_HASH, verifyEntries, type LogKey, type StoredEntry } from '@coffre/core/audit';
|
|
46
|
+
import { openPair, type LogEntry, type PairNotes } from '@coffre/core/merkle';
|
|
44
47
|
import { checkContext, ownerKey, type SecretContext } from '@coffre/core/envelope';
|
|
45
48
|
import { shownMember } from '@coffre/core/schemas';
|
|
46
49
|
import {
|
|
@@ -58,11 +61,23 @@ import {
|
|
|
58
61
|
} from '@coffre/core/kek';
|
|
59
62
|
import {
|
|
60
63
|
checkpointMessage,
|
|
64
|
+
REFUSAL_MESSAGES,
|
|
61
65
|
checkpointVerifier,
|
|
62
66
|
describeAccessFault,
|
|
63
67
|
POLICY_REVISION,
|
|
64
|
-
|
|
68
|
+
answerFrom,
|
|
69
|
+
CLOSED_POLICY,
|
|
70
|
+
live,
|
|
71
|
+
PRINCIPAL,
|
|
72
|
+
CLOSED_SETTINGS,
|
|
73
|
+
livePolicy,
|
|
74
|
+
NO_EDITS,
|
|
65
75
|
policyRevision,
|
|
76
|
+
readEdits,
|
|
77
|
+
roleOf,
|
|
78
|
+
settingsFrom,
|
|
79
|
+
view,
|
|
80
|
+
type PolicyEdits,
|
|
66
81
|
type Access,
|
|
67
82
|
type AccessChange,
|
|
68
83
|
type AccessFault,
|
|
@@ -79,40 +94,84 @@ import {
|
|
|
79
94
|
type LogVerification,
|
|
80
95
|
type Outcome,
|
|
81
96
|
type PolicyChange,
|
|
97
|
+
type Prepared,
|
|
98
|
+
type PrepareInput,
|
|
82
99
|
type Purpose,
|
|
83
100
|
type ReferenceInput,
|
|
84
101
|
type Refusal,
|
|
85
102
|
type RefusalCode,
|
|
86
103
|
type RemoveInput,
|
|
104
|
+
type IssueTokenInput,
|
|
105
|
+
type ChangeBindingsInput,
|
|
106
|
+
type ChangeBindingsOutcome,
|
|
107
|
+
type CutoverInput,
|
|
108
|
+
type CutoverOutcome,
|
|
109
|
+
type ChangeBindingsRefusal,
|
|
110
|
+
type IssueTokenOutcome,
|
|
111
|
+
type IssueTokenRefusal,
|
|
112
|
+
type RevokeCredentialInput,
|
|
113
|
+
type RevokeCredentialOutcome,
|
|
87
114
|
type RewrapInput,
|
|
88
115
|
type SecretRef,
|
|
89
116
|
type SetAccessInput,
|
|
90
117
|
type SetSettingsInput,
|
|
91
118
|
type Settings,
|
|
119
|
+
type Sealing,
|
|
120
|
+
type SignInInput,
|
|
121
|
+
type SignInOutcome,
|
|
122
|
+
type SignInRefusal,
|
|
92
123
|
type Through,
|
|
93
124
|
type UnwrapInput,
|
|
125
|
+
USE_METADATA_LENGTH,
|
|
126
|
+
type Unwrapped,
|
|
127
|
+
type UseInput,
|
|
128
|
+
type NamedUseInput,
|
|
129
|
+
type UseManyInput,
|
|
130
|
+
type UseManyOutcome,
|
|
131
|
+
type NamedUseOutcome,
|
|
132
|
+
type UseOutcome,
|
|
133
|
+
type UseRecord,
|
|
94
134
|
type Wrapped,
|
|
95
135
|
type Vault,
|
|
96
136
|
type Via,
|
|
137
|
+
type ReadsVerification,
|
|
138
|
+
type TreeVerification,
|
|
97
139
|
type VerifyLogInput,
|
|
140
|
+
type VouchedEntry,
|
|
141
|
+
type VouchInput,
|
|
98
142
|
type WrapInput,
|
|
99
143
|
type WrappedKey,
|
|
144
|
+
VOUCHED_AT_ONCE,
|
|
100
145
|
} from '@coffre/core/vault';
|
|
101
146
|
import type { Database, Queryable, Transaction } from '@coffre/db';
|
|
102
147
|
import { isUnavailable, isUniqueViolation, SNAPSHOT } from '@coffre/db/dialect';
|
|
103
|
-
import { everyProjectConversion, newestAccess, reportStands } from '@coffre/db/grants';
|
|
104
|
-
import { appendEntries, lockLogHead, missingEntry, newestAuthentic, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
|
|
148
|
+
import { everyProjectConversion, newestAccess, reportStands, roleColumns, storedRole } from '@coffre/db/grants';
|
|
149
|
+
import { appendEntries, holdFence, holdIssues, lockLogHead, lockLogHeadQueued, missingEntry, newestAuthentic, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
|
|
105
150
|
|
|
151
|
+
import { Changes, keptObjects, Moved, NotLogged, prepareChanges, type ChangeKeys } from './changes.ts';
|
|
106
152
|
import { signer, type Signer } from './checkpoint.ts';
|
|
107
|
-
import {
|
|
153
|
+
import { CutoverRefused, cutoverPayload, cutoverRow, genesisCalls, Moved as VouchMoved, planCutover } from './cutover.ts';
|
|
154
|
+
import { bindingsCall, changeBindings, checkBinding, checkChangeBindings, checkIssue, issueCall, issueToken, revokeCredential, signIn, weighIssue, weighService, type Answer, type IssueRead } from './credentials.ts';
|
|
155
|
+
import { bindingObject, credentialObject } from './objects.ts';
|
|
156
|
+
import { proveIn, type SealThrough } from './checkpointer.ts';
|
|
157
|
+
import { keyRefusals, placeIn, readerOf, referenceRefusal, refuses, sensitiveRefusals, useOnlyRefusals, type Reader, type SecretPlace } from './decide.ts';
|
|
108
158
|
import type { ResolvedVaultConfig } from './config.ts';
|
|
159
|
+
import { limitChange, noEdits, objectKey, POLICY_REF, ROLES_REF, sealed as sealedObject, sealKey, type CredentialInput, type EnvironmentInput, type Input, type Op, type PrincipalInput, type Ref } from './inputs.ts';
|
|
160
|
+
import { TICK_MS } from './log-checkpointer.ts';
|
|
161
|
+
import { authentic, changeEntry, logMacKey, useEntry as usedEntry, type LogMacKey } from './log-entry.ts';
|
|
162
|
+
import { logStore } from './log-store.ts';
|
|
163
|
+
import { checkReads, cursorHolds, readHolds, type ReadRow } from './reads.ts';
|
|
109
164
|
import { carries, further, ownEntries, rehash, relink, START, vaultLogKey, VERIFY_BATCH, withCause, type Position } from './log.ts';
|
|
110
165
|
import { apply, replayFault, type LoggedMember, type Replayed } from './replay.ts';
|
|
166
|
+
import { principalObject } from './objects.ts';
|
|
111
167
|
import { memberMac, rowKey, sameGrants, sealed, standing } from './rows.ts';
|
|
112
168
|
import { snapshot, type Asked, type Snapshot } from './snapshot.ts';
|
|
113
169
|
import * as store from './store.ts';
|
|
114
170
|
import { accessDigest, CHECKPOINT, EMPTY_TALLY, expectedKey, TALLY_VERSION, Walk, type AccessTally, type Expected, type TalliedCheckpoint, type Tally } from './tally.ts';
|
|
115
|
-
import { ACCESS_ACTIONS, type GrantRow, type Member } from './store.ts';
|
|
171
|
+
import { ACCESS_ACTIONS, type EnvironmentRow, type EnvironmentSeal, type GrantRow, type Member } from './store.ts';
|
|
172
|
+
import { use, UseCache, useLimits, useMany, useNamed, type UseLimits, type UsePath } from './use.ts';
|
|
173
|
+
import { verifierKeys, verifyTrees, type VerifierKeys } from './verifier.ts';
|
|
174
|
+
|
|
116
175
|
|
|
117
176
|
export type VaultOptions = {
|
|
118
177
|
/**
|
|
@@ -124,8 +183,51 @@ export type VaultOptions = {
|
|
|
124
183
|
clockOffset?: () => number;
|
|
125
184
|
/** How far one call walks the log (`WALKS`); tests shorten them. */
|
|
126
185
|
walks?: Partial<Walks>;
|
|
186
|
+
/**
|
|
187
|
+
* Which log the vault keeps (0.15's plan, "Behind one switch"): 0.15's,
|
|
188
|
+
* the default since its flip (plan PR 10), where each decision that
|
|
189
|
+
* changes an input also logs it as a change of 0.15's log (`changes.ts`),
|
|
190
|
+
* or 0.14's alone, for the suites until 0.16 removes it. Not a
|
|
191
|
+
* deployment's to set: once cut over, the database refuses 0.14's writes.
|
|
192
|
+
*/
|
|
193
|
+
log?: '0.14' | '0.15';
|
|
194
|
+
/**
|
|
195
|
+
* Under 0.15's log, what a change waits for before it answers: its entry
|
|
196
|
+
* sealed in a signed checkpoint, and witnessed or past the witnesses'
|
|
197
|
+
* bound (R51): the checkpointer's `sealThrough` (checkpointer.ts). A
|
|
198
|
+
* change whose entry it does not seal fails, though committed. Required
|
|
199
|
+
* under 0.15's log.
|
|
200
|
+
*/
|
|
201
|
+
sealThrough?: SealThrough;
|
|
202
|
+
/**
|
|
203
|
+
* Under 0.15's log, the pairs held where no database writer reaches: the
|
|
204
|
+
* signer's memory (`durableHeldOutside` on Workers, the Node runner's
|
|
205
|
+
* `heldOutside`), later a witness's. The verifier holds every walk to
|
|
206
|
+
* them; without, a database rolled back whole to an earlier pair would
|
|
207
|
+
* verify until the signer's next tick refuses it (verifier.ts). Required
|
|
208
|
+
* under 0.15's log, as `sealThrough` is (T151).
|
|
209
|
+
*/
|
|
210
|
+
heldOutside?: () => Promise<readonly PairNotes[]>;
|
|
211
|
+
/**
|
|
212
|
+
* Under 0.15's log, whether the checkpointer seals (T169), bounded: what
|
|
213
|
+
* `sealing()` answers, and `/readyz` asks. The Node runner's `sealing`;
|
|
214
|
+
* on Workers the entrypoint asks the object itself. Without, it seals.
|
|
215
|
+
*/
|
|
216
|
+
sealing?: () => Promise<Sealing> | Sealing;
|
|
217
|
+
/**
|
|
218
|
+
* 0.15's uses, per isolate (use.ts): how many calls in flight at once, by
|
|
219
|
+
* default the hot path's pool, `connections`, and how long a cover holds.
|
|
220
|
+
*/
|
|
221
|
+
uses?: Partial<UseLimits> & { connections?: number };
|
|
222
|
+
/**
|
|
223
|
+
* Whether a call's I/O ends with its request, as on Workers, where a call whose request has ended may never settle:
|
|
224
|
+
* then a use's place in admission lapses past its deadline, settled or not (use.ts, `HELD_PAST_DEADLINE_MS`), its
|
|
225
|
+
* connection closed by then (cloudflare.ts). Not a deployment's to set.
|
|
226
|
+
*/
|
|
227
|
+
callsEndWithRequest?: boolean;
|
|
127
228
|
};
|
|
128
229
|
|
|
230
|
+
|
|
129
231
|
/**
|
|
130
232
|
* A vault's configuration made ready to use, once per process, or once per
|
|
131
233
|
* isolate on Workers: its signer, its log key, and how far it has verified
|
|
@@ -144,7 +246,23 @@ export type PreparedVault = {
|
|
|
144
246
|
logKey: LogKey;
|
|
145
247
|
/** Each signing key's log key: those after the first verify only entries before `since`. */
|
|
146
248
|
logKeys: LogKey[];
|
|
147
|
-
options: Required<Omit<VaultOptions, 'walks'>> & { walks: Walks };
|
|
249
|
+
options: Required<Omit<VaultOptions, 'walks' | 'log' | 'sealThrough' | 'heldOutside' | 'sealing' | 'uses' | 'callsEndWithRequest'>> & { walks: Walks };
|
|
250
|
+
/**
|
|
251
|
+
* 0.15's log, when it is on: the keys its changes are sealed and MACed
|
|
252
|
+
* with, from the vault's signing keys, and what each waits on to answer.
|
|
253
|
+
* Null under 0.14's alone.
|
|
254
|
+
*/
|
|
255
|
+
log015: (ChangeKeys & { sealThrough: SealThrough; heldOutside: () => Promise<readonly PairNotes[]>; sealing: () => Promise<Sealing> | Sealing }) | null;
|
|
256
|
+
/** Whether this process wrote its bulk cap into 0.15's log, or found it there (`#limit`). */
|
|
257
|
+
limited: boolean;
|
|
258
|
+
/** Whether 0.15's log is cut over from 0.14, as this process found it (`cutover`): until then, nothing of 0.15's runs. */
|
|
259
|
+
cutover: boolean;
|
|
260
|
+
/**
|
|
261
|
+
* Whether this process found 0.15's log's cutover written, sealed or not
|
|
262
|
+
* yet (`#settled`): from then on every environment row is sealed, and the
|
|
263
|
+
* vault checks each one it decides on (`#placeFault`).
|
|
264
|
+
*/
|
|
265
|
+
marked: boolean;
|
|
148
266
|
/** How far the chain is rehashed, by any call of this process: its head must still be there. */
|
|
149
267
|
verified: Position;
|
|
150
268
|
/** What member rows are sealed under now; rows.ts. */
|
|
@@ -167,6 +285,12 @@ export type PreparedVault = {
|
|
|
167
285
|
* then everyone the deployment names made an Owner (`#declare`).
|
|
168
286
|
*/
|
|
169
287
|
converted: boolean;
|
|
288
|
+
/** What 0.15's verifier checks the log with (verifier.ts), once a call first needs them. */
|
|
289
|
+
verifier: VerifierKeys | null;
|
|
290
|
+
/** The log's MAC key under each signing key, which `vouch` checks an entry with: made once a call first needs them. */
|
|
291
|
+
vouchKeys?: LogMacKey[];
|
|
292
|
+
/** This isolate's 0.15 uses: their admission count, seal cache and covers (use.ts). */
|
|
293
|
+
uses: UseCache;
|
|
170
294
|
};
|
|
171
295
|
|
|
172
296
|
export async function prepareVault(config: ResolvedVaultConfig, options: VaultOptions = {}): Promise<PreparedVault> {
|
|
@@ -174,6 +298,12 @@ export async function prepareVault(config: ResolvedVaultConfig, options: VaultOp
|
|
|
174
298
|
config,
|
|
175
299
|
...(await keysOf(config.signingKeys)),
|
|
176
300
|
options: { keyBudgetMs: options.keyBudgetMs ?? KEY_BUDGET_MS, clockOffset: options.clockOffset ?? (() => 0), walks: { ...WALKS, ...options.walks } },
|
|
301
|
+
log015: (options.log ?? '0.15') === '0.15' ? log015(config.signingKeys, options.sealThrough, options.heldOutside, options.sealing) : null,
|
|
302
|
+
limited: false,
|
|
303
|
+
cutover: false,
|
|
304
|
+
marked: false,
|
|
305
|
+
verifier: null,
|
|
306
|
+
uses: new UseCache(useLimits(options.uses?.connections ?? DEFAULT_CONNECTIONS, options.uses), { callsEndWithRequest: options.callsEndWithRequest === true }),
|
|
177
307
|
verified: START,
|
|
178
308
|
reported: new Set(),
|
|
179
309
|
since: null,
|
|
@@ -184,6 +314,18 @@ export async function prepareVault(config: ResolvedVaultConfig, options: VaultOp
|
|
|
184
314
|
};
|
|
185
315
|
}
|
|
186
316
|
|
|
317
|
+
/** 0.15's change keys from the vault's signing keys, the first sealing and MACing, each checking a seal. */
|
|
318
|
+
function log015(
|
|
319
|
+
seeds: readonly Uint8Array[],
|
|
320
|
+
sealThrough: SealThrough | undefined,
|
|
321
|
+
heldOutside: (() => Promise<readonly PairNotes[]>) | undefined,
|
|
322
|
+
sealing: (() => Promise<Sealing> | Sealing) = () => ({ ok: true }),
|
|
323
|
+
): NonNullable<PreparedVault['log015']> {
|
|
324
|
+
if (sealThrough === undefined) throw new Error("0.15's log answers a change once its entry is sealed (R51): give the vault the checkpointer's sealThrough");
|
|
325
|
+
if (heldOutside === undefined) throw new Error("0.15's log is verified against the pairs held where no database writer reaches: give the vault the checkpointer's heldOutside");
|
|
326
|
+
return { seals: seeds.map((seed) => sealKey(seed)), mac: logMacKey(seeds[0]!), sealThrough, heldOutside, sealing };
|
|
327
|
+
}
|
|
328
|
+
|
|
187
329
|
/** The vault's own keys, from each of its signing keys, the one it signs with first. */
|
|
188
330
|
async function keysOf(seeds: readonly Uint8Array[]) {
|
|
189
331
|
const signers = await Promise.all(seeds.map((seed) => signer(seed)));
|
|
@@ -192,9 +334,16 @@ async function keysOf(seeds: readonly Uint8Array[]) {
|
|
|
192
334
|
return { signer: signers[0], signers, logKey: logKeys[0], logKeys, rowKey: rowKeys[0], rowKeys };
|
|
193
335
|
}
|
|
194
336
|
|
|
195
|
-
/**
|
|
196
|
-
|
|
197
|
-
|
|
337
|
+
/** A pool's connections, unless its transport says (pg's default). */
|
|
338
|
+
const DEFAULT_CONNECTIONS = 10;
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The vault over `db`, and, for 0.15's uses, over `hot`, the hot path's own
|
|
342
|
+
* login (`coffre_vault_hot`: its lock and statement timeouts, generic
|
|
343
|
+
* plans; schema 0022). Cheap: on Workers, one per call, over that call's connections.
|
|
344
|
+
*/
|
|
345
|
+
export function openVault(db: Database, prepared: PreparedVault, hot?: Database): Required<Vault> {
|
|
346
|
+
return new VaultService(db, prepared, hot);
|
|
198
347
|
}
|
|
199
348
|
|
|
200
349
|
/** Every key operation of one call, together: a removal waits at most this long for a read at KMS. */
|
|
@@ -212,8 +361,45 @@ const LOCK_TIMEOUT_MS = 15_000;
|
|
|
212
361
|
*/
|
|
213
362
|
const READ_LOCK_TIMEOUT_MS = 3_000;
|
|
214
363
|
|
|
215
|
-
/**
|
|
216
|
-
|
|
364
|
+
/**
|
|
365
|
+
* How long a whole read call may take, from its start to its answer, its
|
|
366
|
+
* refusal's entries, the record of a vault key's check and, with a key
|
|
367
|
+
* service, its intent and its own budget (added) included: each transaction's waits are bounded by what is left of it, and
|
|
368
|
+
* past it the call fails as unavailable (`Overdue`) rather than start
|
|
369
|
+
* another. So a caller hears within it, ok or 503, never tens of seconds later.
|
|
370
|
+
*/
|
|
371
|
+
export const READ_DEADLINE_MS = 5_000;
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* How many reads may wait for the log's head at once, through all of the
|
|
375
|
+
* vault's connections; the next is refused at once, as unavailable, holding
|
|
376
|
+
* no connection while it waits (`lockLogHeadQueued`). A read holds the head
|
|
377
|
+
* for a few round trips, about 150 ms through Hyperdrive, so this many
|
|
378
|
+
* clear within a read's bound (2.4 s of its 3), and no more than this many
|
|
379
|
+
* of the pool's connections wait. A burst of 50 reads at a 2-vCPU database
|
|
380
|
+
* a 30 ms round trip away: 17 read within 2.2 s, 33 refused at once.
|
|
381
|
+
*/
|
|
382
|
+
export const READ_HEAD_WAITERS = 16;
|
|
383
|
+
|
|
384
|
+
/** How many releases a checkpoint prunes at most: one short statement, a few each scheduled run. */
|
|
385
|
+
const RELEASES_PRUNED = 10_000;
|
|
386
|
+
|
|
387
|
+
/** A read's call past its deadline (`READ_DEADLINE_MS`): unavailable, as a statement past its bound (57014) is. */
|
|
388
|
+
class Overdue extends Error {
|
|
389
|
+
readonly code = '57014';
|
|
390
|
+
constructor() {
|
|
391
|
+
super('this read took longer than it may: ask again shortly');
|
|
392
|
+
this.name = 'Overdue';
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** The bound of a transaction of a call due at `deadline`: `ms`, or what is left of the call, if less. */
|
|
397
|
+
function within(deadline: number | undefined, ms: number): number {
|
|
398
|
+
if (deadline === undefined) return ms;
|
|
399
|
+
const left = deadline - Date.now();
|
|
400
|
+
if (left <= 0) throw new Overdue();
|
|
401
|
+
return Math.min(ms, left);
|
|
402
|
+
}
|
|
217
403
|
|
|
218
404
|
/** A person, and a service account: only a person sets one up (`givesService`). */
|
|
219
405
|
const PERSON = /^user:/;
|
|
@@ -222,6 +408,14 @@ const SERVICE = /^token:/;
|
|
|
222
408
|
/** Who acts for the vault itself, as when it makes an Owner its deployment names. */
|
|
223
409
|
const VAULT_ACTOR = 'system:vault';
|
|
224
410
|
|
|
411
|
+
/** The cutover's own entry in 0.14's log (`cutover`): the head its change in 0.15's names, or, denied, why it did not cut over. */
|
|
412
|
+
const CUTOVER = 'log.cutover';
|
|
413
|
+
|
|
414
|
+
/** Genesis changes a statement: eleven parameters each, well within Postgres's 65,535 a statement. */
|
|
415
|
+
const GENESIS_BATCH = 2000;
|
|
416
|
+
|
|
417
|
+
const NOT_CUT_OVER = "0.15's log is not cut over from 0.14 yet: the app asks the vault to (`cutover`) before anything else";
|
|
418
|
+
|
|
225
419
|
// A KEK's check: a known value, the size of a data key, wrapped under it in
|
|
226
420
|
// a context no secret has (the nil UUID), and kept in a `key.check` entry
|
|
227
421
|
// (@coffre/core/kek). Opening it again tells the vault its KEK is the one
|
|
@@ -237,53 +431,45 @@ const KEY_ROTATE = 'key.rotate';
|
|
|
237
431
|
/** A new member row, before the decision seals it (`#seal`). */
|
|
238
432
|
const UNSEALED = { accessSeq: 0n, mac: Buffer.alloc(32) };
|
|
239
433
|
|
|
240
|
-
/**
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const SETTINGS = 'settings.change';
|
|
245
|
-
|
|
246
|
-
/** The settings before anyone changed them: people set up service accounts anywhere they hold access. */
|
|
247
|
-
const DEFAULT_SETTINGS: Settings = { serviceAccounts: EVERYWHERE };
|
|
434
|
+
/** A member's object in 0.15's log (inputs.ts). */
|
|
435
|
+
function memberRef(principal: string) {
|
|
436
|
+
return { kind: 'principal' as const, id: principal };
|
|
437
|
+
}
|
|
248
438
|
|
|
249
|
-
/**
|
|
250
|
-
|
|
439
|
+
/** An instance role and its scope as a member's row gives them back (`roleColumns`, then `storedRole`): what 0.15's ops carry, so that the object they make is the row's. */
|
|
440
|
+
function asStored(role: InstanceRole, scope: Scope): { role: InstanceRole; scope: Scope } {
|
|
441
|
+
return storedRole(roleColumns(role, scope));
|
|
442
|
+
}
|
|
251
443
|
|
|
252
|
-
/**
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
*/
|
|
257
|
-
type PolicyEdits = { roles: Record<string, RoleDefinition>; instanceRoles: Partial<Record<InstanceRole, RoleDefinition>>; protected: ProtectedPlace[] };
|
|
444
|
+
/** A grant's place, and what it holds, as a member's object in 0.15's log has it. */
|
|
445
|
+
function inputGrant(grant: Pick<GrantRow, 'projectId' | 'environmentId' | 'environmentSlug' | 'role' | 'expiresAt'>) {
|
|
446
|
+
return { projectId: grant.projectId, environmentId: grant.environmentId, environmentSlug: grant.environmentSlug, role: grant.role, expiresAt: grant.expiresAt };
|
|
447
|
+
}
|
|
258
448
|
|
|
259
449
|
/**
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
* an id with no row ten minutes later is a fork that was never written.
|
|
450
|
+
* The environments an issue was weighed on, each once, with the slug it was weighed with first: what `issue_token`
|
|
451
|
+
* holds under the issue lock (IssuePolicyHeld).
|
|
263
452
|
*/
|
|
264
|
-
|
|
453
|
+
function weighed(slugs: readonly { environmentId: string; slug: string | null }[]): { environmentId: string; slug: string | null }[] {
|
|
454
|
+
const seen = new Set<string>();
|
|
455
|
+
return slugs.filter(({ environmentId }) => !seen.has(environmentId) && seen.add(environmentId) !== undefined);
|
|
456
|
+
}
|
|
265
457
|
|
|
458
|
+
function placeOf(grant: store.Place) {
|
|
459
|
+
return { projectId: grant.projectId ?? null, environmentId: grant.environmentId ?? null, environmentSlug: grant.environmentSlug ?? null };
|
|
460
|
+
}
|
|
266
461
|
|
|
267
|
-
|
|
462
|
+
/** Why a change to a tampered member is refused. */
|
|
463
|
+
const TAMPERED_SUBJECT = "this member's record failed the vault's integrity check: remove them to start over";
|
|
268
464
|
|
|
269
|
-
/**
|
|
270
|
-
const
|
|
465
|
+
/** Why a decision on a place whose row is not as 0.15's log holds it is refused (T159): the row, not the member, is at fault. */
|
|
466
|
+
const TAMPERED_PLACE = "an environment it is about failed the vault's integrity check: its row is not as the log holds it";
|
|
271
467
|
|
|
272
|
-
/**
|
|
273
|
-
|
|
274
|
-
* whoever inserted rows may have hidden an Owner's narrowing, so no role
|
|
275
|
-
* holds anything, a service account's neither, but Owner, which holds
|
|
276
|
-
* everything always and so may write the policy again.
|
|
277
|
-
*/
|
|
278
|
-
const CLOSED_POLICY: Policy = {
|
|
279
|
-
roles: Object.fromEntries(Object.entries(ROLES).map(([id, definition]) => [id, id === 'owner' ? definition : NOTHING_HELD(definition)])),
|
|
280
|
-
instanceRoles: Object.fromEntries(Object.entries(INSTANCE_ROLES).map(([id, definition]) => [id, id === 'owner' ? definition : NOTHING_HELD(definition)])) as Policy['instanceRoles'],
|
|
281
|
-
protected: [EVERY_ENVIRONMENT],
|
|
282
|
-
protectedIn: {},
|
|
283
|
-
};
|
|
468
|
+
/** The action of the vault's entry that holds the instance's settings, and changes them (`settings`). */
|
|
469
|
+
const SETTINGS = 'settings.change';
|
|
284
470
|
|
|
285
|
-
/** The
|
|
286
|
-
const
|
|
471
|
+
/** The action of the vault's entry that holds the instance's access policy, and changes it (`policy`). */
|
|
472
|
+
const POLICY = 'policy.change';
|
|
287
473
|
|
|
288
474
|
/** Why a change to who is an Owner, scoped or not, is refused to anyone else: Owners make and unmake Owners. */
|
|
289
475
|
const OWNERS_ONLY = 'only an Owner of the whole instance makes someone an Owner, or changes or removes one';
|
|
@@ -316,6 +502,9 @@ class Retry {}
|
|
|
316
502
|
/** What each `vault.tampered` entry reports, to mark it logged once committed. */
|
|
317
503
|
const REPORTED = new WeakMap<NewEntry, string>();
|
|
318
504
|
|
|
505
|
+
/** Each environment row's check, once made (`#placeFault`): one HMAC a row, however many parts of a decision read it. */
|
|
506
|
+
const PLACE_FAULTS = new WeakMap<EnvironmentSeal, 'tampered' | 'stale' | null>();
|
|
507
|
+
|
|
319
508
|
/** A key service that did not answer, and the entries that record what it did do. */
|
|
320
509
|
class Outage {
|
|
321
510
|
readonly error: unknown;
|
|
@@ -334,29 +523,6 @@ function refusal(code: RefusalCode, message: string): Refusal {
|
|
|
334
523
|
return { code, message };
|
|
335
524
|
}
|
|
336
525
|
|
|
337
|
-
const MESSAGES: Record<RefusalCode, string> = {
|
|
338
|
-
removed: 'this member was removed',
|
|
339
|
-
not_a_member: 'not a member',
|
|
340
|
-
no_grant: 'no grant covers this',
|
|
341
|
-
expired: 'the grant that covered this has expired',
|
|
342
|
-
bulk_limit: 'too many secrets read in too short a time',
|
|
343
|
-
bad_claim: 'the key does not belong to this secret',
|
|
344
|
-
deleted: 'this project or environment was deleted',
|
|
345
|
-
ended: 'the reference read through was ended',
|
|
346
|
-
personal: "a personal config's values are its owner's alone",
|
|
347
|
-
sensitive: "a sensitive value is never shown to anyone, Owners included: `coffre run` gives your app a placeholder for it through the credential proxy, and a service account reads it",
|
|
348
|
-
not_owner_issued: 'a service account reads a sensitive value only on a token or trust binding an Owner of its project issued',
|
|
349
|
-
use_only: "a workload uses this value through coffre and never holds it: list it in its binding's reads, where the value is held, if it must",
|
|
350
|
-
not_allowed: 'not allowed to change this',
|
|
351
|
-
last_owner: 'the last Owner of the whole instance stays one: make someone else an Owner first',
|
|
352
|
-
invalid: 'not something the rules allow',
|
|
353
|
-
policy_changed: "the instance's roles or protected environments changed while this was asked: try again",
|
|
354
|
-
access_changed: 'what this member holds changed since the read was decided: try again',
|
|
355
|
-
log_broken: 'the vault log does not hold from the last checkpoint',
|
|
356
|
-
wrong_kek: "this vault's key does not open the data it holds",
|
|
357
|
-
tampered: "this member's record failed the vault's integrity check",
|
|
358
|
-
};
|
|
359
|
-
|
|
360
526
|
/**
|
|
361
527
|
* A decision in progress: its transaction, the member rows it locked, its
|
|
362
528
|
* time, and what it will commit. Changes to members and grants wait until
|
|
@@ -392,6 +558,8 @@ type Decision = {
|
|
|
392
558
|
* was deciding. A `Refused` there refuses the decision.
|
|
393
559
|
*/
|
|
394
560
|
underLock: ((locked: Queryable) => Promise<void>)[];
|
|
561
|
+
/** Under 0.15's log, the changes its writes make, each staged beside its write (`changes.ts`); null under 0.14's alone. */
|
|
562
|
+
changes: Changes | null;
|
|
395
563
|
};
|
|
396
564
|
|
|
397
565
|
/**
|
|
@@ -406,7 +574,12 @@ type Reads = Pick<Asked, 'environments' | 'references'> & { released?: { princip
|
|
|
406
574
|
* is what it reads besides its members, and `reads` says it changes none
|
|
407
575
|
* of them (`#decide`).
|
|
408
576
|
*/
|
|
409
|
-
|
|
577
|
+
/**
|
|
578
|
+
* How a decision is made. `deadline`, a read's (`reads`): when its whole
|
|
579
|
+
* call must have answered, every wait in every transaction it takes
|
|
580
|
+
* included (`READ_DEADLINE_MS`).
|
|
581
|
+
*/
|
|
582
|
+
type How = { late?: boolean; asked?: Reads; reads?: boolean; deadline?: number };
|
|
410
583
|
|
|
411
584
|
/** The actions of the entries the vault writes about keys. */
|
|
412
585
|
type KeyAction = 'secret.read' | 'key.wrap' | 'key.rewrap';
|
|
@@ -475,11 +648,64 @@ class VaultService implements Vault {
|
|
|
475
648
|
readonly #db: Database;
|
|
476
649
|
readonly #prepared: PreparedVault;
|
|
477
650
|
readonly #config: ResolvedVaultConfig;
|
|
651
|
+
readonly #hot: Database | undefined;
|
|
478
652
|
|
|
479
|
-
constructor(db: Database, prepared: PreparedVault) {
|
|
653
|
+
constructor(db: Database, prepared: PreparedVault, hot?: Database) {
|
|
480
654
|
this.#db = db;
|
|
481
655
|
this.#prepared = prepared;
|
|
482
656
|
this.#config = prepared.config;
|
|
657
|
+
this.#hot = hot;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** 0.15's use (use.ts), on the hot path's login. */
|
|
661
|
+
async use(input: UseInput): Promise<UseOutcome> {
|
|
662
|
+
return use(this.#usePath(), input);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** 0.15's use as the request names it (use.ts, `useNamed`), on the hot path's login. */
|
|
666
|
+
async useNamed(input: NamedUseInput): Promise<NamedUseOutcome> {
|
|
667
|
+
return useNamed(this.#usePath(), input);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** A request's several keys as one call (use.ts, `useMany`; T167), on the hot path's login. */
|
|
671
|
+
async useMany(input: UseManyInput): Promise<UseManyOutcome> {
|
|
672
|
+
return useMany(this.#usePath(), input);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** What 0.15's uses run on, every way they are asked (use.ts, `UsePath`). */
|
|
676
|
+
#usePath(): UsePath {
|
|
677
|
+
// Behind the switch: a vault keeping 0.14's log alone has no 0.15 objects to decide on.
|
|
678
|
+
const { log015, uses: cache } = this.#prepared;
|
|
679
|
+
if (log015 === null) throw new Error("0.15's uses need 0.15's log: the vault's log option is '0.14'");
|
|
680
|
+
if (this.#hot === undefined) throw new Error("0.15's uses need the hot path's login: configure the vault's hotDatabase");
|
|
681
|
+
// An isolate's first uses read the cutover's marker (`#cutOverYet`) within their admission, as any call holds a connection.
|
|
682
|
+
const ready = this.#prepared.cutover ? undefined : () => this.#cutOverYet();
|
|
683
|
+
return {
|
|
684
|
+
db: this.#hot, sealKeys: log015.seals, macKey: log015.mac, keks: this.#config.keks, cache, ...(ready === undefined ? {} : { ready }),
|
|
685
|
+
oldWrite: (principal, old, through) => this.#oldWrite(principal, old, through),
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Whether 0.14's newest access entry about `principal` that carries the
|
|
691
|
+
* vault's MAC, at or below `through`, is past `old`, what the principal's
|
|
692
|
+
* newest change accounts for (Migration015.tla's UsesSeeOldWrites, of the
|
|
693
|
+
* newest authentic write): asked by a use whose probe found `through` in
|
|
694
|
+
* the vault's name past it, which, but for a write the cutover refused to
|
|
695
|
+
* let land, is one forged there. Read from `through` down, never above: the
|
|
696
|
+
* use's statement committed before this reads, and a change's entry since
|
|
697
|
+
* is none it decided on (review r1). Each forged one is passed over; past
|
|
698
|
+
* `LOOK_BACK` of them, the use is refused as if it were not. It writes
|
|
699
|
+
* nothing: no report, which would wait for 0.14's head, or for the
|
|
700
|
+
* vault's settling, past the use's deadline (reviews r3, r4). The next
|
|
701
|
+
* decision about the principal reports what is forged (`#integrity`).
|
|
702
|
+
*/
|
|
703
|
+
async #oldWrite(principal: string, old: bigint | null, through: bigint): Promise<boolean> {
|
|
704
|
+
// On Workers the use's vault is opened over its one hot connection (cloudflare.ts, `openVault(hot, prepared, hot)`):
|
|
705
|
+
// this reads on it, never a second connection in the call.
|
|
706
|
+
const newest = await newestAccess(this.#db, principal, { authentic: (entry) => this.#authentic(entry), forged: () => {} }, through);
|
|
707
|
+
if (newest === 'unreached') return true;
|
|
708
|
+
return typeof newest !== 'string' && (old === null || newest.seq > old);
|
|
483
709
|
}
|
|
484
710
|
|
|
485
711
|
async #now(db: Queryable): Promise<number> {
|
|
@@ -492,12 +718,15 @@ class VaultService implements Vault {
|
|
|
492
718
|
}
|
|
493
719
|
|
|
494
720
|
/** What a decision about `principals` reads, and `asked` besides, in one statement: under the head when `head` is set. */
|
|
495
|
-
#snapshot(tx: Transaction, principals: readonly string[], asked: Reads, head: boolean): Promise<Snapshot> {
|
|
721
|
+
#snapshot(tx: Transaction, principals: readonly string[], asked: Reads, head: boolean, reading = false): Promise<Snapshot> {
|
|
496
722
|
const { released, ...rest } = asked;
|
|
497
723
|
return snapshot(tx, {
|
|
498
724
|
...rest,
|
|
499
725
|
principals,
|
|
500
726
|
head,
|
|
727
|
+
seals: this.#checksPlaces(),
|
|
728
|
+
// A read waits for the head in one of a few places in line, or is refused at once.
|
|
729
|
+
...(reading ? { waiters: READ_HEAD_WAITERS } : {}),
|
|
501
730
|
...(released === undefined ? {} : { released: { ...released, windowMs: this.#config.bulkLimit.windowMs, clockOffsetMs: this.#prepared.options.clockOffset(), cap: this.#config.bulkLimit.count } }),
|
|
502
731
|
});
|
|
503
732
|
}
|
|
@@ -524,30 +753,39 @@ class VaultService implements Vault {
|
|
|
524
753
|
|
|
525
754
|
/** `#decide`, once the vault is settled. */
|
|
526
755
|
async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, how: How = {}): Promise<Outcome<T>> {
|
|
756
|
+
// Every decision of 0.15's log's after its cutover, whichever path it comes by (`#declare`, `#convert`, `#limit`).
|
|
757
|
+
await this.#cutOverYet();
|
|
527
758
|
const reports: NewEntry[] = [];
|
|
759
|
+
// A read's call is due from its first decision, unless its caller set when.
|
|
760
|
+
const due = how.reads === true ? { ...how, deadline: how.deadline ?? Date.now() + READ_DEADLINE_MS } : how;
|
|
528
761
|
for (let attempt = 1; ; attempt += 1) {
|
|
529
762
|
try {
|
|
530
|
-
return await this.#decideOnce(principals, decide, reports,
|
|
763
|
+
return await this.#decideOnce(principals, decide, reports, due);
|
|
531
764
|
} catch (error) {
|
|
532
765
|
// A member who had no row when this decision locked theirs, and has
|
|
533
766
|
// one now: another decision admitted them meanwhile. Again, with it.
|
|
534
|
-
|
|
767
|
+
// A 0.15 change's row moved from the seal it read (ChangeCAS): read it again (ChangeRereads).
|
|
768
|
+
if (attempt < 3 && (error instanceof Retry || error instanceof Moved || isUniqueViolation(error))) continue;
|
|
535
769
|
throw error;
|
|
536
770
|
}
|
|
537
771
|
}
|
|
538
772
|
}
|
|
539
773
|
|
|
540
|
-
async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], { late = false, asked = {}, reads: shared = false }: How): Promise<Outcome<T>> {
|
|
774
|
+
async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], { late = false, asked = {}, reads: shared = false, deadline }: How): Promise<Outcome<T>> {
|
|
541
775
|
try {
|
|
542
|
-
const
|
|
543
|
-
await store.boundLockWaits(tx, shared ? READ_LOCK_TIMEOUT_MS : LOCK_TIMEOUT_MS, shared);
|
|
776
|
+
const committed = await this.#db.transaction(async (tx) => {
|
|
777
|
+
await store.boundLockWaits(tx, shared ? within(deadline, READ_LOCK_TIMEOUT_MS) : LOCK_TIMEOUT_MS, shared);
|
|
544
778
|
const members = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals, shared);
|
|
545
779
|
// Then the head, and everything the decision reads, in one statement (`snapshot` says why it holds).
|
|
546
|
-
const reads = await this.#snapshot(tx, principals, asked, !late);
|
|
780
|
+
const reads = await this.#snapshot(tx, principals, asked, !late, shared);
|
|
781
|
+
const log015 = this.#prepared.log015;
|
|
547
782
|
const d: Decision = {
|
|
548
783
|
tx, members, at: this.#at(reads), reads, log: [], writes: [], touched: new Set(), grants: new Map(), reports, after: [], underLock: [],
|
|
784
|
+
changes: log015 === null ? null : new Changes(log015),
|
|
549
785
|
};
|
|
550
786
|
const result = await decide(d);
|
|
787
|
+
// Each object 0.15's log will change, read and checked before the writes change its rows.
|
|
788
|
+
await d.changes?.prepare(tx);
|
|
551
789
|
// A read is logged once per place, whichever way it ends (`readsByPlace`).
|
|
552
790
|
const entries = [...reports, ...readsByPlace(d.log)];
|
|
553
791
|
let at = d.at;
|
|
@@ -568,18 +806,36 @@ class VaultService implements Vault {
|
|
|
568
806
|
const sealing = new Set([...d.touched, ...accessSeq.keys()]);
|
|
569
807
|
// Under a share lock, a write to the row would wait for every other read holding it, and they for it.
|
|
570
808
|
if (shared && sealing.size > 0) throw new Error('a read changes no member, and seals no row');
|
|
571
|
-
|
|
572
|
-
|
|
809
|
+
const written = new Map<string, Input>();
|
|
810
|
+
for (const principal of sealing) {
|
|
811
|
+
const resealed = await this.#seal(d, principal, accessSeq.get(principal));
|
|
812
|
+
if (resealed === null) continue;
|
|
813
|
+
written.set(objectKey(memberRef(principal)), resealed.object);
|
|
814
|
+
d.changes?.cite(memberRef(principal), resealed.accessSeq);
|
|
815
|
+
}
|
|
816
|
+
// Last: the fence, which they take exclusive (0.14's append took it already, OldWritesFence), is held from
|
|
817
|
+
// here to the commit, so as few round trips as can be. The append comes first, and its trigger took the issue
|
|
818
|
+
// lock before the fence (0026): a change taking the fence before the issue lock would wait in a circle with the
|
|
819
|
+
// barrier, which takes them in that order (Migration015.tla, IssueBeforeFence).
|
|
820
|
+
const numbers = d.changes === null ? [] : await d.changes.commit(tx, at, written);
|
|
821
|
+
return { result, numbers };
|
|
573
822
|
});
|
|
574
823
|
this.#reported(reports);
|
|
575
|
-
|
|
576
|
-
|
|
824
|
+
// A change answers once its entries are sealed (R51; HotPathOne's CommitBeforeAnswer): the newest of them, with
|
|
825
|
+
// every number below it.
|
|
826
|
+
if (committed.numbers.length > 0) await this.#prepared.log015!.sealThrough(committed.numbers.reduce((a, b) => (a > b ? a : b)));
|
|
827
|
+
return { ok: true, ...committed.result };
|
|
828
|
+
} catch (caught) {
|
|
829
|
+
const error = caught instanceof NotLogged ? this.#notLogged(reports, caught) : caught;
|
|
577
830
|
if (!(error instanceof Refused || error instanceof Outage)) throw error;
|
|
578
831
|
const entries = [...reports, ...readsByPlace(error.entries)];
|
|
579
832
|
if (entries.length > 0) {
|
|
580
|
-
// A refusal's entries are written whatever the decision's bound: it answers only once they are.
|
|
833
|
+
// A refusal's entries are written whatever the decision's bound: it answers only once they are. A read's, within
|
|
834
|
+
// its call's deadline: past it, the call fails as unavailable, having released nothing.
|
|
581
835
|
await this.#db.transaction(async (tx) => {
|
|
582
|
-
await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
836
|
+
await store.boundLockWaits(tx, shared ? within(deadline, READ_LOCK_TIMEOUT_MS) : LOCK_TIMEOUT_MS, shared);
|
|
837
|
+
// In one of the head's places in line, as the read's decision waited, or refused at once.
|
|
838
|
+
if (shared) await lockLogHeadQueued(tx, READ_HEAD_WAITERS);
|
|
583
839
|
await this.#append(tx, entries);
|
|
584
840
|
});
|
|
585
841
|
}
|
|
@@ -594,19 +850,23 @@ class VaultService implements Vault {
|
|
|
594
850
|
* naming `accessSeq`, their newest access entry, when it wrote one. The
|
|
595
851
|
* table must hold exactly that: a grant written around the vault while it
|
|
596
852
|
* decided (its row lock does not stop one) is refused, with the decision,
|
|
597
|
-
* rather than sealed in.
|
|
853
|
+
* rather than sealed in. It answers the member's object as the rows now
|
|
854
|
+
* hold it, and the access entry their row names, for 0.15's log.
|
|
598
855
|
*/
|
|
599
|
-
async #seal(d: Decision, principal: string, accessSeq: bigint | undefined): Promise<
|
|
856
|
+
async #seal(d: Decision, principal: string, accessSeq: bigint | undefined): Promise<{ object: PrincipalInput; accessSeq: bigint } | null> {
|
|
600
857
|
const row = await store.member(d.tx, principal);
|
|
601
|
-
if (row === undefined) return;
|
|
858
|
+
if (row === undefined) return null;
|
|
602
859
|
const decided = d.grants.get(principal);
|
|
603
860
|
if (decided === undefined) throw new Error(`sealing ${principal} without the grants this decision verified`);
|
|
604
861
|
if (!sameGrants(await store.grants(d.tx, principal), decided)) {
|
|
605
862
|
this.#report(d.reports, principal, 'mac', row.mac.toString('hex'));
|
|
606
|
-
throw new Refused(refusal('tampered',
|
|
863
|
+
throw new Refused(refusal('tampered', REFUSAL_MESSAGES.tampered), []);
|
|
607
864
|
}
|
|
608
865
|
const next = { ...row, accessSeq: accessSeq ?? row.accessSeq };
|
|
609
866
|
await store.updateMember(d.tx, principal, { accessSeq: next.accessSeq, mac: memberMac(this.#prepared.rowKey, next, decided) });
|
|
867
|
+
// The member's object as the rows hold it now, which 0.15's change of it must have left, and the access entry its
|
|
868
|
+
// row names, which that change accounts for (`changes.ts`).
|
|
869
|
+
return { object: principalObject(row, decided), accessSeq: next.accessSeq };
|
|
610
870
|
}
|
|
611
871
|
|
|
612
872
|
/**
|
|
@@ -633,6 +893,89 @@ class VaultService implements Vault {
|
|
|
633
893
|
return 'stale';
|
|
634
894
|
}
|
|
635
895
|
|
|
896
|
+
/**
|
|
897
|
+
* Whether the vault checks the environment rows it decides on against
|
|
898
|
+
* 0.15's log (T159): under it, once its cutover is written, which seals
|
|
899
|
+
* every row. Every read of them asks their seals then (`seals`).
|
|
900
|
+
*/
|
|
901
|
+
#checksPlaces(): boolean {
|
|
902
|
+
const { log015, cutover, marked } = this.#prepared;
|
|
903
|
+
return log015 !== null && (cutover || marked);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Why environment `id`'s row, as a decision read it, is not as 0.15's log
|
|
908
|
+
* holds it (T159; R53, every decision input sealed): its seal is not the
|
|
909
|
+
* vault's over its object (`tampered`: re-parented, renamed, its tombstone
|
|
910
|
+
* set or cleared around the log), or not its object's newest change's
|
|
911
|
+
* (`stale`: put back from before a change, or written with none). A live
|
|
912
|
+
* object in a project whose slug is a tombstone is `tampered` too: a
|
|
913
|
+
* project's deletion under 0.15's log deletes each of its environments, as
|
|
914
|
+
* the cutover sealed them, so only a writer leaves one so (review r2). Each
|
|
915
|
+
* is reported, once, as a change refused on it is (`#notLogged`), the row's
|
|
916
|
+
* and not any member's. Null when it is as the log holds it, and for a row
|
|
917
|
+
* read without its seals: under 0.14's log alone, or before the cutover.
|
|
918
|
+
*/
|
|
919
|
+
#placeFault(id: string, row: EnvironmentRow, reports: NewEntry[]): 'tampered' | 'stale' | null {
|
|
920
|
+
const read = row.sealed;
|
|
921
|
+
if (read === undefined) return null;
|
|
922
|
+
let fault = PLACE_FAULTS.get(read);
|
|
923
|
+
if (fault === undefined) {
|
|
924
|
+
fault = !sealedObject(this.#prepared.log015!.seals, { kind: 'environment', id }, read.object, read.seal) || (row.deleted && !read.object.deleted) ? 'tampered'
|
|
925
|
+
: read.newest === null || !read.newest.equals(read.seal!) ? 'stale' : null;
|
|
926
|
+
PLACE_FAULTS.set(read, fault);
|
|
927
|
+
}
|
|
928
|
+
if (fault !== null) this.#report(reports, null, fault === 'tampered' ? 'mac' : 'stale', `log.environment:${id}`);
|
|
929
|
+
return fault;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* A member's grants whose places hold: each on an environment, on a row
|
|
934
|
+
* as 0.15's log holds it, which its project is read from (the grant's row
|
|
935
|
+
* names none of its own). One on a row that is not, or on no row, holds
|
|
936
|
+
* nothing (T159): where it is is a writer's. Their other grants stand.
|
|
937
|
+
* Each row from `rows` when there, else read, with its seals.
|
|
938
|
+
*/
|
|
939
|
+
async #heldGrants(db: Queryable, grants: readonly GrantRow[], reports: NewEntry[], rows?: ReadonlyMap<string, EnvironmentRow>): Promise<GrantRow[]> {
|
|
940
|
+
if (!this.#checksPlaces()) return [...grants];
|
|
941
|
+
const ids = [...new Set(grants.flatMap((grant) => (grant.environmentId === null ? [] : [grant.environmentId])))];
|
|
942
|
+
const missing = ids.filter((id) => !rows?.has(id));
|
|
943
|
+
const read = missing.length === 0 ? new Map<string, EnvironmentRow>() : await store.environmentsById(db, missing, { seals: true });
|
|
944
|
+
const faulty = new Set(ids.filter((id) => {
|
|
945
|
+
const row = rows?.get(id) ?? read.get(id);
|
|
946
|
+
if (row !== undefined) return this.#placeFault(id, row, reports) !== null;
|
|
947
|
+
this.#report(reports, null, 'stale', `log.environment:${id}`);
|
|
948
|
+
return true;
|
|
949
|
+
}));
|
|
950
|
+
return grants.filter((grant) => grant.environmentId === null || !faulty.has(grant.environmentId));
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Refuse a decision about places whose rows are not as 0.15's log holds
|
|
955
|
+
* them (`#placeFault`), each reported: what it would decide on them is a
|
|
956
|
+
* writer's (T159). Ids with no row are left to the decision, which knows
|
|
957
|
+
* no such place.
|
|
958
|
+
*/
|
|
959
|
+
#placesHold(rows: ReadonlyMap<string, EnvironmentRow>, ids: Iterable<string>, reports: NewEntry[]): void {
|
|
960
|
+
let hold = true;
|
|
961
|
+
for (const id of new Set(ids)) {
|
|
962
|
+
const row = rows.get(id);
|
|
963
|
+
if (row !== undefined && this.#placeFault(id, row, reports) !== null) hold = false;
|
|
964
|
+
}
|
|
965
|
+
if (!hold) throw new Refused(refusal('tampered', TAMPERED_PLACE), []);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* The policy `edits` make over the rows of the environments they protect
|
|
970
|
+
* (`livePolicy`), a row not as 0.15's log holds it taken as still
|
|
971
|
+
* protected, its slug unknown: one deleted or renamed around the log
|
|
972
|
+
* narrows what anyone holds there, never widens it (T159).
|
|
973
|
+
*/
|
|
974
|
+
#livePolicy(rows: ReadonlyMap<string, EnvironmentRow>, edits: PolicyEdits, at: number, reports: NewEntry[]): Policy {
|
|
975
|
+
const held = new Map([...rows].map(([id, row]) => [id, this.#placeFault(id, row, reports) === null ? row : { ...row, slug: null, deleted: false }]));
|
|
976
|
+
return livePolicy(held, edits, at);
|
|
977
|
+
}
|
|
978
|
+
|
|
636
979
|
/** The newest access entry about `principal` that carries the vault's MAC (`newestAccess` in @coffre/db/grants). */
|
|
637
980
|
#newestAccess(db: Queryable, principal: string, reports: NewEntry[], reads?: Snapshot): Promise<Newest> {
|
|
638
981
|
if (reads === undefined || !reads.grants.has(principal)) return newestAccess(db, principal, this.#check(reports, principal));
|
|
@@ -690,6 +1033,18 @@ class VaultService implements Vault {
|
|
|
690
1033
|
reports.push(entry);
|
|
691
1034
|
}
|
|
692
1035
|
|
|
1036
|
+
/**
|
|
1037
|
+
* A change refused because 0.15's log does not hold its object as its row
|
|
1038
|
+
* is (`changes.ts`): a row put back, or written around the vault. Reported
|
|
1039
|
+
* as a tampered row is, and refused: adoption or a reseal (plan PR 9)
|
|
1040
|
+
* makes it the log's again, never a change on top of it.
|
|
1041
|
+
*/
|
|
1042
|
+
#notLogged(reports: NewEntry[], error: NotLogged): Refused {
|
|
1043
|
+
const { ref } = error;
|
|
1044
|
+
this.#report(reports, ref?.kind === 'principal' ? ref.id : null, error.fault === 'tampered' ? 'mac' : 'stale', ref === null ? 'log.object' : `log.${ref.kind}:${ref.id}`);
|
|
1045
|
+
return new Refused(refusal('tampered', error.message), []);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
693
1048
|
/** Mark these reports as logged, once their transaction has committed. */
|
|
694
1049
|
#reported(reports: readonly NewEntry[]): void {
|
|
695
1050
|
for (const entry of reports) {
|
|
@@ -718,8 +1073,15 @@ class VaultService implements Vault {
|
|
|
718
1073
|
// database; calls that race settle it alike, and a rotation they race
|
|
719
1074
|
// to write goes in once (`#rotate`).
|
|
720
1075
|
if (prepared.since === null && prepared.superseded === null) await this.#settle();
|
|
1076
|
+
// Under 0.15's log, nothing of its own before the cutover (`cutover`): the conversion and the Owners it names, and
|
|
1077
|
+
// the bulk cap, after it, as decisions of 0.15's log.
|
|
1078
|
+
if (prepared.superseded === null && prepared.log015 !== null && !prepared.cutover && !prepared.marked) {
|
|
1079
|
+
if (!(await store.cutOver(this.#db))) return prepared.superseded;
|
|
1080
|
+
prepared.marked = true;
|
|
1081
|
+
}
|
|
721
1082
|
// A log with no entry of the vault's holds nothing to convert, and stays empty until someone acts.
|
|
722
1083
|
if (prepared.superseded === null && prepared.since !== null && !prepared.converted) await this.#convert();
|
|
1084
|
+
if (prepared.superseded === null && prepared.log015 !== null && !prepared.limited) await this.#limit();
|
|
723
1085
|
return prepared.superseded;
|
|
724
1086
|
}
|
|
725
1087
|
|
|
@@ -751,6 +1113,30 @@ class VaultService implements Vault {
|
|
|
751
1113
|
this.#prepared.converted = await this.#declare(this.#config.owners.map((email) => `user:${email}`));
|
|
752
1114
|
}
|
|
753
1115
|
|
|
1116
|
+
/**
|
|
1117
|
+
* Under 0.15's log, the deployment's bulk cap written into it before the
|
|
1118
|
+
* vault serves anything (plan PR 3, `limitChange`): a `bulk.limit` change
|
|
1119
|
+
* whenever the log's policy says otherwise, as any change, under the head
|
|
1120
|
+
* and the fence. Uses read the cap from it (CapSealed), never from the
|
|
1121
|
+
* configuration. Once per process; until it lands, each call tries again.
|
|
1122
|
+
* A cap the log already holds is read first, with no lock: every isolate
|
|
1123
|
+
* that starts finds it there, and takes neither 0.14's head nor the fence
|
|
1124
|
+
* for it, which every other start would queue behind (PR 5b's bench).
|
|
1125
|
+
*/
|
|
1126
|
+
async #limit(): Promise<void> {
|
|
1127
|
+
const { row: logged } = await store.readObject(this.#db, POLICY_REF as Ref & { kind: 'policy' });
|
|
1128
|
+
if (limitChange(logged?.kind === 'policy' ? logged : null, this.#config.bulkLimit) === null) {
|
|
1129
|
+
this.#prepared.limited = true;
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
const outcome = await this.#decideNow([], async (d) => {
|
|
1133
|
+
const { row } = await store.readObject(d.tx, POLICY_REF as Ref & { kind: 'policy' });
|
|
1134
|
+
const op = limitChange(row?.kind === 'policy' ? row : null, this.#config.bulkLimit);
|
|
1135
|
+
if (op !== null) d.changes!.stage(POLICY_REF as Ref & { kind: 'policy' }, op, VAULT_ACTOR);
|
|
1136
|
+
});
|
|
1137
|
+
if (outcome.ok) this.#prepared.limited = true;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
754
1140
|
async #replaceEveryProject(d: Decision, principal: string): Promise<void> {
|
|
755
1141
|
const row = d.members.get(principal);
|
|
756
1142
|
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
|
|
@@ -758,9 +1144,11 @@ class VaultService implements Vault {
|
|
|
758
1144
|
const everyProject = subject.stored.filter((grant) => grant.projectId === null && grant.environmentId === null);
|
|
759
1145
|
if (everyProject.length === 0) return;
|
|
760
1146
|
if (await reportStands(d.tx, principal, this.#check(d.reports, principal))) return;
|
|
761
|
-
// The projects there are now, read under the log's head, which a deletion holds.
|
|
1147
|
+
// The projects there are now, read under the log's head, which a deletion holds; each environment by its slug as 0.15's
|
|
1148
|
+
// log holds its row (T159, review r7): a grant on a slug in every project becomes one on each place that answers to
|
|
1149
|
+
// it, never on one a writer renamed into it.
|
|
762
1150
|
const before = roleOf(principal, row);
|
|
763
|
-
const conversion = everyProjectConversion(principal, before.role, subject.stored, d.at, await
|
|
1151
|
+
const conversion = everyProjectConversion(principal, before.role, subject.stored, d.at, await this.#livePlaces(d));
|
|
764
1152
|
const after = { role: conversion.role, scope: conversion.scope };
|
|
765
1153
|
const replacedBy = { role: after.role, scope: after.scope, grants: conversion.grants.length };
|
|
766
1154
|
d.grants.set(principal, [...subject.stored]);
|
|
@@ -774,6 +1162,7 @@ class VaultService implements Vault {
|
|
|
774
1162
|
|
|
775
1163
|
for (const grant of everyProject) {
|
|
776
1164
|
drop(grant);
|
|
1165
|
+
d.changes?.stage(memberRef(principal), { op: 'access.revoke', place: placeOf(grant) }, VAULT_ACTOR);
|
|
777
1166
|
// A lapsed one goes with no entry: it changes nothing anyone holds.
|
|
778
1167
|
if (!live(grant, d.at)) continue;
|
|
779
1168
|
const narrowed = conversion.narrowed.filter((loss) => loss.grant.environmentSlug === grant.environmentSlug);
|
|
@@ -794,6 +1183,7 @@ class VaultService implements Vault {
|
|
|
794
1183
|
reason: 'every-project',
|
|
795
1184
|
}));
|
|
796
1185
|
d.writes.push(() => store.updateMember(d.tx, principal, after));
|
|
1186
|
+
d.changes?.stage(memberRef(principal), { op: 'member.role', ...asStored(after.role, after.scope) }, VAULT_ACTOR);
|
|
797
1187
|
}
|
|
798
1188
|
for (const given of conversion.grants) {
|
|
799
1189
|
const place = { projectId: given.projectId, environmentId: given.environmentId, environmentSlug: null };
|
|
@@ -805,6 +1195,7 @@ class VaultService implements Vault {
|
|
|
805
1195
|
await store.insertGrant(d.tx, grant);
|
|
806
1196
|
d.grants.get(principal)!.push(grant);
|
|
807
1197
|
});
|
|
1198
|
+
d.changes?.stage(memberRef(principal), { op: 'access.grant', grant: inputGrant({ ...place, role: given.role, expiresAt: given.expiresAt }) }, VAULT_ACTOR);
|
|
808
1199
|
d.log.push({
|
|
809
1200
|
...accessEntry(VAULT_ACTOR, 'access.grant', principal, 'allow', correlation, {
|
|
810
1201
|
role: given.role,
|
|
@@ -897,7 +1288,8 @@ class VaultService implements Vault {
|
|
|
897
1288
|
* is to a key it does not hold: a vault given a newer KEK has replaced
|
|
898
1289
|
* this one, which writes nothing more. Checked under the head's lock, so
|
|
899
1290
|
* that an instance still running with the replaced KEK, as during a
|
|
900
|
-
* deploy, cannot write after the rotation.
|
|
1291
|
+
* deploy, cannot write after the rotation. Under 0.15's log, its entries
|
|
1292
|
+
* say so (`log015`): once cut over, the log refuses a write without it.
|
|
901
1293
|
*/
|
|
902
1294
|
async #append(
|
|
903
1295
|
tx: Transaction,
|
|
@@ -915,7 +1307,7 @@ class VaultService implements Vault {
|
|
|
915
1307
|
throw new Error(prepared.superseded);
|
|
916
1308
|
}
|
|
917
1309
|
for (const check of underLock) await check(locked);
|
|
918
|
-
}, head === undefined ? undefined : { head, last: head.last, at: held!.at });
|
|
1310
|
+
}, head === undefined ? undefined : { head, last: head.last, at: held!.at }, [], prepared.log015 !== null);
|
|
919
1311
|
// The head still held, as this append left it, for the next append of the same transaction: a remote key's intent,
|
|
920
1312
|
// then the decision's reports.
|
|
921
1313
|
if (head !== undefined) {
|
|
@@ -928,8 +1320,9 @@ class VaultService implements Vault {
|
|
|
928
1320
|
|
|
929
1321
|
// Raw DEKs are cleared on every path. JSON and base64 leave strings that
|
|
930
1322
|
// cannot be wiped, so this is best-effort memory hygiene.
|
|
931
|
-
async unwrap(input: UnwrapInput): Promise<Outcome<
|
|
1323
|
+
async unwrap(input: UnwrapInput): Promise<Outcome<Unwrapped>> {
|
|
932
1324
|
const { principal } = input;
|
|
1325
|
+
const use = input.use === undefined ? null : useEntry(principal, input, input.use);
|
|
933
1326
|
validateText(input.purpose);
|
|
934
1327
|
if (input.keepsSensitive !== undefined && typeof input.keepsSensitive !== 'boolean') throw new Error('keepsSensitive is true, false or absent');
|
|
935
1328
|
if (input.accessRevision !== undefined && typeof input.accessRevision !== 'string') throw new Error('accessRevision names an answer of access, or is absent');
|
|
@@ -941,7 +1334,14 @@ class VaultService implements Vault {
|
|
|
941
1334
|
const items = loaded.versions.map((version) => ({ ...version, secret: { ...version.secret } }));
|
|
942
1335
|
validateItems(items, 'wrapped');
|
|
943
1336
|
const sealed = await this.#referencesAt(vias);
|
|
944
|
-
|
|
1337
|
+
// Settled first, as a decision is: a new process learns here whether the log is cut over, and so whether a fork's row
|
|
1338
|
+
// is held to its seal (review r1).
|
|
1339
|
+
await this.#settled();
|
|
1340
|
+
const forks = await store.environmentsById(this.#db, throughs.flatMap((through) => (through === null ? [] : [through])), { seals: this.#checksPlaces() });
|
|
1341
|
+
// A fork whose row is not as 0.15's log holds it is no place to read through (T159), reported.
|
|
1342
|
+
const forkReports: NewEntry[] = [];
|
|
1343
|
+
for (const [id, fork] of forks) if (this.#placeFault(id, fork, forkReports) !== null) forks.delete(id);
|
|
1344
|
+
await this.#record(forkReports);
|
|
945
1345
|
// A reference reads only the current version of the source its entry names.
|
|
946
1346
|
const viaOk = items.map((item, i) => vias[i] === null || (sealed[i] !== null && sealed[i]!.source.secretId === item.secret.secretId && item.current));
|
|
947
1347
|
// A fork an inherited value is read in is a live environment of the same project as the value's, and not its own,
|
|
@@ -975,6 +1375,17 @@ class VaultService implements Vault {
|
|
|
975
1375
|
if (throughOk.includes(false)) {
|
|
976
1376
|
return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')), { asked: input.accessRevision });
|
|
977
1377
|
}
|
|
1378
|
+
// The app's record of a use names where the read was, which the vault vouches for once it appends it: the place an
|
|
1379
|
+
// item was read at, the fork it is read in, else a reference's holder, else its own, with its project. Another is
|
|
1380
|
+
// a claim the read does not bear out, refused and logged as one; no use is appended.
|
|
1381
|
+
if (use !== null && (use.environmentId !== null || use.projectId !== null)) {
|
|
1382
|
+
const named = items.some((item, i) => {
|
|
1383
|
+
const fork = throughs[i] === null ? undefined : forks.get(throughs[i]!);
|
|
1384
|
+
const place = fork === undefined ? sealed[i]?.holder ?? item.secret : { projectId: fork.projectId, environmentId: throughs[i]! };
|
|
1385
|
+
return (use.environmentId === null || place.environmentId === use.environmentId) && (use.projectId === null || place.projectId === use.projectId);
|
|
1386
|
+
});
|
|
1387
|
+
if (!named) return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')), { asked: input.accessRevision });
|
|
1388
|
+
}
|
|
978
1389
|
const ends = sealed.flatMap((seal) => (seal === null ? [] : [seal.seq]));
|
|
979
1390
|
const remote = items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
|
|
980
1391
|
return this.#keys(
|
|
@@ -993,6 +1404,7 @@ class VaultService implements Vault {
|
|
|
993
1404
|
remote,
|
|
994
1405
|
entry,
|
|
995
1406
|
input,
|
|
1407
|
+
...(use === null ? {} : { also: [use] }),
|
|
996
1408
|
},
|
|
997
1409
|
(contextOf) =>
|
|
998
1410
|
items.map(({ secret, wrapped }) => async (operation: KeyOperation) => {
|
|
@@ -1009,6 +1421,8 @@ class VaultService implements Vault {
|
|
|
1009
1421
|
}),
|
|
1010
1422
|
// Named back: the read was decided on that answer, which this vault checked.
|
|
1011
1423
|
...(input.accessRevision === undefined ? {} : { accessRevision: input.accessRevision }),
|
|
1424
|
+
// And the use, appended with the read: the app has nothing left to append.
|
|
1425
|
+
...(use === null ? {} : { used: true as const }),
|
|
1012
1426
|
}),
|
|
1013
1427
|
);
|
|
1014
1428
|
}
|
|
@@ -1160,12 +1574,12 @@ class VaultService implements Vault {
|
|
|
1160
1574
|
principal: string,
|
|
1161
1575
|
code: 'bad_claim' | 'deleted' | 'invalid',
|
|
1162
1576
|
entries: NewEntry[],
|
|
1163
|
-
{ message =
|
|
1577
|
+
{ message = REFUSAL_MESSAGES[code], asked }: { message?: string; asked?: string } = {},
|
|
1164
1578
|
): Promise<Outcome<never>> {
|
|
1165
1579
|
return this.#decide([principal], async (d) => {
|
|
1166
1580
|
if (asked !== undefined) {
|
|
1167
1581
|
const standing = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads);
|
|
1168
|
-
if (this.#revisionOf(standing, d) !== asked) throw new Refused(refusal('access_changed',
|
|
1582
|
+
if (this.#revisionOf(standing, d) !== asked) throw new Refused(refusal('access_changed', REFUSAL_MESSAGES.access_changed), []);
|
|
1169
1583
|
}
|
|
1170
1584
|
throw new Refused(refusal(code, message), entries);
|
|
1171
1585
|
}, { reads: true });
|
|
@@ -1224,6 +1638,8 @@ class VaultService implements Vault {
|
|
|
1224
1638
|
viaReference?: readonly boolean[];
|
|
1225
1639
|
remote: boolean;
|
|
1226
1640
|
entry: (secret: SecretRef, decision: 'allow' | 'deny', code: string | null) => NewEntry;
|
|
1641
|
+
/** Entries appended with the release, after its own, only once the keys are released: the app's record of a use. */
|
|
1642
|
+
also?: readonly NewEntry[];
|
|
1227
1643
|
input: Correlation & Through & { purpose?: string; keepsSensitive?: boolean; accessRevision?: string };
|
|
1228
1644
|
},
|
|
1229
1645
|
/**
|
|
@@ -1236,11 +1652,14 @@ class VaultService implements Vault {
|
|
|
1236
1652
|
): Promise<Outcome<R>> {
|
|
1237
1653
|
const { action, principal, secrets, entry } = call;
|
|
1238
1654
|
for (const secret of secrets) entry(secret, 'allow', null);
|
|
1239
|
-
|
|
1655
|
+
// One deadline for the call: the record of the KEK's check, the decisions, the intent's and the keys', and, with a
|
|
1656
|
+
// key service, its own budget besides, spent from it too.
|
|
1657
|
+
const deadline = Date.now() + READ_DEADLINE_MS + (call.remote ? this.#prepared.options.keyBudgetMs : 0);
|
|
1658
|
+
const wrongKek = await this.#kekMismatch(deadline);
|
|
1240
1659
|
if (wrongKek !== null) {
|
|
1241
1660
|
return this.#decide([], async () => {
|
|
1242
1661
|
throw new Refused(refusal('wrong_kek', wrongKek), secrets.map((secret) => entry(secret, 'deny', 'wrong_kek')));
|
|
1243
|
-
});
|
|
1662
|
+
}, { reads: true, deadline });
|
|
1244
1663
|
}
|
|
1245
1664
|
// Each personal config's owner, hashed, by environment, as the decision read them: what its keys' contexts bind.
|
|
1246
1665
|
const owners = new Map<string, string>();
|
|
@@ -1248,7 +1667,7 @@ class VaultService implements Vault {
|
|
|
1248
1667
|
const standing = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads);
|
|
1249
1668
|
// A read decided on an earlier answer about its reader, only while that is still the answer: nothing read, nothing logged.
|
|
1250
1669
|
const asked = call.input.accessRevision;
|
|
1251
|
-
if (asked !== undefined && this.#revisionOf(standing, d) !== asked) throw new Refused(refusal('access_changed',
|
|
1670
|
+
if (asked !== undefined && this.#revisionOf(standing, d) !== asked) throw new Refused(refusal('access_changed', REFUSAL_MESSAGES.access_changed), []);
|
|
1252
1671
|
const reader = through(standing, call.input);
|
|
1253
1672
|
// One read of the environments the decision is about, as the store has them, not as the app sent them:
|
|
1254
1673
|
// each secret's and, for a read through a reference, its holder's. A grant on one slug in every project
|
|
@@ -1256,6 +1675,9 @@ class VaultService implements Vault {
|
|
|
1256
1675
|
const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
|
|
1257
1676
|
// Each a personal config is a config of too, whose read its owner needs (`places`).
|
|
1258
1677
|
const environments = d.reads.environments;
|
|
1678
|
+
// Each as 0.15's log holds its row (T159): its project, its slug, its tombstone, a personal config's owner and parent.
|
|
1679
|
+
const decided = [...secrets, ...at].map((place) => place.environmentId);
|
|
1680
|
+
this.#placesHold(environments, [...decided, ...decided.flatMap((id) => (environments.get(id)?.owner == null ? [] : [environments.get(id)!.parentId!]))], d.reports);
|
|
1259
1681
|
owners.clear();
|
|
1260
1682
|
for (const [id, environment] of environments) if (environment.owner !== null) owners.set(id, ownerKey(environment.owner));
|
|
1261
1683
|
const needs = call.needs === undefined ? [] : await call.needs(d.tx);
|
|
@@ -1270,7 +1692,7 @@ class VaultService implements Vault {
|
|
|
1270
1692
|
if (first === null && action === 'secret.read' && this.#overBulkLimit(d)) first = 'bulk_limit';
|
|
1271
1693
|
if (first !== null) {
|
|
1272
1694
|
throw new Refused(
|
|
1273
|
-
refusal(first,
|
|
1695
|
+
refusal(first, REFUSAL_MESSAGES[first]),
|
|
1274
1696
|
secrets.map((secret, i) => entry(secret, 'deny', codes[i] ?? first)),
|
|
1275
1697
|
);
|
|
1276
1698
|
}
|
|
@@ -1319,7 +1741,7 @@ class VaultService implements Vault {
|
|
|
1319
1741
|
}),
|
|
1320
1742
|
}], [], d.reads);
|
|
1321
1743
|
return { seq: appended.seqStart };
|
|
1322
|
-
}, { asked: reads, reads: true });
|
|
1744
|
+
}, { asked: reads, reads: true, deadline });
|
|
1323
1745
|
if (!intent.ok) return intent;
|
|
1324
1746
|
intentSeq = intent.seq;
|
|
1325
1747
|
}
|
|
@@ -1352,10 +1774,10 @@ class VaultService implements Vault {
|
|
|
1352
1774
|
const codes = await vet(locked, d.reads);
|
|
1353
1775
|
const first = codes.find((code) => code !== null);
|
|
1354
1776
|
if (first === undefined || first === null) return;
|
|
1355
|
-
throw new Refused(refusal(first,
|
|
1777
|
+
throw new Refused(refusal(first, REFUSAL_MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, 'deny', codes[item] ?? first)));
|
|
1356
1778
|
});
|
|
1357
1779
|
}
|
|
1358
|
-
const { outcomes, expired } = await settle(operations((secret) => context(secret, owners.get(secret.environmentId))), this.#prepared.options.keyBudgetMs);
|
|
1780
|
+
const { outcomes, expired } = await settle(operations((secret) => context(secret, owners.get(secret.environmentId))), within(deadline, this.#prepared.options.keyBudgetMs));
|
|
1359
1781
|
const done = outcomes.flatMap((outcome) => (outcome.ok ? [outcome.value] : []));
|
|
1360
1782
|
try {
|
|
1361
1783
|
const entries = () => secrets.map((secret, i) =>
|
|
@@ -1372,7 +1794,7 @@ class VaultService implements Vault {
|
|
|
1372
1794
|
}
|
|
1373
1795
|
if (expired) throw new Outage(new KekUnavailableError('key operation exceeded its deadline'), entries());
|
|
1374
1796
|
if (done.length < outcomes.length) {
|
|
1375
|
-
throw new Refused(refusal('bad_claim',
|
|
1797
|
+
throw new Refused(refusal('bad_claim', REFUSAL_MESSAGES.bad_claim), entries());
|
|
1376
1798
|
}
|
|
1377
1799
|
if (late) {
|
|
1378
1800
|
// Again under the log's head, the append's, taken a statement early: a
|
|
@@ -1381,16 +1803,16 @@ class VaultService implements Vault {
|
|
|
1381
1803
|
// A read through a reference: its holder's place too, which a deletion may have taken.
|
|
1382
1804
|
// And as the policy is now, under the head every change to it takes: an environment protected, or a role narrowed,
|
|
1383
1805
|
// since the check above is seen here, and nothing it refuses now is released. All of it read again, holding the head.
|
|
1384
|
-
d.reads = await this.#snapshot(d.tx, [principal], reads, true);
|
|
1806
|
+
d.reads = await this.#snapshot(d.tx, [principal], reads, true, true);
|
|
1385
1807
|
d.at = this.#at(d.reads);
|
|
1386
1808
|
await decided(d);
|
|
1387
1809
|
const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
|
|
1388
1810
|
if (places.some((place) => d.reads.environments.get(place.environmentId)?.deleted ?? false)) {
|
|
1389
|
-
throw new Refused(refusal('deleted',
|
|
1811
|
+
throw new Refused(refusal('deleted', REFUSAL_MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, 'deny', 'deleted')));
|
|
1390
1812
|
}
|
|
1391
1813
|
}
|
|
1392
1814
|
const released = secrets.map((secret, i) => outcomeEntry(secret, i, 'allow', null));
|
|
1393
|
-
d.log.push(...released);
|
|
1815
|
+
d.log.push(...released, ...(call.also ?? []));
|
|
1394
1816
|
const answer = result(done);
|
|
1395
1817
|
// A wrap's answer names its entries, which the app's writes refer to.
|
|
1396
1818
|
if (action !== 'secret.read') d.after.push((seqOf) => Object.assign(answer as object, { seqs: released.map(seqOf) }));
|
|
@@ -1398,7 +1820,7 @@ class VaultService implements Vault {
|
|
|
1398
1820
|
} finally {
|
|
1399
1821
|
for (const value of done) value.wipe();
|
|
1400
1822
|
}
|
|
1401
|
-
}, { late, asked: reads, reads: true });
|
|
1823
|
+
}, { late, asked: reads, reads: true, deadline });
|
|
1402
1824
|
}
|
|
1403
1825
|
|
|
1404
1826
|
/**
|
|
@@ -1469,15 +1891,18 @@ class VaultService implements Vault {
|
|
|
1469
1891
|
const environments = await store.environmentsById(d.tx, items.flatMap((item) => {
|
|
1470
1892
|
const source = sources.get(item.source.secretId);
|
|
1471
1893
|
return source === undefined ? [item.holder.environmentId] : [item.holder.environmentId, source.environmentId];
|
|
1472
|
-
}));
|
|
1894
|
+
}), { seals: this.#checksPlaces() });
|
|
1895
|
+
// Each as 0.15's log holds its row (T159).
|
|
1896
|
+
this.#placesHold(environments, environments.keys(), d.reports);
|
|
1473
1897
|
const codes = items.map((item): RefusalCode | null => {
|
|
1474
1898
|
const source = sources.get(item.source.secretId);
|
|
1475
|
-
|
|
1899
|
+
// The holder's project as the row this decision checked says it (review r4): the read before only names the entry.
|
|
1900
|
+
if (source === undefined || environments.get(item.holder.environmentId)?.projectId !== item.holder.projectId) return 'bad_claim';
|
|
1476
1901
|
if (source.secretId === item.holder.secretId) return 'invalid';
|
|
1477
1902
|
return referenceRefusal(reader, item.holder, source, environments, natures.get(source.secretId) ?? 'secret');
|
|
1478
1903
|
});
|
|
1479
1904
|
const first = codes.find((code) => code !== null) ?? null;
|
|
1480
|
-
if (first !== null) throw new Refused(refusal(first,
|
|
1905
|
+
if (first !== null) throw new Refused(refusal(first, REFUSAL_MESSAGES[first]), items.map((item, i) => entry(item, 'deny', codes[i] ?? first)));
|
|
1481
1906
|
const entries = items.map((item) => entry(item, 'allow', null));
|
|
1482
1907
|
d.log.push(...entries);
|
|
1483
1908
|
const answer = { seqs: [] as number[] };
|
|
@@ -1498,7 +1923,9 @@ class VaultService implements Vault {
|
|
|
1498
1923
|
validateCorrelation(input);
|
|
1499
1924
|
if (reason !== 'replaced' && reason !== 'broken' && reason !== 'abandoned') throw new Error('a reference ends replaced, broken or abandoned');
|
|
1500
1925
|
const vias = input.items.map((item) => validateVia(item)!);
|
|
1501
|
-
if (vias.some((via) => via === null) || new Set(vias.map((via) => via.seq)).size !== vias.length
|
|
1926
|
+
if (vias.some((via) => via === null) || new Set(vias.map((via) => via.seq)).size !== vias.length || new Set(vias.map((via) => via!.reference)).size !== vias.length) {
|
|
1927
|
+
throw new Error('each reference to end, once');
|
|
1928
|
+
}
|
|
1502
1929
|
if (vias.length === 0) return { ok: true, seqs: [] };
|
|
1503
1930
|
const sealed = await this.#referencesAt(vias);
|
|
1504
1931
|
// A holder's row is committed after its reference is sealed, if the write went through: an end names it only then.
|
|
@@ -1531,31 +1958,111 @@ class VaultService implements Vault {
|
|
|
1531
1958
|
});
|
|
1532
1959
|
if (sealed.some((seal) => seal === null)) {
|
|
1533
1960
|
return this.#decide([principal], async () => {
|
|
1534
|
-
throw new Refused(refusal('bad_claim',
|
|
1961
|
+
throw new Refused(refusal('bad_claim', REFUSAL_MESSAGES.bad_claim), vias.map((via, i) => entry(via, sealed[i]!, 'deny', 'bad_claim')));
|
|
1535
1962
|
});
|
|
1536
1963
|
}
|
|
1537
1964
|
const seals = sealed as Sealed[];
|
|
1965
|
+
// Ended already: by an authentic end only. A forged one (a row with the vault's name and no valid MAC) refuses a
|
|
1966
|
+
// read, the safe side there (`endedReferences`); here it would stop a genuine break, and with 0.15's log leave the
|
|
1967
|
+
// holder serving, its end written nowhere (review r9).
|
|
1538
1968
|
const ended = async (db: Queryable) => {
|
|
1539
|
-
const done = await store.
|
|
1969
|
+
const done = new Set((await store.referenceEnds(db, seals.map((seal) => seal.seq))).filter((end) => this.#authentic(end)).map((end) => end.relatedSeq!));
|
|
1540
1970
|
return seals.map((seal) => (done.has(seal.seq) ? ('ended' as const) : null));
|
|
1541
1971
|
};
|
|
1542
1972
|
return this.#decide([principal], async (d) => {
|
|
1543
1973
|
// Under the log's head: two ends of one reference at once, the second finds the first; and the actor's standing stands.
|
|
1544
1974
|
const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), input);
|
|
1545
1975
|
// Ending is allowed in a deleted place: deleting one ends the references into and out of it.
|
|
1546
|
-
const environments = await store.environmentsById(d.tx, seals.map((seal) => seal.holder.environmentId));
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1976
|
+
const environments = await store.environmentsById(d.tx, seals.map((seal) => seal.holder.environmentId), { seals: this.#checksPlaces() });
|
|
1977
|
+
// Each as 0.15's log holds its row (T159).
|
|
1978
|
+
this.#placesHold(environments, environments.keys(), d.reports);
|
|
1979
|
+
// Who may end each: write where it is held; for a break, managing the source's project's access too; abandoning,
|
|
1980
|
+
// its maker alone, for a write of theirs that stored nothing (the vault never reads the app's rows, so whose
|
|
1981
|
+
// seal it is decides, and no one else's end says it).
|
|
1982
|
+
const mayEnd = (reader: Reader, places: ReadonlyMap<string, { projectId: string; slug: string }>) => seals.map((seal): RefusalCode | null => {
|
|
1983
|
+
const holder = refuses(reader, 'secret.write', placeIn(seal.holder, places), false);
|
|
1551
1984
|
if (reason === 'abandoned' && seal.createdBy !== principal) return 'not_allowed';
|
|
1552
|
-
// Replacing or abandoning is the holder's side: write where it is held.
|
|
1553
1985
|
if (holder === null || reason !== 'broken') return holder;
|
|
1554
|
-
return refuses(
|
|
1986
|
+
return refuses(reader, 'grant.manage', { projectId: seal.source.projectId }, false) === null ? null : holder;
|
|
1555
1987
|
});
|
|
1988
|
+
const codes: (RefusalCode | null)[] = mayEnd(actor, environments);
|
|
1989
|
+
// With 0.15's log, also as 0.15 decides it, on sealed objects alone (R53): the actor's principal object, the
|
|
1990
|
+
// roles object and each holder environment's object, each as its log has it and checked against its seal
|
|
1991
|
+
// (`readerOf`, as a use's reader). 0.14's reader reads rows no seal covers (an environment's slug, a project's
|
|
1992
|
+
// tombstone, the protection they make): a writer who swapped one could let a member end what they may not
|
|
1993
|
+
// (reviews r10 and r11). An end needs both to allow it, so an unsealed row can only refuse one. An object the log
|
|
1994
|
+
// does not bear out refuses the end. Per reference: one whose holder environment, or the actor, 0.14's log alone
|
|
1995
|
+
// holds (before the cutover) is 0.14's reader's alone, and no other reference of the call with it (review r12).
|
|
1996
|
+
const allowed14 = !codes.some((code) => code !== null);
|
|
1997
|
+
if (d.changes !== null && allowed14) {
|
|
1998
|
+
const member = await d.changes.held(d.tx, { kind: 'principal', id: principal });
|
|
1999
|
+
const roles = await d.changes.held(d.tx, ROLES_REF as Ref & { kind: 'roles' });
|
|
2000
|
+
const places = new Map<string, EnvironmentInput>();
|
|
2001
|
+
for (const id of new Set(seals.map((seal) => seal.holder.environmentId))) {
|
|
2002
|
+
const held = await d.changes.held(d.tx, { kind: 'environment', id });
|
|
2003
|
+
if (held.log === 'live' && held.object.kind === 'environment') places.set(id, held.object);
|
|
2004
|
+
}
|
|
2005
|
+
if (member.log !== 'unlogged') {
|
|
2006
|
+
const account = member.log === 'live' && member.object.kind === 'principal' ? member.object : null;
|
|
2007
|
+
// The way the actor came in, as a reader takes it: its kind and scope (its generation weighs nothing here).
|
|
2008
|
+
const credential: CredentialInput = {
|
|
2009
|
+
kind: 'credential', id: principal, principal, caller: input.caller, scope: input.credentialScope ?? null, expiresAt: null, revoked: false, generation: account?.generation ?? 0,
|
|
2010
|
+
};
|
|
2011
|
+
const reader = readerOf(account, credential, roles.log === 'live' && roles.object.kind === 'roles' ? roles.object : noEdits(), places.values(), d.at);
|
|
2012
|
+
mayEnd(reader, places).forEach((code, i) => {
|
|
2013
|
+
if (places.has(seals[i]!.holder.environmentId)) codes[i] = code;
|
|
2014
|
+
});
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
1556
2017
|
if (!codes.some((code) => code !== null)) (await ended(d.tx)).forEach((code, i) => (codes[i] = code));
|
|
1557
2018
|
const first = codes.find((code) => code !== null) ?? null;
|
|
1558
|
-
if (first !== null) throw new Refused(refusal(first,
|
|
2019
|
+
if (first !== null) throw new Refused(refusal(first, REFUSAL_MESSAGES[first]), vias.map((via, i) => entry(via, seals[i]!, 'deny', codes[i] ?? first)));
|
|
2020
|
+
// With 0.15's log, each reference ended ends in its row, beside 0.14's entry, in this transaction (0029;
|
|
2021
|
+
// formal/SealedObjects.tla, EndInRow and EndWithEntry). Decided only on what the vault authenticates (R53): that
|
|
2022
|
+
// it has not ended is 0.14's log's (`ended` above), and what its holder reads is the holder's object, as its log
|
|
2023
|
+
// has it and checked against its seal (`heldByLog`: ReferenceInObject, LivenessFromLog), never its rows, which
|
|
2024
|
+
// a writer may rename, plant or mark archived. A holder whose rows the log does not bear out refuses the end
|
|
2025
|
+
// (`tampered`, `stale`).
|
|
2026
|
+
// - A holder whose object reads its source through this reference changes: `secret.write`, no source, whose
|
|
2027
|
+
// newest row, this one, `change_input` ends.
|
|
2028
|
+
// - Any other ends this row alone, by its id (`end_reference`): an abandoned reference's stored none, and its
|
|
2029
|
+
// end touches no other. A holder with no object has only rows, which decide nothing here: 0.14's alone
|
|
2030
|
+
// before the cutover, which holds each reference row to 0.14's `reference.create` and `reference.end` (plan
|
|
2031
|
+
// PR 9); or archived, whose unarchive restores what its log last sealed (`keptObjects`, T134), never its rows.
|
|
2032
|
+
// - So an archived holder whose kept object reads through this reference has that object ended too: written
|
|
2033
|
+
// ended, then deleted again, still no object (EndWhileArchived). Otherwise its unarchive would restore it
|
|
2034
|
+
// live, the row's end cleared by a writer, or refuse an honest one for the end in its rows. Its rows must make
|
|
2035
|
+
// the kept object, as a live holder's must make its sealed one: else the end is refused, reported.
|
|
2036
|
+
if (d.changes !== null) {
|
|
2037
|
+
const changes = d.changes;
|
|
2038
|
+
const rows: string[] = [];
|
|
2039
|
+
const staged = new Set<string>();
|
|
2040
|
+
const end = (ref: Ref & { kind: 'secret' }, at: { projectId: string; environmentId: string }, archived: boolean) => {
|
|
2041
|
+
if (staged.has(ref.id)) return;
|
|
2042
|
+
staged.add(ref.id);
|
|
2043
|
+
changes.stage(ref, { op: 'secret.write', projectId: at.projectId, environmentId: at.environmentId, version: '', nature: 'secret', key: null, digest: null, source: null, reference: null }, principal);
|
|
2044
|
+
if (archived) changes.stage(ref, { op: 'secret.delete' }, principal);
|
|
2045
|
+
};
|
|
2046
|
+
for (const [i, { holder }] of seals.entries()) {
|
|
2047
|
+
const reference = vias[i]!.reference;
|
|
2048
|
+
const ref = { kind: 'secret' as const, id: holder.secretId };
|
|
2049
|
+
const held = await changes.held(d.tx, ref);
|
|
2050
|
+
if (held.log === 'live' && held.object.kind === 'secret' && held.object.reference === reference) {
|
|
2051
|
+
end(ref, holder, false);
|
|
2052
|
+
continue;
|
|
2053
|
+
}
|
|
2054
|
+
if (held.log === 'gone') {
|
|
2055
|
+
const kept = await changes.kept(d.tx, ref);
|
|
2056
|
+
if (kept?.kind === 'secret' && kept.reference === reference) {
|
|
2057
|
+
// Its rows must make that object, or its end would write none of them: refused, reported, as a live one's.
|
|
2058
|
+
await changes.rowsAsKept(d.tx, ref, kept);
|
|
2059
|
+
end(ref, kept, true);
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
rows.push(reference);
|
|
2063
|
+
}
|
|
2064
|
+
if (rows.length > 0) d.writes.push((at) => store.endReferenceRows(d.tx, rows, at));
|
|
2065
|
+
}
|
|
1559
2066
|
const entries = vias.map((via, i) => entry(via, seals[i]!, 'allow', null));
|
|
1560
2067
|
d.log.push(...entries);
|
|
1561
2068
|
const answer = { seqs: [] as number[] };
|
|
@@ -1618,6 +2125,8 @@ class VaultService implements Vault {
|
|
|
1618
2125
|
* and is asked about again at each of their requests. Whether it ran.
|
|
1619
2126
|
*/
|
|
1620
2127
|
async #declare(principals: readonly string[]): Promise<boolean> {
|
|
2128
|
+
// Under 0.15's log, no Owner made before the cutover: nothing of the log's precedes its change (AnchorHolds).
|
|
2129
|
+
if (!(await this.#cutOverNow())) return false;
|
|
1621
2130
|
const at = await this.#now(this.#db);
|
|
1622
2131
|
const due: string[] = [];
|
|
1623
2132
|
for (const principal of principals) {
|
|
@@ -1665,7 +2174,13 @@ class VaultService implements Vault {
|
|
|
1665
2174
|
const owner = { role: 'owner' as const, scope: EVERYWHERE };
|
|
1666
2175
|
d.grants.set(principal, [...subject.stored]);
|
|
1667
2176
|
d.touched.add(principal);
|
|
1668
|
-
if (tampered) await this.#startOver(d, VAULT_ACTOR, principal, row, subject.fault!, correlation, (code, message) => new Refused(refusal(code, message ??
|
|
2177
|
+
if (tampered) await this.#startOver(d, VAULT_ACTOR, principal, row, subject.fault!, correlation, (code, message) => new Refused(refusal(code, message ?? REFUSAL_MESSAGES[code]), []));
|
|
2178
|
+
// Started over, their row is one 0.15's log does not hold, and changes nothing there: adoption or a reseal does
|
|
2179
|
+
// (plan PR 9). Otherwise, the change the writes below make.
|
|
2180
|
+
if (!tampered) {
|
|
2181
|
+
const op = row === undefined ? 'member.add' : row.status === 'removed' ? 'member.restore' : 'member.role';
|
|
2182
|
+
d.changes?.stage(memberRef(principal), { op, ...asStored(owner.role, owner.scope) }, VAULT_ACTOR);
|
|
2183
|
+
}
|
|
1669
2184
|
if (row === undefined && !tampered) {
|
|
1670
2185
|
d.log.push(accessEntry(VAULT_ACTOR, 'member.add', principal, 'allow', correlation, { ...owner, reason }));
|
|
1671
2186
|
d.writes.push((at) => store.insertMember(d.tx, {
|
|
@@ -1696,7 +2211,8 @@ class VaultService implements Vault {
|
|
|
1696
2211
|
if (fault !== null) return { principal, status: 'tampered', live: none, all: none, fault, stored: grants };
|
|
1697
2212
|
if (!PRINCIPAL.test(principal)) return { principal, status: 'unknown', live: none, all: none, fault, stored: grants };
|
|
1698
2213
|
if (row?.status !== 'active') return { principal, status: row?.status ?? 'unknown', live: none, all: none, fault, stored: grants };
|
|
1699
|
-
|
|
2214
|
+
// What they hold: their grants but those on a place whose row is not as 0.15's log holds it (T159). Kept as stored.
|
|
2215
|
+
const held = (await this.#heldGrants(db, grants, reports, reads?.environments)).map((grant) => ({ ...grant, role: grant.role as Role }));
|
|
1700
2216
|
const { role, scope } = roleOf(principal, row);
|
|
1701
2217
|
// Every decision about them reads the policy: their roles as an Owner edited them, and which environments are protected.
|
|
1702
2218
|
const { policy } = await this.#policyAt(db, reports, at, reads);
|
|
@@ -1712,25 +2228,16 @@ class VaultService implements Vault {
|
|
|
1712
2228
|
}
|
|
1713
2229
|
|
|
1714
2230
|
#access(principal: string, row: Member | undefined, held: readonly GrantRow[], at: number, tampered: boolean, policy: Policy): Access {
|
|
1715
|
-
|
|
1716
|
-
const access: Access = {
|
|
1717
|
-
principal,
|
|
1718
|
-
status: tampered ? 'tampered' : !PRINCIPAL.test(principal) ? 'unknown' : (row?.status ?? 'unknown'),
|
|
1719
|
-
generation: row?.generation ?? 0,
|
|
1720
|
-
...(active ? roleOf(principal, row) : { role: 'member', scope: EVERYWHERE }),
|
|
1721
|
-
grants: active ? held.filter((grant) => live(grant, at) && grantKind(grant) !== null).map(view) : [],
|
|
1722
|
-
since: row ? iso(row.statusChangedAt) : null,
|
|
1723
|
-
by: row?.statusChangedBy ?? null,
|
|
1724
|
-
policy,
|
|
1725
|
-
};
|
|
1726
|
-
return { ...access, revision: accessRevision(access) };
|
|
2231
|
+
return answerFrom(principal, row, held, at, tampered, policy);
|
|
1727
2232
|
}
|
|
1728
2233
|
|
|
1729
2234
|
/** The revision of what `access` would answer about a standing a decision read: none but an active member's matches a kept answer. */
|
|
1730
2235
|
#revisionOf(standing: Standing, d: Decision): string | null {
|
|
1731
2236
|
const { policy } = standing.live;
|
|
1732
2237
|
if (standing.status !== 'active' || policy === undefined) return null;
|
|
1733
|
-
|
|
2238
|
+
// Its grants that hold, as `access` answers them (`#heldGrants`): those `all` keeps.
|
|
2239
|
+
const held = standing.stored.filter((grant) => grant.environmentId === null || standing.all.grants.some((kept) => kept.environmentId === grant.environmentId));
|
|
2240
|
+
return this.#access(standing.principal, d.members.get(standing.principal), held, d.at, false, policy).revision!;
|
|
1734
2241
|
}
|
|
1735
2242
|
|
|
1736
2243
|
/**
|
|
@@ -1755,11 +2262,13 @@ class VaultService implements Vault {
|
|
|
1755
2262
|
/** `access`, as the rows are now. */
|
|
1756
2263
|
async #read(principal: string, superseded: string | null): Promise<Access> {
|
|
1757
2264
|
const read = async (db: Queryable, reports: NewEntry[]) => {
|
|
1758
|
-
const reads = await snapshot(db, { principals: [principal], members: true });
|
|
2265
|
+
const reads = await snapshot(db, { principals: [principal], members: true, seals: this.#checksPlaces() });
|
|
1759
2266
|
const [row, held, at] = [reads.members.get(principal), reads.grants.get(principal)!, this.#at(reads)];
|
|
1760
2267
|
const fault = await this.#integrity(db, principal, row, held, reports, reads);
|
|
2268
|
+
// Their grants but those on a place whose row is not as 0.15's log holds it, which hold nothing (T159).
|
|
2269
|
+
const holding = fault === null ? await this.#heldGrants(db, held, reports, reads.environments) : held;
|
|
1761
2270
|
const { policy } = await this.#policyAt(db, reports, at, reads);
|
|
1762
|
-
return this.#access(principal, row,
|
|
2271
|
+
return this.#access(principal, row, holding, at, fault !== null, policy);
|
|
1763
2272
|
};
|
|
1764
2273
|
const found: NewEntry[] = [];
|
|
1765
2274
|
const quick = await read(this.#db, found).catch((error: unknown) => {
|
|
@@ -1807,7 +2316,7 @@ class VaultService implements Vault {
|
|
|
1807
2316
|
const action = changes.every((change) => change.role === null) ? 'access.revoke' : 'access.grant';
|
|
1808
2317
|
// One place refused is shown where it is, to whoever reads that place's log; an invalid one may be no place at all.
|
|
1809
2318
|
const [only] = changes.length === 1 ? changes : [];
|
|
1810
|
-
const refused = (code: RefusalCode, message =
|
|
2319
|
+
const refused = (code: RefusalCode, message = REFUSAL_MESSAGES[code]) => {
|
|
1811
2320
|
const where = only === undefined || code === 'invalid' ? { ids: {}, detail: {} } : located(only);
|
|
1812
2321
|
return new Refused(refusal(code, message), [{
|
|
1813
2322
|
...accessEntry(actor, action, principal, 'deny', input, { changes: input.changes, ...where.detail }, code),
|
|
@@ -1845,11 +2354,10 @@ class VaultService implements Vault {
|
|
|
1845
2354
|
throw refused('invalid', `${change.role} can only be granted on a project`);
|
|
1846
2355
|
}
|
|
1847
2356
|
}
|
|
1848
|
-
const
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
);
|
|
2357
|
+
const named = changes.flatMap((change) => (change.environmentId === null ? [] : [change.environmentId]));
|
|
2358
|
+
const known = await store.places(d.tx, changes.map((change) => change.projectId!), named, { seals: this.#checksPlaces() });
|
|
2359
|
+
// Each environment by its project and slug as 0.15's log holds its row (T159): what a grant there is weighed on.
|
|
2360
|
+
this.#placesHold(known.environments, named, d.reports);
|
|
1853
2361
|
const where: Place[] = changes.map(({ projectId, environmentId, role }) => {
|
|
1854
2362
|
const environment = environmentId === null ? undefined : known.environments.get(environmentId);
|
|
1855
2363
|
if (!known.projects.has(projectId!) || (environmentId !== null && environment?.projectId !== projectId)) {
|
|
@@ -1883,7 +2391,7 @@ class VaultService implements Vault {
|
|
|
1883
2391
|
// The setting as of the log's head, which a change to it takes too.
|
|
1884
2392
|
const setting = (await this.#settingsAt(d.tx, d.reports)).serviceAccounts;
|
|
1885
2393
|
// One just admitted holds nothing yet, and is theirs.
|
|
1886
|
-
const account = admitted !== undefined || row === undefined ? { grants: [], admittedBy: actor } : await this.#account(d.tx, subject, row);
|
|
2394
|
+
const account = admitted !== undefined || row === undefined ? { grants: [], admittedBy: actor } : await this.#account(d.tx, subject, row, d.reads, d.reports);
|
|
1887
2395
|
// Each place's new role they must hold, and what it held before: taking a grant away is changing it.
|
|
1888
2396
|
const gives = changes.every((change, i) => {
|
|
1889
2397
|
const before = subject.live.grants.find((grant) => placeKey({ ...grant, environmentSlug: null }) === placeKey(change));
|
|
@@ -1906,7 +2414,7 @@ class VaultService implements Vault {
|
|
|
1906
2414
|
* not run the instance sets one up as they would through `admit`.
|
|
1907
2415
|
*/
|
|
1908
2416
|
async #admitWith(d: Decision, acting: Standing, principal: string, row: Member | undefined, input: SetAccessInput): Promise<Admitted> {
|
|
1909
|
-
const refused = (code: RefusalCode, message =
|
|
2417
|
+
const refused = (code: RefusalCode, message = REFUSAL_MESSAGES[code]) =>
|
|
1910
2418
|
new Refused(refusal(code, message), [accessEntry(acting.principal, 'member.add', principal, 'deny', input, { changes: input.changes }, code)]);
|
|
1911
2419
|
if (!SERVICE.test(principal)) throw refused('invalid', 'only a service account is admitted with its grants: a person, by an instance role');
|
|
1912
2420
|
if (!runsInstance(acting.live)) await this.#setsUp(d, acting, true, refused);
|
|
@@ -1948,6 +2456,7 @@ class VaultService implements Vault {
|
|
|
1948
2456
|
|
|
1949
2457
|
if (change.role === null) {
|
|
1950
2458
|
clear();
|
|
2459
|
+
if (existing !== undefined) d.changes?.stage(memberRef(principal), { op: 'access.revoke', place: placeOf(place) }, actor);
|
|
1951
2460
|
if (current === undefined) return 'unchanged';
|
|
1952
2461
|
entry('access.revoke', null);
|
|
1953
2462
|
return 'revoked';
|
|
@@ -1960,6 +2469,8 @@ class VaultService implements Vault {
|
|
|
1960
2469
|
await store.insertGrant(d.tx, grant);
|
|
1961
2470
|
d.grants.get(principal)!.push(grant);
|
|
1962
2471
|
});
|
|
2472
|
+
// In place of whatever it held there, lapsed or not, as clearing it then inserting this does.
|
|
2473
|
+
d.changes?.stage(memberRef(principal), { op: 'access.grant', grant: inputGrant({ ...placeOf(place), role, expiresAt }) }, actor);
|
|
1963
2474
|
entry('access.grant', role);
|
|
1964
2475
|
return current === undefined ? 'created' : 'updated';
|
|
1965
2476
|
}
|
|
@@ -1967,7 +2478,7 @@ class VaultService implements Vault {
|
|
|
1967
2478
|
admit(input: AdmitInput): Promise<Outcome<Admitted>> {
|
|
1968
2479
|
const { actor, principal } = input;
|
|
1969
2480
|
const asked = input.role === undefined ? {} : { role: input.role, scope: input.scope ?? EVERYWHERE };
|
|
1970
|
-
const refused = (code: RefusalCode, message =
|
|
2481
|
+
const refused = (code: RefusalCode, message = REFUSAL_MESSAGES[code]) =>
|
|
1971
2482
|
new Refused(refusal(code, message), [
|
|
1972
2483
|
accessEntry(actor, 'member.add', principal, 'deny', input, asked, code),
|
|
1973
2484
|
]);
|
|
@@ -2007,6 +2518,7 @@ class VaultService implements Vault {
|
|
|
2007
2518
|
if (changed) {
|
|
2008
2519
|
d.log.push(accessEntry(actor, 'member.role', principal, 'allow', input, { role: wanted.role, scope: wanted.scope, previousRole: row.role, previousScope: row.scope }));
|
|
2009
2520
|
d.writes.push(() => store.updateMember(d.tx, principal, { role: wanted.role, scope: wanted.scope }));
|
|
2521
|
+
d.changes?.stage(memberRef(principal), { op: 'member.role', ...asStored(wanted.role, wanted.scope) }, actor);
|
|
2010
2522
|
return { created: false, ...wanted, generation: row.generation };
|
|
2011
2523
|
}
|
|
2012
2524
|
return { created: false, role: row.role, scope: row.scope, generation: row.generation };
|
|
@@ -2022,11 +2534,30 @@ class VaultService implements Vault {
|
|
|
2022
2534
|
*/
|
|
2023
2535
|
async #setsUp(d: Decision, acting: Standing, service: boolean, refused: (code: RefusalCode, message?: string) => Refused): Promise<void> {
|
|
2024
2536
|
const setting = (await this.#settingsAt(d.tx, d.reports)).serviceAccounts;
|
|
2025
|
-
if (!service || !PERSON.test(acting.principal) || !setsUpServices(acting.live, setting, placesOf(await
|
|
2537
|
+
if (!service || !PERSON.test(acting.principal) || !setsUpServices(acting.live, setting, placesOf(await this.#livePlaces(d)))) {
|
|
2026
2538
|
throw refused('not_allowed', 'only admins and owners of the whole instance may add people or set their roles, and a service account takes access somewhere the instance lets people set one up');
|
|
2027
2539
|
}
|
|
2028
2540
|
}
|
|
2029
2541
|
|
|
2542
|
+
/**
|
|
2543
|
+
* Every live project and its environments, each environment by its
|
|
2544
|
+
* project, slug and tombstone as 0.15's log holds its row (T159): one that
|
|
2545
|
+
* is not is no place anyone sets a service account up in, nor that a
|
|
2546
|
+
* grant on every project of 0.4 becomes one on. Each read once,
|
|
2547
|
+
* in the statement whose rows are checked, so what is checked is what is
|
|
2548
|
+
* weighed (review r3: a row renamed between two reads, and back).
|
|
2549
|
+
*/
|
|
2550
|
+
async #livePlaces(d: Decision): Promise<Awaited<ReturnType<typeof store.liveProjects>>> {
|
|
2551
|
+
const projects = await store.liveProjects(d.tx);
|
|
2552
|
+
if (!this.#checksPlaces()) return projects;
|
|
2553
|
+
const rows = await store.environmentsById(d.tx, 'all', { seals: true });
|
|
2554
|
+
const held = [...rows].filter(([id, row]) => this.#placeFault(id, row, d.reports) === null && !row.deleted);
|
|
2555
|
+
return projects.map((project) => ({
|
|
2556
|
+
id: project.id,
|
|
2557
|
+
environments: held.filter(([, row]) => row.projectId === project.id).map(([id, row]) => ({ id, slug: row.slug })).sort((a, b) => (a.id < b.id ? -1 : 1)),
|
|
2558
|
+
}));
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2030
2561
|
/**
|
|
2031
2562
|
* Admit `principal`, who has no row or a removed one: a fresh start, as a
|
|
2032
2563
|
* member unless given a role (`wanted`), at the generation a removal
|
|
@@ -2048,6 +2579,7 @@ class VaultService implements Vault {
|
|
|
2048
2579
|
if (wanted?.role === 'owner' && !ownsInstance(acting.live)) throw refused('not_allowed', OWNERS_ONLY);
|
|
2049
2580
|
const { role, scope } = wanted ?? { role: 'member' as const, scope: EVERYWHERE };
|
|
2050
2581
|
d.log.push(accessEntry(actor, row === undefined ? 'member.add' : 'member.restore', principal, 'allow', correlation, { role, scope }));
|
|
2582
|
+
d.changes?.stage(memberRef(principal), { op: row === undefined ? 'member.add' : 'member.restore', ...asStored(role, scope) }, actor);
|
|
2051
2583
|
d.writes.push(async (at) => {
|
|
2052
2584
|
if (row === undefined) {
|
|
2053
2585
|
await store.insertMember(d.tx, {
|
|
@@ -2072,7 +2604,7 @@ class VaultService implements Vault {
|
|
|
2072
2604
|
|
|
2073
2605
|
remove(input: RemoveInput): Promise<Outcome<{ revoked: Grant[]; generation: number }>> {
|
|
2074
2606
|
const { actor, principal } = input;
|
|
2075
|
-
const refused = (code: RefusalCode, message =
|
|
2607
|
+
const refused = (code: RefusalCode, message = REFUSAL_MESSAGES[code]) =>
|
|
2076
2608
|
new Refused(refusal(code, message), [accessEntry(actor, 'member.remove', principal, 'deny', input, {}, code)]);
|
|
2077
2609
|
return this.#decide([actor, principal], async (d) => {
|
|
2078
2610
|
validateCorrelation(input);
|
|
@@ -2091,7 +2623,7 @@ class VaultService implements Vault {
|
|
|
2091
2623
|
if (!runsInstance(holder)) {
|
|
2092
2624
|
// A service account its manager removes: they reach everything it holds (`managesService`).
|
|
2093
2625
|
const theirs = SERVICE.test(principal) && PERSON.test(actor) && row?.status === 'active' &&
|
|
2094
|
-
managesService(holder, (await this.#settingsAt(d.tx, d.reports)).serviceAccounts, actor, await this.#account(d.tx, subject, row));
|
|
2626
|
+
managesService(holder, (await this.#settingsAt(d.tx, d.reports)).serviceAccounts, actor, await this.#account(d.tx, subject, row, d.reads, d.reports));
|
|
2095
2627
|
if (!theirs) throw refused('not_allowed', 'only admins and owners of the whole instance may remove members, and a service account whoever manages it');
|
|
2096
2628
|
}
|
|
2097
2629
|
if (row?.status !== 'active') throw refused(row === undefined ? 'not_a_member' : 'removed');
|
|
@@ -2113,6 +2645,11 @@ class VaultService implements Vault {
|
|
|
2113
2645
|
}
|
|
2114
2646
|
const generation = row.generation + 1;
|
|
2115
2647
|
d.log.push(accessEntry(actor, 'member.remove', principal, 'allow', input, { revoked: revoked.length, generation }));
|
|
2648
|
+
// In 0.15's log, what the writes below do, op by op: every grant revoked, lapsed ones too, the role back to a
|
|
2649
|
+
// Member's, then the removal.
|
|
2650
|
+
for (const grant of held) d.changes?.stage(memberRef(principal), { op: 'access.revoke', place: placeOf(grant) }, actor);
|
|
2651
|
+
if (row.role !== 'member') d.changes?.stage(memberRef(principal), { op: 'member.role', ...asStored('member', EVERYWHERE) }, actor);
|
|
2652
|
+
d.changes?.stage(memberRef(principal), { op: 'member.remove' }, actor);
|
|
2116
2653
|
d.touched.add(principal);
|
|
2117
2654
|
d.writes.push(async (at) => {
|
|
2118
2655
|
await store.deleteGrants(d.tx, principal);
|
|
@@ -2135,8 +2672,15 @@ class VaultService implements Vault {
|
|
|
2135
2672
|
* environment by its slug, which the setting's scope matches, and who
|
|
2136
2673
|
* admitted it, as its row says.
|
|
2137
2674
|
*/
|
|
2138
|
-
async #account(db: Queryable, subject: Standing, row: Member): Promise<ServiceAccount> {
|
|
2139
|
-
|
|
2675
|
+
async #account(db: Queryable, subject: Standing, row: Member, reads: Snapshot, reports: NewEntry[]): Promise<ServiceAccount> {
|
|
2676
|
+
// Every place it holds a grant on, lapsed ones too, as 0.15's log holds its row (T159): one that is not holds nothing,
|
|
2677
|
+
// and so would leave the account easier to manage, not harder. Refused instead.
|
|
2678
|
+
const ids = subject.stored.flatMap((grant) => (grant.environmentId === null ? [] : [grant.environmentId]));
|
|
2679
|
+
// The decision's own read of them, their grants' places among it once 0.15's log is cut over (`asked.seals`); else read.
|
|
2680
|
+
const missing = ids.filter((id) => !reads.environments.has(id));
|
|
2681
|
+
const slugs = missing.length === 0 ? reads.environments : new Map([...reads.environments, ...await store.environmentsById(db, missing, { seals: this.#checksPlaces() })]);
|
|
2682
|
+
if (this.#checksPlaces() && ids.some((id) => !slugs.has(id))) throw new Refused(refusal('tampered', TAMPERED_PLACE), []);
|
|
2683
|
+
this.#placesHold(slugs, ids, reports);
|
|
2140
2684
|
return {
|
|
2141
2685
|
grants: subject.live.grants.map((grant) => ({
|
|
2142
2686
|
role: grant.role,
|
|
@@ -2164,15 +2708,16 @@ class VaultService implements Vault {
|
|
|
2164
2708
|
* entries and none of its own within reach, the setting is closed, never
|
|
2165
2709
|
* the default: otherwise whoever can insert rows could open it again.
|
|
2166
2710
|
*/
|
|
2167
|
-
async #settingsAt(db: Queryable, reports: NewEntry[]): Promise<Settings> {
|
|
2711
|
+
async #settingsAt(db: Queryable, reports: NewEntry[], reads?: Snapshot): Promise<Settings> {
|
|
2712
|
+
// A snapshot's newest first, when it read it (`asked.settings`); the pages before it, as they are asked.
|
|
2168
2713
|
const entry = await newestAuthentic(
|
|
2169
|
-
(limit, before) =>
|
|
2714
|
+
(limit, before) => (reads?.newestSettings !== undefined && before === undefined
|
|
2715
|
+
? Promise.resolve(reads.newestSettings === null ? [] : [reads.newestSettings])
|
|
2716
|
+
: store.newestVaultEntries(db, SETTINGS, limit, before)),
|
|
2170
2717
|
this.#check(reports, null),
|
|
2171
2718
|
);
|
|
2172
|
-
if (entry === 'none') return
|
|
2173
|
-
|
|
2174
|
-
const { serviceAccounts } = JSON.parse(entry.metadata) as { serviceAccounts?: unknown };
|
|
2175
|
-
return isScope(serviceAccounts) ? { serviceAccounts } : CLOSED_SETTINGS;
|
|
2719
|
+
if (entry === 'none') return settingsFrom(null);
|
|
2720
|
+
return typeof entry === 'string' ? CLOSED_SETTINGS : settingsFrom(entry.metadata);
|
|
2176
2721
|
}
|
|
2177
2722
|
|
|
2178
2723
|
// --- the instance's access policy -------------------------------------------
|
|
@@ -2193,7 +2738,7 @@ class VaultService implements Vault {
|
|
|
2193
2738
|
* no role holds anything but Owner, otherwise whoever can insert rows
|
|
2194
2739
|
* could unprotect them. Its edits are then unknown, not none.
|
|
2195
2740
|
*/
|
|
2196
|
-
async #policyAt(db: Queryable, reports: NewEntry[], at: number, reads?: Snapshot): Promise<{ policy: Policy; edits: PolicyEdits; readable: boolean }> {
|
|
2741
|
+
async #policyAt(db: Queryable, reports: NewEntry[], at: number, reads?: Snapshot): Promise<{ policy: Policy; edits: PolicyEdits; readable: boolean; seq: bigint | null }> {
|
|
2197
2742
|
// The snapshot's newest first, with the rows it protects; the pages before it, as they are asked.
|
|
2198
2743
|
const entry = await newestAuthentic(
|
|
2199
2744
|
(limit, before) => (reads !== undefined && before === undefined
|
|
@@ -2201,11 +2746,13 @@ class VaultService implements Vault {
|
|
|
2201
2746
|
: store.newestVaultEntries(db, POLICY, limit, before)),
|
|
2202
2747
|
this.#check(reports, null),
|
|
2203
2748
|
);
|
|
2204
|
-
if (entry === 'none') return { policy: DEFAULT_POLICY, edits: NO_EDITS, readable: true };
|
|
2749
|
+
if (entry === 'none') return { policy: DEFAULT_POLICY, edits: NO_EDITS, readable: true, seq: null };
|
|
2205
2750
|
const edits = typeof entry === 'string' ? null : readEdits(JSON.parse(entry.metadata) as { policy?: unknown });
|
|
2206
|
-
if (edits === null) return { policy: CLOSED_POLICY, edits: NO_EDITS, readable: false };
|
|
2207
|
-
const rows = entry === reads?.newestPolicy
|
|
2208
|
-
|
|
2751
|
+
if (edits === null) return { policy: CLOSED_POLICY, edits: NO_EDITS, readable: false, seq: null };
|
|
2752
|
+
const rows = entry === reads?.newestPolicy
|
|
2753
|
+
? reads.environments
|
|
2754
|
+
: await store.environmentsById(db, edits.protected.map((place) => place.environmentId), { seals: this.#checksPlaces() });
|
|
2755
|
+
return { policy: this.#livePolicy(rows, edits, at, reports), edits, readable: true, seq: (entry as StoredEntry).seq };
|
|
2209
2756
|
}
|
|
2210
2757
|
|
|
2211
2758
|
changePolicy(input: ChangePolicyInput): Promise<Outcome<{ policy: Policy; seq: number | null }>> {
|
|
@@ -2224,7 +2771,7 @@ class VaultService implements Vault {
|
|
|
2224
2771
|
...at,
|
|
2225
2772
|
metadata: JSON.stringify({ ...detail, ...traced(input) }),
|
|
2226
2773
|
});
|
|
2227
|
-
const refused = (code: RefusalCode, message =
|
|
2774
|
+
const refused = (code: RefusalCode, message = REFUSAL_MESSAGES[code]) =>
|
|
2228
2775
|
new Refused(refusal(code, message), [entry('deny', { change: describeChange(change) }, code)]);
|
|
2229
2776
|
return this.#decide([actor], async (d) => {
|
|
2230
2777
|
validateCorrelation(input);
|
|
@@ -2244,6 +2791,8 @@ class VaultService implements Vault {
|
|
|
2244
2791
|
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");
|
|
2245
2792
|
// Those the policy no longer holds stay out: a deleted environment's id, and an unwritten fork's past its window,
|
|
2246
2793
|
// neither of which comes back, the window being one a fork writes its row well within.
|
|
2794
|
+
// What 0.15's log changes with it: the roles object, or each environment whose protection moves.
|
|
2795
|
+
const changes: { ref: Ref & { kind: 'roles' | 'environment' }; op: Op }[] = [];
|
|
2247
2796
|
const next: PolicyEdits = {
|
|
2248
2797
|
roles: { ...edits.roles },
|
|
2249
2798
|
instanceRoles: { ...edits.instanceRoles },
|
|
@@ -2271,6 +2820,7 @@ class VaultService implements Vault {
|
|
|
2271
2820
|
throw refused('invalid', `${change.role} is granted on environments: as it ships, it holds a project-wide permission, which would make those grants incoherent`);
|
|
2272
2821
|
}
|
|
2273
2822
|
delete table[change.role];
|
|
2823
|
+
changes.push({ ref: ROLES_REF as Ref & { kind: 'roles' }, op: { op: 'role.define', table: instance ? 'instanceRoles' : 'roles', role: change.role, definition: null } });
|
|
2274
2824
|
} else {
|
|
2275
2825
|
const problem = definitionProblem(change.definition);
|
|
2276
2826
|
if (problem !== null) throw refused('invalid', problem);
|
|
@@ -2282,6 +2832,7 @@ class VaultService implements Vault {
|
|
|
2282
2832
|
const shipped = instance ? INSTANCE_ROLES[change.role as InstanceRole] : isBuiltinRole(change.role) ? ROLES[change.role] : undefined;
|
|
2283
2833
|
if (shipped !== undefined && JSON.stringify(normalDefinition(shipped)) === JSON.stringify(definition)) delete table[change.role];
|
|
2284
2834
|
else table[change.role] = definition;
|
|
2835
|
+
changes.push({ ref: ROLES_REF as Ref & { kind: 'roles' }, op: { op: 'role.define', table: instance ? 'instanceRoles' : 'roles', role: change.role, definition } });
|
|
2285
2836
|
}
|
|
2286
2837
|
} else if (change?.kind === 'protect') {
|
|
2287
2838
|
if (typeof change.projectId !== 'string' || !UUID.test(change.projectId) || !Array.isArray(change.environmentIds) || change.environmentIds.length === 0
|
|
@@ -2291,28 +2842,52 @@ class VaultService implements Vault {
|
|
|
2291
2842
|
if (!(await store.projectLive(d.tx, change.projectId))) throw refused('invalid', 'no such project');
|
|
2292
2843
|
at.projectId = change.projectId;
|
|
2293
2844
|
// Each a live environment of the project, or none yet: a fork's id, protected before its row is written.
|
|
2294
|
-
const found = await store.environmentsById(d.tx, change.environmentIds);
|
|
2845
|
+
const found = await store.environmentsById(d.tx, change.environmentIds, { seals: this.#checksPlaces() });
|
|
2846
|
+
// Each as 0.15's log holds its row (T159, review r1): its project and whether it was deleted decide what changes, and
|
|
2847
|
+
// a tombstone set around the log would keep a protection's end from its object. One the log has never held (no
|
|
2848
|
+
// seal, no change) is 0.14's alone, which the change path takes as such.
|
|
2849
|
+
this.#placesHold(found, [...found].flatMap(([id, row]) => (row.sealed?.seal == null && row.sealed?.newest == null ? [] : [id])), d.reports);
|
|
2295
2850
|
if ([...found.values()].some((environment) => environment.projectId !== change.projectId)) throw refused('invalid', 'each environment is one of the project\'s');
|
|
2296
2851
|
if (change.environmentIds.length === 1 && found.has(change.environmentIds[0]!)) at.environmentId = change.environmentIds[0];
|
|
2852
|
+
// An environment whose object 0.15's log changes with this: one with a row, not deleted (`environment.protect`).
|
|
2853
|
+
const live015 = (id: string) => found.get(id)?.deleted === false;
|
|
2297
2854
|
if (change.protected) {
|
|
2298
2855
|
if (!allows(acting.live, 'environment.manage', { projectId: change.projectId })) throw refused('not_allowed', 'protecting an environment takes managing its project\'s environments');
|
|
2299
2856
|
const known = new Set(next.protected.map((place) => place.environmentId));
|
|
2857
|
+
// An id with no row yet, a fork's, is no object of 0.15's log: its creation carries its protection (plan PR 4b).
|
|
2858
|
+
// A deleted one changes no more there: 0.14 keeps its id in the edits, which its policy reads past.
|
|
2859
|
+
for (const id of change.environmentIds) if (!known.has(id) && live015(id)) changes.push({ ref: { kind: 'environment', id }, op: { op: 'environment.protect', protected: true } });
|
|
2300
2860
|
next.protected = [...next.protected, ...change.environmentIds.filter((id) => !known.has(id)).map((environmentId) => ({ projectId: change.projectId, environmentId, at: d.at }))]
|
|
2301
2861
|
.sort((a, b) => (a.environmentId < b.environmentId ? -1 : 1));
|
|
2302
2862
|
} else {
|
|
2303
2863
|
// Unprotecting widens what people hold there: an Owner of the project's to do, or of the whole instance's (R30).
|
|
2304
2864
|
// It takes off only what the project protects: a fork's id, with no row yet, is another project's to unprotect.
|
|
2305
2865
|
if (!ownsProject(acting.live, change.projectId)) throw refused('not_allowed', 'only an Owner of its project, or of the whole instance, unprotects an environment');
|
|
2866
|
+
for (const place of next.protected) {
|
|
2867
|
+
if (place.projectId === change.projectId && change.environmentIds.includes(place.environmentId) && live015(place.environmentId)) {
|
|
2868
|
+
changes.push({ ref: { kind: 'environment', id: place.environmentId }, op: { op: 'environment.protect', protected: false } });
|
|
2869
|
+
}
|
|
2870
|
+
}
|
|
2306
2871
|
next.protected = next.protected.filter((place) => place.projectId !== change.projectId || !change.environmentIds.includes(place.environmentId));
|
|
2307
2872
|
}
|
|
2308
2873
|
} else {
|
|
2309
2874
|
throw refused('invalid', 'a change is to a role, an instance role, or protection');
|
|
2310
2875
|
}
|
|
2311
2876
|
// The policy now, and the newest change, which it is as of: this one's, when it changed anything.
|
|
2312
|
-
const answer = {
|
|
2877
|
+
const answer = {
|
|
2878
|
+
policy: this.#livePolicy(await store.environmentsById(d.tx, next.protected.map((place) => place.environmentId), { seals: this.#checksPlaces() }), next, d.at, d.reports),
|
|
2879
|
+
seq: newest === null ? null : Number(newest),
|
|
2880
|
+
};
|
|
2313
2881
|
if (!readable || JSON.stringify(next) !== JSON.stringify(edits)) {
|
|
2882
|
+
// With 0.15's log, ordered against the vault's issues in flight, which check the policy under the issue lock shared:
|
|
2883
|
+
// exclusive, its rows written, before its append's trigger takes the fence (`holdIssues`).
|
|
2884
|
+
if (this.#prepared.log015 !== null) await holdIssues(d.tx);
|
|
2314
2885
|
const made = entry('allow', { policy: next, change: describeChange(change), before: describeBefore(change, before) });
|
|
2315
2886
|
d.log.push(made);
|
|
2887
|
+
for (const { ref, op } of changes) d.changes?.stage(ref, op, actor);
|
|
2888
|
+
d.after.push((seqOf) => {
|
|
2889
|
+
for (const { ref } of changes) d.changes?.cite(ref, BigInt(seqOf(made)));
|
|
2890
|
+
});
|
|
2316
2891
|
d.after.push((seqOf) => (answer.seq = seqOf(made)));
|
|
2317
2892
|
}
|
|
2318
2893
|
return answer;
|
|
@@ -2330,7 +2905,7 @@ class VaultService implements Vault {
|
|
|
2330
2905
|
requestId: input.requestId ?? null,
|
|
2331
2906
|
metadata: JSON.stringify({ ...detail, ...traced(input) }),
|
|
2332
2907
|
});
|
|
2333
|
-
const refused = (code: RefusalCode, message =
|
|
2908
|
+
const refused = (code: RefusalCode, message = REFUSAL_MESSAGES[code]) =>
|
|
2334
2909
|
new Refused(refusal(code, message), [entry('deny', { serviceAccounts: input.settings?.serviceAccounts ?? null }, code)]);
|
|
2335
2910
|
return this.#decide([actor], async (d) => {
|
|
2336
2911
|
validateCorrelation(input);
|
|
@@ -2404,10 +2979,10 @@ class VaultService implements Vault {
|
|
|
2404
2979
|
* service that cannot answer leaves it undecided: the error is thrown,
|
|
2405
2980
|
* and the next call asks again.
|
|
2406
2981
|
*/
|
|
2407
|
-
async #kekMismatch(): Promise<string | null> {
|
|
2982
|
+
async #kekMismatch(deadline?: number): Promise<string | null> {
|
|
2408
2983
|
const prepared = this.#prepared;
|
|
2409
2984
|
if (!prepared.kekChecked) {
|
|
2410
|
-
const wrong = await this.#checkKeks();
|
|
2985
|
+
const wrong = await this.#checkKeks(deadline);
|
|
2411
2986
|
if (!prepared.kekChecked) {
|
|
2412
2987
|
prepared.wrongKek = wrong;
|
|
2413
2988
|
prepared.kekChecked = true;
|
|
@@ -2422,14 +2997,16 @@ class VaultService implements Vault {
|
|
|
2422
2997
|
* keys it wrapped, if there are any; then its check value is recorded.
|
|
2423
2998
|
* A KEK that opens neither is not the one that wrapped the data.
|
|
2424
2999
|
*/
|
|
2425
|
-
async #checkKeks(): Promise<string | null> {
|
|
3000
|
+
async #checkKeks(deadline?: number): Promise<string | null> {
|
|
2426
3001
|
// A vault that may not write cannot record a check either; why it may not is the answer.
|
|
2427
3002
|
const superseded = await this.#settled();
|
|
2428
3003
|
if (superseded !== null) return superseded;
|
|
2429
3004
|
const checks = await this.#checkValues(this.#db);
|
|
2430
3005
|
const budget = this.#prepared.options.keyBudgetMs;
|
|
2431
3006
|
for (const kek of this.#config.keks.all) {
|
|
2432
|
-
|
|
3007
|
+
// A read's, within what is left of its deadline.
|
|
3008
|
+
const spend = within(deadline, budget);
|
|
3009
|
+
const operation = { deadline: Date.now() + spend, signal: AbortSignal.timeout(spend) };
|
|
2433
3010
|
const opens = async (wrapped: WrappedDek, context: SecretContext, expected?: Buffer) => {
|
|
2434
3011
|
try {
|
|
2435
3012
|
const key = await kek.unwrap(wrapped, context, operation);
|
|
@@ -2460,6 +3037,8 @@ class VaultService implements Vault {
|
|
|
2460
3037
|
// The key it opened, if any, is in the log, as every key the vault opens is. Another call that
|
|
2461
3038
|
// checked at the same time may have recorded one first: under the log's lock, the first stays.
|
|
2462
3039
|
await this.#db.transaction(async (tx) => {
|
|
3040
|
+
// Once a process, by its first key operation: a read's waits as the read's, within its deadline.
|
|
3041
|
+
await store.boundLockWaits(tx, within(deadline, READ_LOCK_TIMEOUT_MS), true);
|
|
2463
3042
|
await lockLogHead(tx);
|
|
2464
3043
|
if ((await this.#checkValues(tx)).has(`${kek.provider}:${kek.keyId}`)) return;
|
|
2465
3044
|
await this.#append(tx, [
|
|
@@ -2607,12 +3186,12 @@ class VaultService implements Vault {
|
|
|
2607
3186
|
stalled = `the rehash of the past did not run, and goes on where it was next time: ${(error as Error).message}`;
|
|
2608
3187
|
}
|
|
2609
3188
|
}
|
|
2610
|
-
|
|
3189
|
+
const decided = await this.#decide([], async (d) => {
|
|
2611
3190
|
for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
|
|
2612
3191
|
// Checkpoints one at a time, each against the one before.
|
|
2613
3192
|
const head = await lockLogHead(d.tx);
|
|
2614
3193
|
const latest = await store.latestVaultEntry(d.tx, [CHECKPOINT]);
|
|
2615
|
-
const refused = (code: RefusalCode, detail: Record<string, unknown>, message =
|
|
3194
|
+
const refused = (code: RefusalCode, detail: Record<string, unknown>, message = REFUSAL_MESSAGES[code]) =>
|
|
2616
3195
|
new Refused(refusal(code, message), [
|
|
2617
3196
|
{ actor: SCHEDULER, action: CHECKPOINT, decision: 'deny', code, metadata: JSON.stringify(detail) },
|
|
2618
3197
|
]);
|
|
@@ -2683,6 +3262,15 @@ class VaultService implements Vault {
|
|
|
2683
3262
|
d.log.push({ actor: SCHEDULER, action: CHECKPOINT, decision: 'allow', relatedSeq: at.seq, metadata: JSON.stringify(kept) });
|
|
2684
3263
|
return stalled === null ? { checkpoint } : { checkpoint, warning: stalled };
|
|
2685
3264
|
});
|
|
3265
|
+
// Then the bulk count's upkeep, which no read waits on, after a checkpoint signed, never instead of a refusal: the
|
|
3266
|
+
// releases no window can count again, kept a day and an hour (`RELEASES_KEPT_MS`), longer than any window, up to
|
|
3267
|
+
// `RELEASES_PRUNED` of them, in one statement of its own, holding no lock any read takes but the rows it deletes,
|
|
3268
|
+
// and none an append holds. What is kept is the database's to say, not the caller's: no login forgets a release a
|
|
3269
|
+
// window counts. A failure leaves it to the next checkpoint, and says so.
|
|
3270
|
+
if (!decided.ok) return decided;
|
|
3271
|
+
const pruned = await store.pruneReleases(this.#db, RELEASES_PRUNED).then(() => null, (error: Error) => `the releases the bulk limit counts were not pruned, and the next checkpoint tries again: ${error.message}`);
|
|
3272
|
+
if (pruned === null) return decided;
|
|
3273
|
+
return { ...decided, warning: decided.warning === undefined ? pruned : `${decided.warning}; ${pruned}` };
|
|
2686
3274
|
}
|
|
2687
3275
|
|
|
2688
3276
|
/**
|
|
@@ -2759,7 +3347,7 @@ class VaultService implements Vault {
|
|
|
2759
3347
|
return JSON.parse(refused.metadata) as { failedAtSeq?: number | null; reason?: string };
|
|
2760
3348
|
}
|
|
2761
3349
|
|
|
2762
|
-
async about(): Promise<{ checkpointKeys: Record<string, CheckpointKey>; owners: string[]; rootAdmins: [] }> {
|
|
3350
|
+
async about(): Promise<{ checkpointKeys: Record<string, CheckpointKey>; owners: string[]; rootAdmins: []; log?: '0.15'; uses?: true; batchUses?: true; sealing?: true; credentials?: true; issues?: true }> {
|
|
2763
3351
|
await this.#settled();
|
|
2764
3352
|
const { signers, since } = this.#prepared;
|
|
2765
3353
|
// A key it replaced vouches only for prefixes before its first entry under the current one: none, before it has one.
|
|
@@ -2769,9 +3357,684 @@ class VaultService implements Vault {
|
|
|
2769
3357
|
owners: [...this.#config.owners],
|
|
2770
3358
|
// For an app of 0.5 while it runs beside this vault: there are none, and its lists and checks need the field.
|
|
2771
3359
|
rootAdmins: [],
|
|
3360
|
+
// With it, the vault makes sign-ins through bindings and revokes credentials itself (credentials.ts).
|
|
3361
|
+
...(this.#prepared.log015 === null ? {} : { log: '0.15' as const, sealing: true as const, credentials: true as const, issues: true as const, binds: true as const }),
|
|
3362
|
+
// 0.15's uses need its log and the hot path's login: the app opens values through them only then (`use`).
|
|
3363
|
+
// A request's several keys in one call too (`useMany`, T167), said so that an app newer than its vault asks for it
|
|
3364
|
+
// only of a vault that has it.
|
|
3365
|
+
...(this.#prepared.log015 === null || this.#hot === undefined ? {} : { uses: true as const, batchUses: true as const }),
|
|
3366
|
+
};
|
|
3367
|
+
}
|
|
3368
|
+
|
|
3369
|
+
/**
|
|
3370
|
+
* 0.15's cutover from 0.14 (cutover.ts; plan PR 9; formal/Migration015.tla).
|
|
3371
|
+
* Done once: the marker read with no lock first, so that each isolate's
|
|
3372
|
+
* start stalls nothing once it is. Without `input`, where 0.14's chain is
|
|
3373
|
+
* verified from, for the app to check its own entries from there.
|
|
3374
|
+
*
|
|
3375
|
+
* With it: 0.14's chain walked on from its newest checkpoint, outside any
|
|
3376
|
+
* transaction (VerifyChain); then one transaction, under every member's
|
|
3377
|
+
* row, 0.14's head, the issue lock shared and the fence (RowFirst,
|
|
3378
|
+
* CutoverHeadLock, CutoverIssueFirst, CutoverFence), the marker read again
|
|
3379
|
+
* (Marker), the rest of the chain to the head, what the app checked still
|
|
3380
|
+
* the chain's and nothing of the app's since (VouchedHeld), every row read
|
|
3381
|
+
* and checked (`planCutover`), the places 0.14 deleted made as 0.15 leaves
|
|
3382
|
+
* them, each object's genesis, its own 0.14 entry, `log.cutover`, and the
|
|
3383
|
+
* cutover's change naming it. Answers once that change is sealed (R51).
|
|
3384
|
+
*/
|
|
3385
|
+
async cutover(input?: CutoverInput | null): Promise<CutoverOutcome> {
|
|
3386
|
+
const log015 = this.#log015();
|
|
3387
|
+
const prepared = this.#prepared;
|
|
3388
|
+
if (prepared.cutover || (await this.#cutoverSealed())) {
|
|
3389
|
+
prepared.cutover = true;
|
|
3390
|
+
return { ok: true, done: true };
|
|
3391
|
+
}
|
|
3392
|
+
if (prepared.since === null && prepared.superseded === null) await this.#settle();
|
|
3393
|
+
if (prepared.superseded !== null) return { ok: false, refused: 'refused', why: prepared.superseded };
|
|
3394
|
+
const from = await this.#resume(this.#db, 'newest');
|
|
3395
|
+
if (!from.ok) return this.#cutoverRefused(`0.14's chain does not verify at entry ${from.failedAtSeq}: ${from.reason}`);
|
|
3396
|
+
const mark = { seq: from.at.seq.toString(), hash: from.at.hash.toString('hex') };
|
|
3397
|
+
// Over RPC or a socket, no input arrives as null.
|
|
3398
|
+
if (input == null) return { ok: true, done: false, from: mark };
|
|
3399
|
+
// The app checked its entries from where this walk starts, or another checkpoint came since: it checks again.
|
|
3400
|
+
if (input.checked?.from?.seq !== mark.seq || input.checked.from.hash !== mark.hash) return { ok: false, refused: 'moved', why: "0.14's newest checkpoint is not the one the app checked from" };
|
|
3401
|
+
// 0.14's chain whole, outside any transaction (VerifyChain): the prefix its newest checkpoint signed, rehashed from the
|
|
3402
|
+
// first entry, which must come to the hash it signed (an entry edited, deleted or moved before it does not: review
|
|
3403
|
+
// r4), each entry there MAC'd when that checkpoint was signed; then the entries since, by the vault's MACs. Once a
|
|
3404
|
+
// cutover: hashes alone, no MAC, no tally.
|
|
3405
|
+
const signed = await relink(this.#db, START, from.at.seq, Infinity);
|
|
3406
|
+
if (!signed.ok) return this.#cutoverRefused(`0.14's chain does not verify at entry ${signed.failedAtSeq}: ${signed.reason}`);
|
|
3407
|
+
if (signed.at.seq !== from.at.seq || !signed.at.hash.equals(from.at.hash)) return this.#cutoverRefused(`0.14's chain up to entry ${from.at.seq} is not the prefix its newest checkpoint signed`);
|
|
3408
|
+
const walked = await this.#walkOn(from, Infinity);
|
|
3409
|
+
if (!walked.ok) return this.#cutoverRefused(`0.14's chain does not verify at entry ${walked.failedAtSeq}: ${walked.reason}`);
|
|
3410
|
+
const reports: NewEntry[] = [];
|
|
3411
|
+
let number: bigint;
|
|
3412
|
+
try {
|
|
3413
|
+
number = await this.#db.transaction((tx) => this.#cutOver(tx, input, walked, reports));
|
|
3414
|
+
} catch (error) {
|
|
3415
|
+
if (error instanceof VouchMoved || error instanceof Retry) return { ok: false, refused: 'moved', why: error instanceof VouchMoved ? error.message : 'a member was admitted meanwhile' };
|
|
3416
|
+
if (error instanceof CutoverRefused) return this.#cutoverRefused(error.message);
|
|
3417
|
+
throw error;
|
|
3418
|
+
}
|
|
3419
|
+
this.#reported(reports);
|
|
3420
|
+
// Done once sealed (R51), whichever vault's change it is: one found under the locks too.
|
|
3421
|
+
await log015.sealThrough(number);
|
|
3422
|
+
prepared.cutover = true;
|
|
3423
|
+
return { ok: true, done: true };
|
|
3424
|
+
}
|
|
3425
|
+
|
|
3426
|
+
/** The cutover's transaction (`cutover`): its change's number, another vault's if it cut over first. */
|
|
3427
|
+
async #cutOver(tx: Transaction, input: CutoverInput, walked: Walked, reports: NewEntry[]): Promise<bigint> {
|
|
3428
|
+
const log015 = this.#log015();
|
|
3429
|
+
const { logKeys } = this.#prepared;
|
|
3430
|
+
await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
3431
|
+
const principals = (await store.allMembers(tx)).map((row) => row.principal);
|
|
3432
|
+
const rows = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals);
|
|
3433
|
+
const head = await lockLogHead(tx);
|
|
3434
|
+
await holdFence(tx);
|
|
3435
|
+
const found = await store.cutoverNumber(tx);
|
|
3436
|
+
if (found !== null) return found;
|
|
3437
|
+
if ((await store.allMembers(tx)).length !== rows.size) throw new Retry();
|
|
3438
|
+
// 0.14's chain, to the head it holds: what the walk read still there, and the rest by the vault's MACs.
|
|
3439
|
+
if (!(await carries(tx, headOf(walked.at)))) throw new CutoverRefused(`the log up to entry ${walked.at.seq} changed while the vault walked it`);
|
|
3440
|
+
const walk = new Walk(walked.tally, walked.digest);
|
|
3441
|
+
const rest = await rehash(tx, logKeys, walked.at, walk, {});
|
|
3442
|
+
if (!rest.ok) throw new CutoverRefused(`0.14's chain does not verify at entry ${rest.failedAtSeq}: ${rest.reason}`);
|
|
3443
|
+
const unsigned = await this.#checkpointFault(tx, walk.checkpoints);
|
|
3444
|
+
if (unsigned !== null) throw new CutoverRefused(`0.14's chain does not verify at entry ${unsigned.failedAtSeq}: ${unsigned.reason}`);
|
|
3445
|
+
if (rest.at.seq !== head.nextSeq - 1n || !rest.at.hash.equals(head.headHash)) throw new CutoverRefused("0.14's chain head does not name its last entry");
|
|
3446
|
+
// What the app checked by its MACs is the chain's, and it wrote nothing since: what it vouched for stands (VouchedHeld).
|
|
3447
|
+
const to = { seq: Number(input.checked.to.seq), hash: input.checked.to.hash };
|
|
3448
|
+
if (!Number.isSafeInteger(to.seq) || to.seq < -1 || BigInt(to.seq) > rest.at.seq || !(await carries(tx, to))) throw new VouchMoved("the chain the app checked is not 0.14's now");
|
|
3449
|
+
if (await store.appEntriesAfter(tx, BigInt(to.seq))) throw new VouchMoved('the app wrote to 0.14\'s log since it vouched for its rows');
|
|
3450
|
+
|
|
3451
|
+
const at = await this.#now(tx);
|
|
3452
|
+
const { edits, readable, seq: policySeq } = await this.#policyAt(tx, reports, at);
|
|
3453
|
+
if (!readable) throw new CutoverRefused("0.14's newest policy is not the vault's: its roles and protection are not known");
|
|
3454
|
+
const logged = await store.loggedObjects(tx);
|
|
3455
|
+
const held = new Map<string, GrantRow[]>();
|
|
3456
|
+
for (const grant of await store.grants(tx)) held.set(grant.principal, [...(held.get(grant.principal) ?? []), grant]);
|
|
3457
|
+
const seals = await store.memberSeals(tx);
|
|
3458
|
+
// Each member's newest access entry, all in one read: a row whose MAC holds and names it, the vault's, is as 0.14's
|
|
3459
|
+
// `#integrity` takes it; any other is checked by it, one read each, and reported.
|
|
3460
|
+
const newest = await store.newestAccessEntries(tx);
|
|
3461
|
+
const members = [];
|
|
3462
|
+
for (const member of rows.values()) {
|
|
3463
|
+
const grants = held.get(member.principal) ?? [];
|
|
3464
|
+
const seal = seals.get(member.principal) ?? null;
|
|
3465
|
+
// A member 0.15's log holds already is its log's: not checked as 0.14's again.
|
|
3466
|
+
const fresh = seal === null && !logged.has(objectKey(memberRef(member.principal)));
|
|
3467
|
+
const entry = newest.get(member.principal);
|
|
3468
|
+
const plain = entry !== undefined && entry.seq === member.accessSeq && this.#authentic(entry) && this.#sealed(member, grants);
|
|
3469
|
+
members.push({ member, grants, seal, genuine: !fresh || plain || (await this.#integrity(tx, member.principal, member, grants, reports)) === null });
|
|
3470
|
+
}
|
|
3471
|
+
const secrets = await store.everySecret(tx);
|
|
3472
|
+
const references = await this.#referencesMade(tx, secrets);
|
|
3473
|
+
const plan = planCutover({
|
|
3474
|
+
logged,
|
|
3475
|
+
config: await store.configRows(tx),
|
|
3476
|
+
members,
|
|
3477
|
+
edits,
|
|
3478
|
+
policySeq,
|
|
3479
|
+
bulk: this.#config.bulkLimit,
|
|
3480
|
+
projects: await store.everyProject(tx),
|
|
3481
|
+
environments: await store.everyEnvironment(tx),
|
|
3482
|
+
secrets,
|
|
3483
|
+
references,
|
|
3484
|
+
input,
|
|
3485
|
+
credentials: await store.credentialsAt(tx, input.credentials.map((columns) => columns.id)),
|
|
3486
|
+
bindings: await store.bindingsAt(tx, input.bindings.map((columns) => columns.id)),
|
|
3487
|
+
connections: await store.connectionsAt(tx, input.connections.map((columns) => columns.id)),
|
|
3488
|
+
});
|
|
3489
|
+
// Reported as a change refused on them would be (`#notLogged`), once: a member's by their check (`#integrity`).
|
|
3490
|
+
for (const { ref, fault } of plan.skipped) if (ref.kind !== 'principal') this.#report(reports, null, fault === 'tampered' ? 'mac' : 'stale', `log.${ref.kind}:${ref.id}`);
|
|
3491
|
+
|
|
3492
|
+
await store.endReferenceRows(tx, plan.ends, at);
|
|
3493
|
+
await store.cutoverPlaces(tx, at, plan.rename, plan.archive);
|
|
3494
|
+
// Its own entry last, the head its change names (the anchor): the trigger's issue lock and fence held already.
|
|
3495
|
+
const appended = await this.#append(tx, [...reports, {
|
|
3496
|
+
actor: VAULT_ACTOR, action: CUTOVER, decision: 'allow',
|
|
3497
|
+
metadata: JSON.stringify({ from: { seq: walked.from.at.seq.toString(), hash: walked.from.at.hash.toString('hex') }, sealed: plan.genesis.length + plan.bindings.length, skipped: plan.skipped.length }),
|
|
3498
|
+
}]);
|
|
3499
|
+
const calls = genesisCalls(log015, plan.genesis, at);
|
|
3500
|
+
for (let i = 0; i < calls.length; i += GENESIS_BATCH) {
|
|
3501
|
+
const numbers = await store.changeInputs(tx, calls.slice(i, i + GENESIS_BATCH));
|
|
3502
|
+
if (numbers.some((n) => n === null)) throw new VouchMoved('an object\'s row moved under the cutover\'s locks');
|
|
3503
|
+
}
|
|
3504
|
+
for (const call of genesisCalls(log015, plan.bindings, at)) {
|
|
3505
|
+
if ((await store.sealBinding(tx, call)) === null) throw new VouchMoved(`the binding ${call.ref.id}'s row moved under the cutover's locks`);
|
|
3506
|
+
}
|
|
3507
|
+
const anchor = { seq: appended.nextSeq - 1n, hash: appended.headHash };
|
|
3508
|
+
return store.logCutover(tx, cutoverRow(log015, at, cutoverPayload(anchor, walked.from.at)));
|
|
3509
|
+
}
|
|
3510
|
+
|
|
3511
|
+
/**
|
|
3512
|
+
* Each key's newest reference row's `reference.create`, by its number:
|
|
3513
|
+
* whether it is the vault's, by its MAC, about that row, holder and
|
|
3514
|
+
* source; and whether an authentic `reference.end` ended it.
|
|
3515
|
+
*/
|
|
3516
|
+
async #referencesMade(db: Queryable, secrets: readonly store.SecretRead[]): Promise<Map<string, { authentic: boolean; ended: boolean }>> {
|
|
3517
|
+
const rows = secrets.flatMap((secret) => (secret.reference === null ? [] : [{ secret, reference: secret.reference }]));
|
|
3518
|
+
const made = await store.entriesAt(db, rows.map(({ reference }) => reference.createdSeq));
|
|
3519
|
+
// An entry of these the vault's MAC does not hold is damaged history, read under the cutover's locks: refused, never
|
|
3520
|
+
// taken for no end (a reference 0.14 holds ended would be sealed live: review r4) nor for no reference.
|
|
3521
|
+
const damaged = (entry: StoredEntry) => {
|
|
3522
|
+
throw new CutoverRefused(`0.14's ${entry.action} at entry ${entry.seq} does not carry the vault's MAC: its history is damaged`);
|
|
3523
|
+
};
|
|
3524
|
+
const ends = await store.referenceEnds(db, rows.map(({ reference }) => reference.createdSeq));
|
|
3525
|
+
for (const entry of ends) if (!this.#authentic(entry)) damaged(entry);
|
|
3526
|
+
const ended = new Set(ends.map((entry) => entry.relatedSeq!.toString()));
|
|
3527
|
+
return new Map(rows.map(({ secret, reference }) => {
|
|
3528
|
+
const entry = made.get(reference.createdSeq);
|
|
3529
|
+
const created = entry !== undefined && entry.author === 'vault' && entry.action === 'reference.create' && entry.decision === 'allow';
|
|
3530
|
+
if (created && !this.#authentic(entry)) damaged(entry);
|
|
3531
|
+
const sealed = !created ? null : sealedReference(entry, reference.id);
|
|
3532
|
+
// The holder where the vault made it: a row moved around it, its id and reference row kept, would read the source
|
|
3533
|
+
// from another place, where 0.14's read through it is refused.
|
|
3534
|
+
const authentic = sealed !== null && sealed.holder.secretId === secret.id && sealed.holder.projectId === secret.projectId
|
|
3535
|
+
&& sealed.holder.environmentId === secret.environmentId && sealed.source.secretId === reference.source;
|
|
3536
|
+
return [reference.createdSeq.toString(), { authentic, ended: ended.has(reference.createdSeq.toString()) }];
|
|
3537
|
+
}));
|
|
3538
|
+
}
|
|
3539
|
+
|
|
3540
|
+
/** A cutover refused, recorded once a process (`log.cutover`, denied), and answered. */
|
|
3541
|
+
async #cutoverRefused(why: string): Promise<CutoverOutcome> {
|
|
3542
|
+
const reports: NewEntry[] = [];
|
|
3543
|
+
const key = `cutover|${why}`;
|
|
3544
|
+
if (!this.#prepared.reported.has(key)) {
|
|
3545
|
+
const entry: NewEntry = { actor: VAULT_ACTOR, action: CUTOVER, decision: 'deny', code: 'log_broken', metadata: JSON.stringify({ reason: why }) };
|
|
3546
|
+
REPORTED.set(entry, key);
|
|
3547
|
+
reports.push(entry);
|
|
3548
|
+
}
|
|
3549
|
+
if (reports.length > 0) await this.#db.transaction((tx) => this.#append(tx, reports));
|
|
3550
|
+
this.#reported(reports);
|
|
3551
|
+
return { ok: false, refused: 'refused', why };
|
|
3552
|
+
}
|
|
3553
|
+
|
|
3554
|
+
/**
|
|
3555
|
+
* Under 0.15's log, that it is cut over from 0.14 (`cutover`): nothing of
|
|
3556
|
+
* 0.15's is decided, changed or used before, so that the cutover's change
|
|
3557
|
+
* precedes every other (AnchorHolds) and nothing decides on rows it has
|
|
3558
|
+
* not sealed. The marker read once, with no lock, until it is there.
|
|
3559
|
+
*/
|
|
3560
|
+
async #cutOverYet(): Promise<void> {
|
|
3561
|
+
if (!(await this.#cutOverNow())) throw new Error(NOT_CUT_OVER);
|
|
3562
|
+
}
|
|
3563
|
+
|
|
3564
|
+
/** Whether the vault may serve 0.15's log: kept by 0.14's alone, or cut over, its change sealed (`#cutoverSealed`). */
|
|
3565
|
+
async #cutOverNow(): Promise<boolean> {
|
|
3566
|
+
const prepared = this.#prepared;
|
|
3567
|
+
if (prepared.log015 === null || prepared.cutover) return true;
|
|
3568
|
+
if (!(await this.#cutoverSealed())) return false;
|
|
3569
|
+
prepared.cutover = true;
|
|
3570
|
+
return true;
|
|
3571
|
+
}
|
|
3572
|
+
|
|
3573
|
+
/**
|
|
3574
|
+
* Whether the log is cut over, its change sealed: the marker read with no
|
|
3575
|
+
* lock, then, found, its seal waited for (R51), as the vault that wrote it
|
|
3576
|
+
* waits before it answers. Committed and not sealed yet, as when that vault
|
|
3577
|
+
* waits or failed before its seal, it serves nothing until it is. Sealed
|
|
3578
|
+
* long ago, as it is but in a deployment's first moments, a pair this
|
|
3579
|
+
* vault's key signed past it says so (T169): an isolate's first calls, its
|
|
3580
|
+
* uses among them, ask no checkpointer, which may be the one thing that
|
|
3581
|
+
* does not answer.
|
|
3582
|
+
*/
|
|
3583
|
+
async #cutoverSealed(): Promise<boolean> {
|
|
3584
|
+
const number = await store.cutoverNumber(this.#db);
|
|
3585
|
+
if (number === null) return false;
|
|
3586
|
+
if (!(await this.#signedPast(number))) await this.#log015().sealThrough(number);
|
|
3587
|
+
return true;
|
|
3588
|
+
}
|
|
3589
|
+
|
|
3590
|
+
/**
|
|
3591
|
+
* Whether the database's newest checkpoint is a pair this vault's key signed in whose changes tree change `number`
|
|
3592
|
+
* is proved, as its seal proves it: a pair covering its number may cover a gap a writer moved the change into
|
|
3593
|
+
* (review r1). Anything short of that goes to the seal, which alarms on what it refuses.
|
|
3594
|
+
*/
|
|
3595
|
+
async #signedPast(number: bigint): Promise<boolean> {
|
|
3596
|
+
const store = logStore(this.#db);
|
|
3597
|
+
const newest = await store.newest();
|
|
3598
|
+
if (newest === null || newest.upto < number) return false;
|
|
3599
|
+
const pair = await openPair(newest.notes, (await this.#verifierKeys()).verifiers);
|
|
3600
|
+
if (pair === null || pair.upto !== newest.upto) return false;
|
|
3601
|
+
const proof = await proveIn(store, number, { pair, notes: newest.notes }, { tree: 'changes' }).catch(() => null);
|
|
3602
|
+
return proof !== null;
|
|
3603
|
+
}
|
|
3604
|
+
|
|
3605
|
+
/**
|
|
3606
|
+
* The app's changes, sealed (`prepareChanges`, plan PR 4b): no lock,
|
|
3607
|
+
* nothing written, and no read but, for an unarchive, what its key's log
|
|
3608
|
+
* last sealed (`keptObjects`, T134); but the report of a row that is not
|
|
3609
|
+
* the vault's, which refuses them all (`tampered`, `stale`), as a decision
|
|
3610
|
+
* of its own would.
|
|
3611
|
+
*/
|
|
3612
|
+
async prepare(input: PrepareInput): Promise<Outcome<Prepared>> {
|
|
3613
|
+
const log015 = this.#log015();
|
|
3614
|
+
if (typeof input?.actor !== 'string' || input.actor.length === 0 || !Array.isArray(input.changes)) throw new Error('prepare takes an actor and the changes the app will write');
|
|
3615
|
+
// Before the cutover, a change of 0.14's rows alone, which logs nothing (`prepareChanges`): what the app key's
|
|
3616
|
+
// rotation makes as it signs sessions out, which the app's door finishes before it cuts over (review r3). One that
|
|
3617
|
+
// makes an object, or changes one the log holds, waits for the cutover.
|
|
3618
|
+
if (input.changes.some((change) => change.before === null || change.before.seal !== null)) await this.#cutOverYet();
|
|
3619
|
+
try {
|
|
3620
|
+
const restored = input.changes.flatMap((change) => (change.kind === 'secret' && change.restores === true ? [{ kind: 'secret' as const, id: change.id }] : []));
|
|
3621
|
+
const kept = restored.length === 0 ? new Map() : await keptObjects(log015.seals, this.#db, restored);
|
|
3622
|
+
return { ok: true, changes: prepareChanges(log015, Date.now() + this.#prepared.options.clockOffset(), input.actor, input.changes, kept) };
|
|
3623
|
+
} catch (error) {
|
|
3624
|
+
if (!(error instanceof NotLogged)) throw error;
|
|
3625
|
+
const reports: NewEntry[] = [];
|
|
3626
|
+
const refused = this.#notLogged(reports, error);
|
|
3627
|
+
await this.#record(reports);
|
|
3628
|
+
return { ok: false, refusal: refused.refusal };
|
|
3629
|
+
}
|
|
3630
|
+
}
|
|
3631
|
+
|
|
3632
|
+
/** A sign-in through a trust binding, made by the vault (credentials.ts, T149): one statement, then the seal wait (R51). */
|
|
3633
|
+
async signIn(input: SignInInput): Promise<SignInOutcome> {
|
|
3634
|
+
const log015 = this.#log015();
|
|
3635
|
+
await this.#cutOverYet();
|
|
3636
|
+
const at = Date.now() + this.#prepared.options.clockOffset();
|
|
3637
|
+
// The proof, verified here on the sealed binding (D148): what the credential is held to comes of the token, not the app.
|
|
3638
|
+
const proven = await this.#proven(log015, input, at);
|
|
3639
|
+
if ('refused' in proven) return proven;
|
|
3640
|
+
const answer = await signIn(this.#db, log015, at, proven);
|
|
3641
|
+
if (answer.number == null) return { ok: false, refused: answer.state as Exclude<SignInRefusal, 'proof'> };
|
|
3642
|
+
const sealed = await log015.sealThrough(BigInt(answer.number));
|
|
3643
|
+
return { ok: true, number: answer.number, witnessed: sealed.witnessed ?? null };
|
|
3644
|
+
}
|
|
3645
|
+
|
|
3646
|
+
/**
|
|
3647
|
+
* A credential revoked by the vault (credentials.ts, T149): one statement,
|
|
3648
|
+
* then the seal wait. A row whose seal is not the vault's, or not its
|
|
3649
|
+
* object's newest change's, is reported, as `prepare` reports one; one
|
|
3650
|
+
* whose policy moved since its revoke was weighed is refused, nothing
|
|
3651
|
+
* reported.
|
|
3652
|
+
*/
|
|
3653
|
+
async revokeCredential(input: RevokeCredentialInput): Promise<RevokeCredentialOutcome> {
|
|
3654
|
+
const log015 = this.#log015();
|
|
3655
|
+
await this.#cutOverYet();
|
|
3656
|
+
const notLogged = async (error: NotLogged) => {
|
|
3657
|
+
const reports: NewEntry[] = [];
|
|
3658
|
+
this.#notLogged(reports, error);
|
|
3659
|
+
await this.#record(reports);
|
|
3660
|
+
return { ok: false as const, refused: error.fault };
|
|
3661
|
+
};
|
|
3662
|
+
let answer: Answer;
|
|
3663
|
+
try {
|
|
3664
|
+
answer = await revokeCredential(this.#db, log015, Date.now() + this.#prepared.options.clockOffset(), input);
|
|
3665
|
+
} catch (error) {
|
|
3666
|
+
if (error instanceof NotLogged) return notLogged(error);
|
|
3667
|
+
throw error;
|
|
3668
|
+
}
|
|
3669
|
+
if (answer.state === 'stale') return notLogged(new NotLogged({ kind: 'credential', id: input.before.columns.id }, 'stale'));
|
|
3670
|
+
if (answer.state === 'moved' || answer.state === 'busy' || answer.state === 'policy') return { ok: false, refused: answer.state };
|
|
3671
|
+
if (answer.number == null) throw new Error('revoke_credential answered neither a change nor why it made none');
|
|
3672
|
+
const sealed = await log015.sealThrough(BigInt(answer.number));
|
|
3673
|
+
return { ok: true, number: answer.number, witnessed: sealed.witnessed ?? null };
|
|
3674
|
+
}
|
|
3675
|
+
|
|
3676
|
+
/**
|
|
3677
|
+
* What of a sign-in the vault checks itself (D148, PR 3b; formal/Lifecycle.tla's
|
|
3678
|
+
* VaultVerifiesToken, AudienceSealed, Fetched), asking no one: only on a
|
|
3679
|
+
* binding whose seal is its own (BindingSealed: the issuer, keys, audience
|
|
3680
|
+
* and claims it trusts are the ones a manager made it with); a workload's
|
|
3681
|
+
* DPoP proof, whose key's thumbprint names the session's key, the
|
|
3682
|
+
* credential's `jkt` and, where its platform lets the caller choose the
|
|
3683
|
+
* token's audience, that audience, never the app's; and, when the sealed
|
|
3684
|
+
* binding carries its issuer's keys, the token, on those keys
|
|
3685
|
+
* (`verifyRunToken`), the sign-in's spend, times and run then taken from
|
|
3686
|
+
* it. A binding of keys its issuer publishes, which the vault does not
|
|
3687
|
+
* fetch, and a proof that is no ID token (an AWS signed request) take the
|
|
3688
|
+
* app's word for the token, as their binding's seal says.
|
|
3689
|
+
*/
|
|
3690
|
+
async #proven(log015: NonNullable<PreparedVault['log015']>, input: SignInInput, at: number): Promise<SignInInput | { ok: false; refused: 'unsealed' } | { ok: false; refused: 'proof'; reason: string; message: string; kid: string | null }> {
|
|
3691
|
+
const binding = checkBinding(input?.binding?.columns);
|
|
3692
|
+
const seal = typeof input.binding.seal === 'string' ? Buffer.from(input.binding.seal, 'base64') : null;
|
|
3693
|
+
if (seal === null || binding.revoked || binding.principal !== input.principal || !sealedObject(log015.seals, { kind: 'binding', id: binding.id }, bindingObject(binding), seal)) {
|
|
3694
|
+
return { ok: false, refused: 'unsealed' };
|
|
3695
|
+
}
|
|
3696
|
+
const refuse = (reason: string, message: string, kid: string | null = null) => ({ ok: false as const, refused: 'proof' as const, reason, message, kid });
|
|
3697
|
+
const { idToken, dpop } = input;
|
|
3698
|
+
if ((idToken !== null && (typeof idToken?.token !== 'string' || typeof idToken.jwks !== 'string')) || (dpop !== null && typeof dpop !== 'string')) {
|
|
3699
|
+
throw new Error('a sign-in names the run\'s ID token and its binding\'s uploaded keys, or neither, and a DPoP proof or none');
|
|
3700
|
+
}
|
|
3701
|
+
const workload = input.credential.kind === 'workload';
|
|
3702
|
+
// A workload's key: the one its DPoP proof is signed by, for this instance's sign-in, by the binding's own origin.
|
|
3703
|
+
let jkt: string | null = null;
|
|
3704
|
+
if (workload) {
|
|
3705
|
+
if (dpop === null) return refuse('dpop', 'a DPoP header, a proof signed by the key the session will be bound to, comes with the sign-in');
|
|
3706
|
+
const proof = await verifyProof(dpop, { method: 'POST', url: `${new URL(binding.origin).origin}${WORKLOAD_SIGN_IN_PATH}`, skewSeconds: EXCHANGE_SKEW_SECONDS, now: at });
|
|
3707
|
+
if (!proof.ok) return refuse('dpop', `the DPoP proof does not hold: ${proof.message}`);
|
|
3708
|
+
jkt = proof.jkt;
|
|
3709
|
+
if (input.credential.jkt !== jkt) return refuse('dpop', 'the session is bound to the key its DPoP proof is signed by, no other');
|
|
3710
|
+
} else if (dpop !== null) {
|
|
3711
|
+
return refuse('malformed', 'a CI run\'s sign-in takes no DPoP proof');
|
|
3712
|
+
}
|
|
3713
|
+
// The token the vault verifies itself only on keys its binding's seal digests: it fetches none (Lifecycle.tla's Fetched).
|
|
3714
|
+
if (!binding.jwksUri.startsWith(UPLOADED_KEYS)) {
|
|
3715
|
+
if (idToken !== null) return refuse('malformed', 'a binding of keys its issuer publishes takes the app\'s word for its token, which the vault does not verify');
|
|
3716
|
+
return input;
|
|
3717
|
+
}
|
|
3718
|
+
if (idToken === null) return refuse('malformed', 'a sign-in through a binding of uploaded keys comes with its token, for the vault to verify');
|
|
3719
|
+
let proven: RunProof;
|
|
3720
|
+
try {
|
|
3721
|
+
const run = { profile: binding.profile as WorkloadProfile, issuer: binding.issuer, origin: binding.origin, jwksUri: binding.jwksUri, jwks: idToken.jwks, claims: binding.claims, policy: binding.policy };
|
|
3722
|
+
proven = await verifyRunToken(idToken.token, run, { now: new Date(at), workload: jkt === null ? null : { jkt } });
|
|
3723
|
+
} catch (error) {
|
|
3724
|
+
if (error instanceof RunProofRefused) return refuse(error.reason, error.message, error.kid);
|
|
3725
|
+
throw error;
|
|
3726
|
+
}
|
|
3727
|
+
if (workload && proven.notAfter !== null && input.credential.notAfter !== null && input.credential.notAfter > proven.notAfter) {
|
|
3728
|
+
return refuse('expired', 'the session ends no later than the token it was signed in with');
|
|
3729
|
+
}
|
|
3730
|
+
return {
|
|
3731
|
+
...input,
|
|
3732
|
+
spent: proven.spent === null ? null : proven.spent.toString('base64'),
|
|
3733
|
+
validUntil: proven.validUntil,
|
|
3734
|
+
record: { ...input.record, issuer: binding.issuer, run: proven.run },
|
|
3735
|
+
};
|
|
3736
|
+
}
|
|
3737
|
+
|
|
3738
|
+
/**
|
|
3739
|
+
* A token issued by the vault (credentials.ts, T149 PR 2; formal/Lifecycle.tla's
|
|
3740
|
+
* IssueRead, IssueRows): what it is weighed on read, the issuer and the
|
|
3741
|
+
* account in one snapshot and the credential the request came in on
|
|
3742
|
+
* beside it, each row checked as `access` checks it; the issue weighed
|
|
3743
|
+
* with the app's own rules on those reads (`weighIssue`); then one
|
|
3744
|
+
* statement that holds the rows as read, and the seal wait. Nothing is
|
|
3745
|
+
* held between the read and the statement. A row that moved in between
|
|
3746
|
+
* (IssuerCAS) has it read and weighed again, as the model's IssueCheck
|
|
3747
|
+
* does, up to three times; then `moved`, and the app asks again.
|
|
3748
|
+
*/
|
|
3749
|
+
async issueToken(input: IssueTokenInput): Promise<IssueTokenOutcome> {
|
|
3750
|
+
const log015 = this.#log015();
|
|
3751
|
+
await this.#settled();
|
|
3752
|
+
await this.#cutOverYet();
|
|
3753
|
+
const scope = checkIssue(input);
|
|
3754
|
+
for (let tries = 0; ; tries += 1) {
|
|
3755
|
+
const outcome = await this.#issueOnce(log015, input, scope);
|
|
3756
|
+
if (outcome.ok || outcome.refused !== 'moved' || tries === 2) return outcome;
|
|
3757
|
+
}
|
|
3758
|
+
}
|
|
3759
|
+
|
|
3760
|
+
async #issueOnce(log015: NonNullable<PreparedVault['log015']>, input: IssueTokenInput, scope: CredentialScope): Promise<IssueTokenOutcome> {
|
|
3761
|
+
const read = await this.#issuerRead(log015, { ...input, scope, service: input.credential.kind === 'service' });
|
|
3762
|
+
if ('refused' in read) return read;
|
|
3763
|
+
const why = weighIssue(input, scope, read.holder, read.places, read.via?.columns ?? null, read.issue.at, read.service);
|
|
3764
|
+
if (why !== null) {
|
|
3765
|
+
await this.#record(read.reports);
|
|
3766
|
+
return { ok: false, refused: 'refused', why };
|
|
3767
|
+
}
|
|
3768
|
+
await this.#record(read.reports);
|
|
3769
|
+
const answer = await issueToken(this.#db, issueCall(log015, input, read.issue));
|
|
3770
|
+
if (answer.number == null) return { ok: false, refused: answer.state === 'expired' ? 'refused' : answer.state as IssueTokenRefusal, ...(answer.state === 'expired' ? { why: 'its end is past' } : {}) };
|
|
3771
|
+
const sealedAt = await log015.sealThrough(BigInt(answer.number));
|
|
3772
|
+
return { ok: true, number: answer.number, witnessed: sealedAt.witnessed ?? null };
|
|
3773
|
+
}
|
|
3774
|
+
|
|
3775
|
+
/**
|
|
3776
|
+
* What a grant to `principal` by `actor` is weighed on, read and checked
|
|
3777
|
+
* as `access` checks each row (a token's issue, PR 2; a binding's, PR 3a):
|
|
3778
|
+
* the members, their grants, the policy, a service's setting and the
|
|
3779
|
+
* places in one snapshot, the credential the request came in on beside it;
|
|
3780
|
+
* then the issuer's standing through that credential, the account's, and
|
|
3781
|
+
* the scope's places. A refusal records what the reads reported; else the
|
|
3782
|
+
* caller records them before it writes.
|
|
3783
|
+
*/
|
|
3784
|
+
async #issuerRead(
|
|
3785
|
+
log015: NonNullable<PreparedVault['log015']>,
|
|
3786
|
+
input: Pick<IssueTokenInput, 'actor' | 'via' | 'principal' | 'generation' | 'actorAccess' | 'policy'> & { scope: CredentialScope; service: boolean },
|
|
3787
|
+
): Promise<{ ok: false; refused: IssueTokenRefusal; why?: string } | {
|
|
3788
|
+
issue: IssueRead; holder: Holdings; places: (Place | null)[]; via: Awaited<ReturnType<typeof store.issuingCredential>>;
|
|
3789
|
+
service: { setting: ServiceSetting | null; account: ServiceAccount } | null; reports: NewEntry[];
|
|
3790
|
+
}> {
|
|
3791
|
+
const { actor, principal, scope } = input;
|
|
3792
|
+
const environmentIds = [...new Set([
|
|
3793
|
+
...scope.flatMap((grant) => (grant.environmentId == null ? [] : [grant.environmentId])),
|
|
3794
|
+
...input.policy.protected.map(({ environmentId }) => environmentId),
|
|
3795
|
+
])];
|
|
3796
|
+
const reports: NewEntry[] = [];
|
|
3797
|
+
// The members, their grants, the policy, the setting and the places in one statement; the credential beside it.
|
|
3798
|
+
const [reads, via] = await Promise.all([
|
|
3799
|
+
snapshot(this.#db, { principals: [...new Set([actor, principal])], members: true, settings: input.service, environments: environmentIds, seals: this.#checksPlaces() }),
|
|
3800
|
+
input.via === null ? Promise.resolve(null) : store.issuingCredential(this.#db, input.via),
|
|
3801
|
+
]);
|
|
3802
|
+
// The settings from the snapshot's newest entry; the pages before it read only when it fails its check.
|
|
3803
|
+
const settings = input.service ? await this.#settingsAt(this.#db, reports, reads) : null;
|
|
3804
|
+
const at = this.#at(reads);
|
|
3805
|
+
const refuse = async (refused: IssueTokenRefusal, why?: string) => {
|
|
3806
|
+
await this.#record(reports);
|
|
3807
|
+
return { ok: false as const, refused, ...(why === undefined ? {} : { why }) };
|
|
3808
|
+
};
|
|
3809
|
+
// Each environment read, as 0.15's log holds its row (T159): a protected one that is not stays protected, its slug
|
|
3810
|
+
// unknown, as `access` answers it (`#livePolicy`); one the scope names refuses the issue; a member's grant on one
|
|
3811
|
+
// holds nothing (`#standing`), and the account's refuses whoever manages it (`#account`).
|
|
3812
|
+
const faulty = new Set([...reads.environments].flatMap(([id, row]) => (this.#placeFault(id, row, reports) === null ? [] : [id])));
|
|
3813
|
+
// The policy the app weighed the scope on, as `policyStill` holds a write to it: one moved since says so, first.
|
|
3814
|
+
const newestPolicy = reads.newestPolicy === null ? null : String(reads.newestPolicy.seq);
|
|
3815
|
+
const renamed = input.policy.protected.some(({ environmentId, slug }) => {
|
|
3816
|
+
const environment = reads.environments.get(environmentId);
|
|
3817
|
+
return (environment === undefined || faulty.has(environmentId) || environment.deleted ? null : environment.slug) !== slug;
|
|
3818
|
+
});
|
|
3819
|
+
if (newestPolicy !== input.policy.seq || renamed) return refuse('policy');
|
|
3820
|
+
const actorRow = reads.members.get(actor);
|
|
3821
|
+
const acting = await this.#standing(this.#db, actor, actorRow, at, reports, reads);
|
|
3822
|
+
if (acting.status !== 'active' || actorRow === undefined) return refuse('ended');
|
|
3823
|
+
if (input.actorAccess !== null && actorRow.accessSeq.toString() !== input.actorAccess) return refuse('access');
|
|
3824
|
+
// The credential the request came in on: the log's (its seal the vault's, and its object's newest change's), live, its issuer's.
|
|
3825
|
+
let viaScope: CredentialScope | null = null;
|
|
3826
|
+
if (input.via !== null) {
|
|
3827
|
+
if (via === null) return refuse('ended');
|
|
3828
|
+
// One 0.14 made, which the log never held, has nothing the vault can authenticate: the app issues on it as before.
|
|
3829
|
+
if (via.seal === null && via.newest === null) return refuse('unlogged');
|
|
3830
|
+
const object = credentialObject(via.columns);
|
|
3831
|
+
const logged = via.seal !== null && via.newest !== null && via.newest.equals(via.seal) && sealedObject(log015.seals, { kind: 'credential', id: input.via }, object, via.seal);
|
|
3832
|
+
if (!logged || via.columns.principal !== actor || via.columns.revoked || (via.columns.expiresAt ?? Infinity) <= at || via.columns.generation !== actorRow.generation) {
|
|
3833
|
+
return refuse('ended');
|
|
3834
|
+
}
|
|
3835
|
+
viaScope = object.scope;
|
|
3836
|
+
}
|
|
3837
|
+
const caller = actor.startsWith('user:') ? 'person' as const : via?.columns.kind === 'workload' ? 'workload' as const : 'bearer' as const;
|
|
3838
|
+
const holder = through(acting, { credentialScope: viaScope, caller }).live;
|
|
3839
|
+
const principalRow = reads.members.get(principal);
|
|
3840
|
+
const subject = principal === actor ? acting : await this.#standing(this.#db, principal, principalRow, at, reports, reads);
|
|
3841
|
+
if (subject.status !== 'active' || principalRow === undefined || principalRow.generation !== input.generation) {
|
|
3842
|
+
return refuse(principal === actor ? 'ended' : 'gone');
|
|
3843
|
+
}
|
|
3844
|
+
if (scope.some((grant) => grant.environmentId != null && faulty.has(grant.environmentId))) return refuse('refused', TAMPERED_PLACE);
|
|
3845
|
+
// Each place by its environment's slug, which scopes match: one deleted, or of another project, is no place. The
|
|
3846
|
+
// protected ones' slugs are held to the policy under the issue lock (`issue_token`), but those whose rows are not as
|
|
3847
|
+
// the log holds them, which it weighed with none: no slug moves that.
|
|
3848
|
+
const places = scope.map((grant): Place | null => {
|
|
3849
|
+
if (grant.environmentId == null) return { projectId: grant.projectId! };
|
|
3850
|
+
const environment = reads.environments.get(grant.environmentId);
|
|
3851
|
+
return environment === undefined || environment.deleted || environment.projectId !== grant.projectId
|
|
3852
|
+
? null
|
|
3853
|
+
: { projectId: grant.projectId!, environmentId: grant.environmentId, environmentSlug: environment.slug };
|
|
3854
|
+
});
|
|
3855
|
+
let service: { setting: ServiceSetting | null; account: ServiceAccount } | null = null;
|
|
3856
|
+
try {
|
|
3857
|
+
if (settings !== null) service = { setting: settings.serviceAccounts, account: await this.#account(this.#db, subject, principalRow, reads, reports) };
|
|
3858
|
+
} catch (error) {
|
|
3859
|
+
if (error instanceof Refused) return refuse('refused', error.refusal.message);
|
|
3860
|
+
throw error;
|
|
3861
|
+
}
|
|
3862
|
+
return {
|
|
3863
|
+
holder, places, via, service, reports,
|
|
3864
|
+
issue: {
|
|
3865
|
+
at,
|
|
3866
|
+
actor: { generation: actorRow.generation, accessSeq: actorRow.accessSeq },
|
|
3867
|
+
principal: { generation: principalRow.generation, accessSeq: principalRow.accessSeq },
|
|
3868
|
+
via: via === null ? null : { columns: via.columns, seal: via.seal },
|
|
3869
|
+
// The protected environments' slugs, and the scope's as weighed: none moves before the number (Fable on PR 2).
|
|
3870
|
+
slugs: weighed([
|
|
3871
|
+
...input.policy.protected.filter(({ environmentId }) => !faulty.has(environmentId)),
|
|
3872
|
+
...places.flatMap((place) => (place?.environmentId == null ? [] : [{ environmentId: place.environmentId, slug: place.environmentSlug ?? null }])),
|
|
3873
|
+
// The places of the account's grants, by whose slugs `#account` weighed who manages it (review r2).
|
|
3874
|
+
...(service === null ? [] : subject.live.grants.flatMap((grant) => {
|
|
3875
|
+
const row = grant.environmentId === null ? undefined : reads.environments.get(grant.environmentId);
|
|
3876
|
+
return row === undefined ? [] : [{ environmentId: grant.environmentId!, slug: row.deleted ? null : row.slug }];
|
|
3877
|
+
})),
|
|
3878
|
+
]),
|
|
3879
|
+
},
|
|
3880
|
+
};
|
|
3881
|
+
}
|
|
3882
|
+
|
|
3883
|
+
|
|
3884
|
+
/**
|
|
3885
|
+
* A service's bindings changed by the vault (credentials.ts, T149 PR 3a;
|
|
3886
|
+
* formal/Lifecycle.tla's bind, as an issue, and unbind): a binding made is
|
|
3887
|
+
* weighed as a service token's issue is, on the same reads (`#issuerRead`,
|
|
3888
|
+
* `weighService`), and those it ends, with every credential they issued,
|
|
3889
|
+
* must be the log's (their seals the vault's). One statement, which holds
|
|
3890
|
+
* all of it as read (`change_bindings`), then the seal wait. A row that
|
|
3891
|
+
* moved meanwhile answers `moved`: the app reads again, as the rows it
|
|
3892
|
+
* ends are its reads.
|
|
3893
|
+
*/
|
|
3894
|
+
async changeBindings(input: ChangeBindingsInput): Promise<ChangeBindingsOutcome> {
|
|
3895
|
+
const log015 = this.#log015();
|
|
3896
|
+
await this.#settled();
|
|
3897
|
+
await this.#cutOverYet();
|
|
3898
|
+
checkChangeBindings(input);
|
|
3899
|
+
const { actor, make } = input;
|
|
3900
|
+
let read: IssueRead | null = null;
|
|
3901
|
+
let reports: NewEntry[] = [];
|
|
3902
|
+
if (make !== null) {
|
|
3903
|
+
const scope = readScope(make.scope);
|
|
3904
|
+
if (scope === null || scope.length === 0) throw new Error('a binding made reaches what its scope names, at least one place');
|
|
3905
|
+
const weighed = await this.#issuerRead(log015, {
|
|
3906
|
+
actor, via: input.via, principal: make.principal, generation: make.generation, actorAccess: input.actorAccess, policy: input.policy, scope, service: true,
|
|
3907
|
+
});
|
|
3908
|
+
if ('refused' in weighed) return weighed;
|
|
3909
|
+
const why = make.createdBy !== actor.slice(actor.indexOf(':') + 1) ? 'a binding names who made it, its maker'
|
|
3910
|
+
: weighed.places.some((place) => place === null) ? 'a place the binding names is no longer there'
|
|
3911
|
+
: weighService(actor, make.principal, scope, weighed.holder, weighed.places as Place[], weighed.service);
|
|
3912
|
+
if (why !== null) {
|
|
3913
|
+
await this.#record(weighed.reports);
|
|
3914
|
+
return { ok: false, refused: 'refused', why };
|
|
3915
|
+
}
|
|
3916
|
+
read = weighed.issue;
|
|
3917
|
+
reports = weighed.reports;
|
|
3918
|
+
}
|
|
3919
|
+
const call = bindingsCall(log015, input, read?.at ?? Date.now() + this.#prepared.options.clockOffset(), read);
|
|
3920
|
+
await this.#record(reports);
|
|
3921
|
+
if ('refused' in call) return { ok: false, refused: call.refused };
|
|
3922
|
+
const answer = await changeBindings(this.#db, call);
|
|
3923
|
+
if (answer.number == null) return { ok: false, refused: answer.state as ChangeBindingsRefusal };
|
|
3924
|
+
// Only rows the log never held were ended: no change of the log's to wait for.
|
|
3925
|
+
if (answer.number === '0') return { ok: true, number: '0', witnessed: null };
|
|
3926
|
+
const sealedAt = await log015.sealThrough(BigInt(answer.number));
|
|
3927
|
+
return { ok: true, number: answer.number, witnessed: sealedAt.witnessed ?? null };
|
|
3928
|
+
}
|
|
3929
|
+
|
|
3930
|
+
/** Once entry `number` is sealed and witnessed, or its witnesses past their 2 s (R51): what a change the app made waits for. */
|
|
3931
|
+
async waitSealed(input: { number: string }): Promise<Outcome<{ witnessed: boolean | null }>> {
|
|
3932
|
+
const log015 = this.#log015();
|
|
3933
|
+
if (typeof input?.number !== 'string' || !/^[1-9][0-9]{0,18}$/.test(input.number)) throw new Error('waitSealed takes an entry\'s number, in decimal');
|
|
3934
|
+
const sealed = await log015.sealThrough(BigInt(input.number));
|
|
3935
|
+
return { ok: true, witnessed: sealed.witnessed ?? null };
|
|
3936
|
+
}
|
|
3937
|
+
|
|
3938
|
+
/**
|
|
3939
|
+
* 0.15's log checked (verifier.ts): the newest pair and every entry above
|
|
3940
|
+
* it, held to the pairs held outside. With 0.14's log alone, there is
|
|
3941
|
+
* none to check. The signer remembers a pair before the database has it
|
|
3942
|
+
* (RememberFirst), so a check that lands in between finds the database
|
|
3943
|
+
* behind (`Verdict.behind`): it is checked again 50 ms later, then a
|
|
3944
|
+
* tick later (reviews r4, r5). A rollback is still behind then.
|
|
3945
|
+
*/
|
|
3946
|
+
/** Whether 0.15's checkpointer seals (T169): what `/readyz` asks. Under 0.14's log alone, nothing waits on one. */
|
|
3947
|
+
async sealing(): Promise<Sealing> {
|
|
3948
|
+
const log015 = this.#prepared.log015;
|
|
3949
|
+
if (log015 === null) return { ok: true };
|
|
3950
|
+
try {
|
|
3951
|
+
return await log015.sealing();
|
|
3952
|
+
} catch (error) {
|
|
3953
|
+
return { ok: false, reason: (error as Error).message };
|
|
3954
|
+
}
|
|
3955
|
+
}
|
|
3956
|
+
|
|
3957
|
+
async verifyTrees(): Promise<TreeVerification> {
|
|
3958
|
+
await this.#settled();
|
|
3959
|
+
if (this.#prepared.log015 === null) return { kept: false };
|
|
3960
|
+
const anchors = await this.#prepared.log015.heldOutside();
|
|
3961
|
+
const keys = await this.#verifierKeys();
|
|
3962
|
+
const walk = () => verifyTrees(this.#db, keys, { anchors });
|
|
3963
|
+
let walked = await walk();
|
|
3964
|
+
// The signer's write lands a moment after it remembers: asked again soon, then once more a tick later, for a write
|
|
3965
|
+
// that failed and is made again at the next tick (PR 8 review r5).
|
|
3966
|
+
for (const wait of [50, TICK_MS + 250]) {
|
|
3967
|
+
if (!walked.behind) break;
|
|
3968
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
3969
|
+
walked = await walk();
|
|
3970
|
+
}
|
|
3971
|
+
// What crosses to the app: the verdict, never the retry's signal.
|
|
3972
|
+
const { behind: _behind, ...verdict } = walked;
|
|
3973
|
+
return verdict;
|
|
3974
|
+
}
|
|
3975
|
+
|
|
3976
|
+
/**
|
|
3977
|
+
* The entries of 0.15's log, as an audit page read them, whose MAC holds
|
|
3978
|
+
* under none of the vault's signing keys over the columns it shows (T166):
|
|
3979
|
+
* forged, or edited, around coffre. A malformed one is named too. Pure
|
|
3980
|
+
* CPU: about 3 µs an entry, `VOUCHED_AT_ONCE` at most.
|
|
3981
|
+
*/
|
|
3982
|
+
async vouch(input: VouchInput): Promise<{ forged: string[] }> {
|
|
3983
|
+
const entries: unknown = (input as { entries?: unknown } | undefined)?.entries;
|
|
3984
|
+
if (!Array.isArray(entries) || entries.length > VOUCHED_AT_ONCE) throw new Error(`vouch takes up to ${VOUCHED_AT_ONCE} entries`);
|
|
3985
|
+
const macs = (this.#prepared.vouchKeys ??= this.#config.signingKeys.map((seed) => logMacKey(seed)));
|
|
3986
|
+
return {
|
|
3987
|
+
forged: entries.flatMap((entry: VouchedEntry) => {
|
|
3988
|
+
const named = typeof entry?.number === 'string' ? entry.number : '?';
|
|
3989
|
+
if (entry?.tree === 'cursor') {
|
|
3990
|
+
const at = typeof entry.through === 'string' && /^[0-9]{1,19}$/.test(entry.through) && typeof entry.pair === 'string' && /^[0-9]{1,19}$/.test(entry.pair) && Number.isSafeInteger(entry.at);
|
|
3991
|
+
const mac = entry.mac === null ? null : typeof entry.mac === 'string' && /^[0-9a-f]{32}$/.test(entry.mac) ? Buffer.from(entry.mac, 'hex') : undefined;
|
|
3992
|
+
const cursor = at && mac !== undefined ? { through: BigInt(entry.through), pair: BigInt(entry.pair), at: entry.at, mac } : null;
|
|
3993
|
+
return cursor !== null && macs.some((key) => cursorHolds(key, cursor)) ? [] : [named];
|
|
3994
|
+
}
|
|
3995
|
+
if (entry?.tree === 'reads') {
|
|
3996
|
+
const read = vouchedRead(entry);
|
|
3997
|
+
return read !== null && macs.some((key) => readHolds(key, read)) ? [] : [named];
|
|
3998
|
+
}
|
|
3999
|
+
const logged = vouchedEntry(entry as Exclude<VouchedEntry, { tree: 'reads' | 'cursor' }>);
|
|
4000
|
+
return logged !== null && macs.some((key) => authentic(key)(logged)) ? [] : [named];
|
|
4001
|
+
}),
|
|
4002
|
+
};
|
|
4003
|
+
}
|
|
4004
|
+
|
|
4005
|
+
/**
|
|
4006
|
+
* A stretch of the reads' check (reads.ts, `checkReads`): from `start`, or
|
|
4007
|
+
* a stretch's `next` (a pair's number), in a snapshot of its own. Flat:
|
|
4008
|
+
* a checkpoint's range, whatever the log's length.
|
|
4009
|
+
*/
|
|
4010
|
+
async verifyReads(input: { from: string }): Promise<ReadsVerification> {
|
|
4011
|
+
await this.#settled();
|
|
4012
|
+
if (this.#prepared.log015 === null) return { kept: false };
|
|
4013
|
+
const from = (input as { from?: unknown } | undefined)?.from;
|
|
4014
|
+
const at = typeof from === 'string' ? /^(?:start|([0-9]{1,19}))$/.exec(from) : null;
|
|
4015
|
+
if (at === null) throw new Error('verifyReads starts at start, or at a stretch\'s next');
|
|
4016
|
+
const keys = await this.#verifierKeys();
|
|
4017
|
+
const checked = await this.#db.transaction((tx) => checkReads(logStore(tx as unknown as Database), keys, at[1] === undefined ? 0n : BigInt(at[1])), SNAPSHOT);
|
|
4018
|
+
return {
|
|
4019
|
+
kept: true,
|
|
4020
|
+
ok: checked.problems.length === 0,
|
|
4021
|
+
problems: checked.problems,
|
|
4022
|
+
through: String(checked.through),
|
|
4023
|
+
listed: String(checked.listed),
|
|
4024
|
+
next: checked.next === null ? null : String(checked.next),
|
|
2772
4025
|
};
|
|
2773
4026
|
}
|
|
2774
4027
|
|
|
4028
|
+
async #verifierKeys(): Promise<VerifierKeys> {
|
|
4029
|
+
return (this.#prepared.verifier ??= await verifierKeys(this.#config.signingKeys));
|
|
4030
|
+
}
|
|
4031
|
+
|
|
4032
|
+
#log015(): NonNullable<PreparedVault['log015']> {
|
|
4033
|
+
const log015 = this.#prepared.log015;
|
|
4034
|
+
if (log015 === null) throw new Error("this vault keeps 0.14's log: the app writes its rows as 0.14 does (`about().log`)");
|
|
4035
|
+
return log015;
|
|
4036
|
+
}
|
|
4037
|
+
|
|
2775
4038
|
async keyChecks(): Promise<KeyChecks> {
|
|
2776
4039
|
// A vault key with no check yet gets one first, as before the first key operation.
|
|
2777
4040
|
await this.#kekMismatch();
|
|
@@ -2989,6 +4252,9 @@ class VaultService implements Vault {
|
|
|
2989
4252
|
|
|
2990
4253
|
type Broken = Extract<LogVerification, { ok: false }>;
|
|
2991
4254
|
|
|
4255
|
+
/** 0.14's chain as the cutover walked it from its newest checkpoint, outside any transaction (`#walkOn`). */
|
|
4256
|
+
type Walked = { from: Resumed; tally: Tally; digest: string; at: Position };
|
|
4257
|
+
|
|
2992
4258
|
/**
|
|
2993
4259
|
* Where a walk of the log resumes: the checkpoint's entry (null for the
|
|
2994
4260
|
* first entry), the end of the prefix it signed, the tally through there,
|
|
@@ -3088,6 +4354,45 @@ function validateText(value: string): void {
|
|
|
3088
4354
|
|
|
3089
4355
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
3090
4356
|
|
|
4357
|
+
/**
|
|
4358
|
+
* The app's `credential.use` entry, as the vault appends it beside a read
|
|
4359
|
+
* (`UnwrapInput.use`): what the app wrote, checked to be what an entry
|
|
4360
|
+
* holds, in its reader's name. The app wrote it under the head itself
|
|
4361
|
+
* before 0.14.5; it describes the read, and decides nothing.
|
|
4362
|
+
*/
|
|
4363
|
+
function useEntry(principal: string, correlation: Correlation, use: UseRecord): NewEntry {
|
|
4364
|
+
const id = (value: unknown, name: string) => {
|
|
4365
|
+
if (value !== null && (typeof value !== 'string' || !UUID.test(value))) throw new Error(`a use's ${name} must be a lowercase UUID or null`);
|
|
4366
|
+
return value;
|
|
4367
|
+
};
|
|
4368
|
+
// The bound first: nothing past it is read.
|
|
4369
|
+
if (typeof use.metadata !== 'string' || use.metadata.length > USE_METADATA_LENGTH) {
|
|
4370
|
+
throw new Error(`a use's metadata must be a JSON object of at most ${USE_METADATA_LENGTH} characters`);
|
|
4371
|
+
}
|
|
4372
|
+
if (use.sourceIp !== null) validateText(use.sourceIp);
|
|
4373
|
+
validateText(use.metadata);
|
|
4374
|
+
let metadata: unknown;
|
|
4375
|
+
try {
|
|
4376
|
+
metadata = JSON.parse(use.metadata);
|
|
4377
|
+
} catch {
|
|
4378
|
+
metadata = undefined;
|
|
4379
|
+
}
|
|
4380
|
+
if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
|
|
4381
|
+
throw new Error(`a use's metadata must be a JSON object of at most ${USE_METADATA_LENGTH} characters`);
|
|
4382
|
+
}
|
|
4383
|
+
return {
|
|
4384
|
+
actor: principal,
|
|
4385
|
+
action: 'credential.use',
|
|
4386
|
+
decision: 'allow',
|
|
4387
|
+
projectId: id(use.projectId, 'projectId'),
|
|
4388
|
+
environmentId: id(use.environmentId, 'environmentId'),
|
|
4389
|
+
operationId: correlation.operationId ?? null,
|
|
4390
|
+
requestId: correlation.requestId ?? null,
|
|
4391
|
+
sourceIp: use.sourceIp,
|
|
4392
|
+
metadata: use.metadata,
|
|
4393
|
+
};
|
|
4394
|
+
}
|
|
4395
|
+
|
|
3091
4396
|
/** The caller's ids go into the log as they are: a request id is text, an operation id a lowercase UUID. */
|
|
3092
4397
|
function validateCorrelation(input: Correlation): void {
|
|
3093
4398
|
if (input.requestId != null) validateText(input.requestId);
|
|
@@ -3341,10 +4646,6 @@ function accessEntry(
|
|
|
3341
4646
|
};
|
|
3342
4647
|
}
|
|
3343
4648
|
|
|
3344
|
-
function live(grant: GrantRow, at: number): boolean {
|
|
3345
|
-
return grant.expiresAt === null || grant.expiresAt > at;
|
|
3346
|
-
}
|
|
3347
|
-
|
|
3348
4649
|
/**
|
|
3349
4650
|
* Where an entry about a grant says it is: its project and environment; or,
|
|
3350
4651
|
* for a grant on every project of 0.4, neither, and the place as a path in
|
|
@@ -3370,67 +4671,10 @@ function placeKey(place: store.Place): string {
|
|
|
3370
4671
|
return place.projectId !== null ? `project:${place.projectId}` : `every-project:${place.environmentSlug ?? ''}`;
|
|
3371
4672
|
}
|
|
3372
4673
|
|
|
3373
|
-
/** A member's instance role, as their row has it: service accounts are members, whatever a row says. */
|
|
3374
|
-
function roleOf(principal: string, row: Member): { role: InstanceRole; scope: Scope } {
|
|
3375
|
-
return principal.startsWith('user:') ? { role: row.role, scope: row.scope } : { role: 'member', scope: EVERYWHERE };
|
|
3376
|
-
}
|
|
3377
|
-
|
|
3378
|
-
function view(grant: GrantRow): Grant {
|
|
3379
|
-
return {
|
|
3380
|
-
projectId: grant.projectId,
|
|
3381
|
-
environmentId: grant.environmentId,
|
|
3382
|
-
role: grant.role as Role,
|
|
3383
|
-
expiresAt: grant.expiresAt === null ? null : iso(grant.expiresAt),
|
|
3384
|
-
grantedAt: iso(grant.grantedAt),
|
|
3385
|
-
grantedBy: grant.grantedBy,
|
|
3386
|
-
};
|
|
3387
|
-
}
|
|
3388
|
-
|
|
3389
4674
|
function iso(ms: number): string {
|
|
3390
4675
|
return new Date(ms).toISOString();
|
|
3391
4676
|
}
|
|
3392
4677
|
|
|
3393
|
-
/** A policy entry's edits, checked as the vault wrote them, or null for any that does not hold. */
|
|
3394
|
-
function readEdits(stored: { policy?: unknown }): PolicyEdits | null {
|
|
3395
|
-
const edits = stored.policy as Partial<PolicyEdits> | undefined;
|
|
3396
|
-
if (typeof edits !== 'object' || edits === null) return null;
|
|
3397
|
-
const { roles, instanceRoles, protected: protectedIds } = edits;
|
|
3398
|
-
if (typeof roles !== 'object' || roles === null || typeof instanceRoles !== 'object' || instanceRoles === null || !Array.isArray(protectedIds)) return null;
|
|
3399
|
-
const defined = (table: object) => Object.values(table).every((definition) => definitionProblem(definition as RoleDefinition) === null);
|
|
3400
|
-
if (!defined(roles) || !defined(instanceRoles) || Object.keys(instanceRoles).some((role) => !isInstanceRole(role))) return null;
|
|
3401
|
-
if (protectedIds.some((place) => typeof place !== 'object' || place === null || !UUID.test((place as ProtectedPlace).projectId) || !UUID.test((place as ProtectedPlace).environmentId)
|
|
3402
|
-
|| !Number.isSafeInteger((place as ProtectedPlace).at))) return null;
|
|
3403
|
-
return { roles: roles as Record<string, RoleDefinition>, instanceRoles: instanceRoles as PolicyEdits['instanceRoles'], protected: protectedIds as ProtectedPlace[] };
|
|
3404
|
-
}
|
|
3405
|
-
|
|
3406
|
-
/**
|
|
3407
|
-
* The policy `edits` say as of `at`: the roles as they ship, each edited
|
|
3408
|
-
* one replaced and each made one added; and the protected environments, but
|
|
3409
|
-
* for those deleted since, the project around one's included: a deleted
|
|
3410
|
-
* prod protects nothing, and holds nobody back from giving a service
|
|
3411
|
-
* account a role on all of its project (`givesService`). A fork's id with
|
|
3412
|
-
* no row yet stays, protected before it is written, ten minutes: one with
|
|
3413
|
-
* none after that is a fork refused or abandoned, never written. Each
|
|
3414
|
-
* keeps its slug, which a scoped instance role's filters read. The edits
|
|
3415
|
-
* keep them all: no write is needed.
|
|
3416
|
-
*/
|
|
3417
|
-
function livePolicy(rows: ReadonlyMap<string, store.EnvironmentRow>, edits: PolicyEdits, at: number): Policy {
|
|
3418
|
-
const live = edits.protected.filter((place) => {
|
|
3419
|
-
const row = rows.get(place.environmentId);
|
|
3420
|
-
return row === undefined ? at - place.at < UNWRITTEN_FORK_MS : !row.deleted;
|
|
3421
|
-
});
|
|
3422
|
-
const slugOf = (place: ProtectedPlace) => rows.get(place.environmentId)?.slug ?? null;
|
|
3423
|
-
return {
|
|
3424
|
-
roles: { ...ROLES, ...edits.roles },
|
|
3425
|
-
instanceRoles: { ...INSTANCE_ROLES, ...edits.instanceRoles },
|
|
3426
|
-
protected: live.map((place) => place.environmentId),
|
|
3427
|
-
protectedIn: live.reduce<Record<string, { environmentId: string; slug: string | null }[]>>(
|
|
3428
|
-
(byProject, place) => ({ ...byProject, [place.projectId]: [...(byProject[place.projectId] ?? []), { environmentId: place.environmentId, slug: slugOf(place) }] }),
|
|
3429
|
-
{},
|
|
3430
|
-
),
|
|
3431
|
-
};
|
|
3432
|
-
}
|
|
3433
|
-
|
|
3434
4678
|
/** A change, as its entry names it. */
|
|
3435
4679
|
function describeChange(change: PolicyChange | undefined): Record<string, unknown> | null {
|
|
3436
4680
|
if (change === undefined || change === null || typeof change !== 'object') return null;
|
|
@@ -3472,3 +4716,32 @@ function serialisable(wrapped: { kekProvider: string; kekId: string; kekVersion:
|
|
|
3472
4716
|
function base64(bytes: Uint8Array): string {
|
|
3473
4717
|
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64');
|
|
3474
4718
|
}
|
|
4719
|
+
|
|
4720
|
+
/** An entry as `vouch` was given it, as the trees take it; null for one of no shape a row has. */
|
|
4721
|
+
function vouchedEntry(entry: Exclude<VouchedEntry, { tree: 'reads' | 'cursor' }>): LogEntry | null {
|
|
4722
|
+
const hex = (value: unknown) => (typeof value === 'string' && /^(?:[0-9a-f]{2})*$/.test(value) ? Buffer.from(value, 'hex') : null);
|
|
4723
|
+
const text = (value: unknown) => typeof value === 'string';
|
|
4724
|
+
if (typeof entry !== 'object' || entry === null || !text(entry.number) || !/^[0-9]{1,19}$/.test(entry.number) || !Number.isSafeInteger(entry.at) || !text(entry.requestId)) return null;
|
|
4725
|
+
const mac = hex(entry.mac);
|
|
4726
|
+
if (mac === null) return null;
|
|
4727
|
+
const number = BigInt(entry.number);
|
|
4728
|
+
if (entry.tree === 'changes') {
|
|
4729
|
+
const seal = entry.seal === null ? null : hex(entry.seal);
|
|
4730
|
+
if (!text(entry.object) || !text(entry.actor) || !text(entry.payload) || (entry.seal !== null && seal === null)) return null;
|
|
4731
|
+
return changeEntry({ number, at: entry.at, requestId: entry.requestId, object: entry.object, actor: entry.actor, payload: entry.payload, seal, mac });
|
|
4732
|
+
}
|
|
4733
|
+
if (entry.tree !== 'uses' || !/^[0-9]{1,19}$/.test(entry.requestId) || !text(entry.credential) || !text(entry.secret) || !Number.isSafeInteger(entry.purpose)
|
|
4734
|
+
|| (entry.place !== null && !text(entry.place))) return null;
|
|
4735
|
+
return usedEntry({ number, at: entry.at, requestId: BigInt(entry.requestId), credential: entry.credential, secret: entry.secret, purpose: entry.purpose, place: entry.place, mac });
|
|
4736
|
+
}
|
|
4737
|
+
|
|
4738
|
+
/** A read as `vouch` was given it (`log_reads`, T166); null for one of no shape the projector writes. */
|
|
4739
|
+
function vouchedRead(entry: Extract<VouchedEntry, { tree: 'reads' }>): ReadRow | null {
|
|
4740
|
+
const id = (value: unknown) => typeof value === 'string' && value.length > 0;
|
|
4741
|
+
const maybe = (value: unknown): value is string | null => value === null || id(value);
|
|
4742
|
+
const mac = typeof entry.mac === 'string' && /^[0-9a-f]{32}$/.test(entry.mac) ? Buffer.from(entry.mac, 'hex') : null;
|
|
4743
|
+
if (mac === null || typeof entry.number !== 'string' || !/^[0-9]{1,19}$/.test(entry.number) || !Number.isSafeInteger(entry.at) || !Number.isSafeInteger(entry.purpose)
|
|
4744
|
+
|| !id(entry.credential) || !id(entry.principal) || !id(entry.secret) || !id(entry.environment) || !id(entry.project)
|
|
4745
|
+
|| !maybe(entry.place) || !maybe(entry.source) || !maybe(entry.sourceEnvironment) || !maybe(entry.sourceProject) || !maybe(entry.version)) return null;
|
|
4746
|
+
return { ...entry, number: BigInt(entry.number), mac };
|
|
4747
|
+
}
|