@coffre/vault 0.14.5 → 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 +341 -4
- package/dist/cloudflare.js +802 -6
- package/dist/index.js +1 -1
- package/dist/log-entry-BYWqj3O5.d.ts +364 -0
- package/dist/node.d.ts +181 -6
- package/dist/node.js +358 -16
- package/dist/{src-Bg0ZeMJW.js → src-DheG92EM.js} +1 -1
- 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 +3 -2
- 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 +64 -10
- package/src/store.ts +832 -36
- package/src/use.postgres.ts +111 -0
- package/src/use.sqlite.ts +542 -0
- package/src/use.ts +1060 -0
- package/src/vault.ts +1366 -220
- package/src/verifier.ts +136 -0
- package/src/witness-workers.ts +163 -0
- package/dist/vault-Cbi45kLB.js +0 -5197
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,43 +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,
|
|
94
125
|
USE_METADATA_LENGTH,
|
|
95
126
|
type Unwrapped,
|
|
127
|
+
type UseInput,
|
|
128
|
+
type NamedUseInput,
|
|
129
|
+
type UseManyInput,
|
|
130
|
+
type UseManyOutcome,
|
|
131
|
+
type NamedUseOutcome,
|
|
132
|
+
type UseOutcome,
|
|
96
133
|
type UseRecord,
|
|
97
134
|
type Wrapped,
|
|
98
135
|
type Vault,
|
|
99
136
|
type Via,
|
|
137
|
+
type ReadsVerification,
|
|
138
|
+
type TreeVerification,
|
|
100
139
|
type VerifyLogInput,
|
|
140
|
+
type VouchedEntry,
|
|
141
|
+
type VouchInput,
|
|
101
142
|
type WrapInput,
|
|
102
143
|
type WrappedKey,
|
|
144
|
+
VOUCHED_AT_ONCE,
|
|
103
145
|
} from '@coffre/core/vault';
|
|
104
146
|
import type { Database, Queryable, Transaction } from '@coffre/db';
|
|
105
147
|
import { isUnavailable, isUniqueViolation, SNAPSHOT } from '@coffre/db/dialect';
|
|
106
|
-
import { everyProjectConversion, newestAccess, reportStands } from '@coffre/db/grants';
|
|
107
|
-
import { appendEntries, lockLogHead, lockLogHeadQueued, 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';
|
|
108
150
|
|
|
151
|
+
import { Changes, keptObjects, Moved, NotLogged, prepareChanges, type ChangeKeys } from './changes.ts';
|
|
109
152
|
import { signer, type Signer } from './checkpoint.ts';
|
|
110
|
-
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';
|
|
111
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';
|
|
112
164
|
import { carries, further, ownEntries, rehash, relink, START, vaultLogKey, VERIFY_BATCH, withCause, type Position } from './log.ts';
|
|
113
165
|
import { apply, replayFault, type LoggedMember, type Replayed } from './replay.ts';
|
|
166
|
+
import { principalObject } from './objects.ts';
|
|
114
167
|
import { memberMac, rowKey, sameGrants, sealed, standing } from './rows.ts';
|
|
115
168
|
import { snapshot, type Asked, type Snapshot } from './snapshot.ts';
|
|
116
169
|
import * as store from './store.ts';
|
|
117
170
|
import { accessDigest, CHECKPOINT, EMPTY_TALLY, expectedKey, TALLY_VERSION, Walk, type AccessTally, type Expected, type TalliedCheckpoint, type Tally } from './tally.ts';
|
|
118
|
-
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
|
+
|
|
119
175
|
|
|
120
176
|
export type VaultOptions = {
|
|
121
177
|
/**
|
|
@@ -127,8 +183,51 @@ export type VaultOptions = {
|
|
|
127
183
|
clockOffset?: () => number;
|
|
128
184
|
/** How far one call walks the log (`WALKS`); tests shorten them. */
|
|
129
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;
|
|
130
228
|
};
|
|
131
229
|
|
|
230
|
+
|
|
132
231
|
/**
|
|
133
232
|
* A vault's configuration made ready to use, once per process, or once per
|
|
134
233
|
* isolate on Workers: its signer, its log key, and how far it has verified
|
|
@@ -147,7 +246,23 @@ export type PreparedVault = {
|
|
|
147
246
|
logKey: LogKey;
|
|
148
247
|
/** Each signing key's log key: those after the first verify only entries before `since`. */
|
|
149
248
|
logKeys: LogKey[];
|
|
150
|
-
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;
|
|
151
266
|
/** How far the chain is rehashed, by any call of this process: its head must still be there. */
|
|
152
267
|
verified: Position;
|
|
153
268
|
/** What member rows are sealed under now; rows.ts. */
|
|
@@ -170,6 +285,12 @@ export type PreparedVault = {
|
|
|
170
285
|
* then everyone the deployment names made an Owner (`#declare`).
|
|
171
286
|
*/
|
|
172
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;
|
|
173
294
|
};
|
|
174
295
|
|
|
175
296
|
export async function prepareVault(config: ResolvedVaultConfig, options: VaultOptions = {}): Promise<PreparedVault> {
|
|
@@ -177,6 +298,12 @@ export async function prepareVault(config: ResolvedVaultConfig, options: VaultOp
|
|
|
177
298
|
config,
|
|
178
299
|
...(await keysOf(config.signingKeys)),
|
|
179
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 }),
|
|
180
307
|
verified: START,
|
|
181
308
|
reported: new Set(),
|
|
182
309
|
since: null,
|
|
@@ -187,6 +314,18 @@ export async function prepareVault(config: ResolvedVaultConfig, options: VaultOp
|
|
|
187
314
|
};
|
|
188
315
|
}
|
|
189
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
|
+
|
|
190
329
|
/** The vault's own keys, from each of its signing keys, the one it signs with first. */
|
|
191
330
|
async function keysOf(seeds: readonly Uint8Array[]) {
|
|
192
331
|
const signers = await Promise.all(seeds.map((seed) => signer(seed)));
|
|
@@ -195,9 +334,16 @@ async function keysOf(seeds: readonly Uint8Array[]) {
|
|
|
195
334
|
return { signer: signers[0], signers, logKey: logKeys[0], logKeys, rowKey: rowKeys[0], rowKeys };
|
|
196
335
|
}
|
|
197
336
|
|
|
198
|
-
/**
|
|
199
|
-
|
|
200
|
-
|
|
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);
|
|
201
347
|
}
|
|
202
348
|
|
|
203
349
|
/** Every key operation of one call, together: a removal waits at most this long for a read at KMS. */
|
|
@@ -255,9 +401,6 @@ function within(deadline: number | undefined, ms: number): number {
|
|
|
255
401
|
return Math.min(ms, left);
|
|
256
402
|
}
|
|
257
403
|
|
|
258
|
-
/** A member: `user:<email>` or `token:<name>`. */
|
|
259
|
-
const PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
|
|
260
|
-
|
|
261
404
|
/** A person, and a service account: only a person sets one up (`givesService`). */
|
|
262
405
|
const PERSON = /^user:/;
|
|
263
406
|
const SERVICE = /^token:/;
|
|
@@ -265,6 +408,14 @@ const SERVICE = /^token:/;
|
|
|
265
408
|
/** Who acts for the vault itself, as when it makes an Owner its deployment names. */
|
|
266
409
|
const VAULT_ACTOR = 'system:vault';
|
|
267
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
|
+
|
|
268
419
|
// A KEK's check: a known value, the size of a data key, wrapped under it in
|
|
269
420
|
// a context no secret has (the nil UUID), and kept in a `key.check` entry
|
|
270
421
|
// (@coffre/core/kek). Opening it again tells the vault its KEK is the one
|
|
@@ -280,53 +431,45 @@ const KEY_ROTATE = 'key.rotate';
|
|
|
280
431
|
/** A new member row, before the decision seals it (`#seal`). */
|
|
281
432
|
const UNSEALED = { accessSeq: 0n, mac: Buffer.alloc(32) };
|
|
282
433
|
|
|
283
|
-
/**
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
const SETTINGS = 'settings.change';
|
|
288
|
-
|
|
289
|
-
/** The settings before anyone changed them: people set up service accounts anywhere they hold access. */
|
|
290
|
-
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
|
+
}
|
|
291
438
|
|
|
292
|
-
/**
|
|
293
|
-
|
|
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
|
+
}
|
|
294
443
|
|
|
295
|
-
/**
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
*/
|
|
300
|
-
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
|
+
}
|
|
301
448
|
|
|
302
449
|
/**
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
* 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).
|
|
306
452
|
*/
|
|
307
|
-
|
|
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
|
+
}
|
|
308
457
|
|
|
458
|
+
function placeOf(grant: store.Place) {
|
|
459
|
+
return { projectId: grant.projectId ?? null, environmentId: grant.environmentId ?? null, environmentSlug: grant.environmentSlug ?? null };
|
|
460
|
+
}
|
|
309
461
|
|
|
310
|
-
|
|
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";
|
|
311
464
|
|
|
312
|
-
/**
|
|
313
|
-
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";
|
|
314
467
|
|
|
315
|
-
/**
|
|
316
|
-
|
|
317
|
-
* whoever inserted rows may have hidden an Owner's narrowing, so no role
|
|
318
|
-
* holds anything, a service account's neither, but Owner, which holds
|
|
319
|
-
* everything always and so may write the policy again.
|
|
320
|
-
*/
|
|
321
|
-
const CLOSED_POLICY: Policy = {
|
|
322
|
-
roles: Object.fromEntries(Object.entries(ROLES).map(([id, definition]) => [id, id === 'owner' ? definition : NOTHING_HELD(definition)])),
|
|
323
|
-
instanceRoles: Object.fromEntries(Object.entries(INSTANCE_ROLES).map(([id, definition]) => [id, id === 'owner' ? definition : NOTHING_HELD(definition)])) as Policy['instanceRoles'],
|
|
324
|
-
protected: [EVERY_ENVIRONMENT],
|
|
325
|
-
protectedIn: {},
|
|
326
|
-
};
|
|
468
|
+
/** The action of the vault's entry that holds the instance's settings, and changes them (`settings`). */
|
|
469
|
+
const SETTINGS = 'settings.change';
|
|
327
470
|
|
|
328
|
-
/** The
|
|
329
|
-
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';
|
|
330
473
|
|
|
331
474
|
/** Why a change to who is an Owner, scoped or not, is refused to anyone else: Owners make and unmake Owners. */
|
|
332
475
|
const OWNERS_ONLY = 'only an Owner of the whole instance makes someone an Owner, or changes or removes one';
|
|
@@ -359,6 +502,9 @@ class Retry {}
|
|
|
359
502
|
/** What each `vault.tampered` entry reports, to mark it logged once committed. */
|
|
360
503
|
const REPORTED = new WeakMap<NewEntry, string>();
|
|
361
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
|
+
|
|
362
508
|
/** A key service that did not answer, and the entries that record what it did do. */
|
|
363
509
|
class Outage {
|
|
364
510
|
readonly error: unknown;
|
|
@@ -377,29 +523,6 @@ function refusal(code: RefusalCode, message: string): Refusal {
|
|
|
377
523
|
return { code, message };
|
|
378
524
|
}
|
|
379
525
|
|
|
380
|
-
const MESSAGES: Record<RefusalCode, string> = {
|
|
381
|
-
removed: 'this member was removed',
|
|
382
|
-
not_a_member: 'not a member',
|
|
383
|
-
no_grant: 'no grant covers this',
|
|
384
|
-
expired: 'the grant that covered this has expired',
|
|
385
|
-
bulk_limit: 'too many secrets read in too short a time',
|
|
386
|
-
bad_claim: 'the key does not belong to this secret',
|
|
387
|
-
deleted: 'this project or environment was deleted',
|
|
388
|
-
ended: 'the reference read through was ended',
|
|
389
|
-
personal: "a personal config's values are its owner's alone",
|
|
390
|
-
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",
|
|
391
|
-
not_owner_issued: 'a service account reads a sensitive value only on a token or trust binding an Owner of its project issued',
|
|
392
|
-
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",
|
|
393
|
-
not_allowed: 'not allowed to change this',
|
|
394
|
-
last_owner: 'the last Owner of the whole instance stays one: make someone else an Owner first',
|
|
395
|
-
invalid: 'not something the rules allow',
|
|
396
|
-
policy_changed: "the instance's roles or protected environments changed while this was asked: try again",
|
|
397
|
-
access_changed: 'what this member holds changed since the read was decided: try again',
|
|
398
|
-
log_broken: 'the vault log does not hold from the last checkpoint',
|
|
399
|
-
wrong_kek: "this vault's key does not open the data it holds",
|
|
400
|
-
tampered: "this member's record failed the vault's integrity check",
|
|
401
|
-
};
|
|
402
|
-
|
|
403
526
|
/**
|
|
404
527
|
* A decision in progress: its transaction, the member rows it locked, its
|
|
405
528
|
* time, and what it will commit. Changes to members and grants wait until
|
|
@@ -435,6 +558,8 @@ type Decision = {
|
|
|
435
558
|
* was deciding. A `Refused` there refuses the decision.
|
|
436
559
|
*/
|
|
437
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;
|
|
438
563
|
};
|
|
439
564
|
|
|
440
565
|
/**
|
|
@@ -523,11 +648,64 @@ class VaultService implements Vault {
|
|
|
523
648
|
readonly #db: Database;
|
|
524
649
|
readonly #prepared: PreparedVault;
|
|
525
650
|
readonly #config: ResolvedVaultConfig;
|
|
651
|
+
readonly #hot: Database | undefined;
|
|
526
652
|
|
|
527
|
-
constructor(db: Database, prepared: PreparedVault) {
|
|
653
|
+
constructor(db: Database, prepared: PreparedVault, hot?: Database) {
|
|
528
654
|
this.#db = db;
|
|
529
655
|
this.#prepared = prepared;
|
|
530
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);
|
|
531
709
|
}
|
|
532
710
|
|
|
533
711
|
async #now(db: Queryable): Promise<number> {
|
|
@@ -546,6 +724,7 @@ class VaultService implements Vault {
|
|
|
546
724
|
...rest,
|
|
547
725
|
principals,
|
|
548
726
|
head,
|
|
727
|
+
seals: this.#checksPlaces(),
|
|
549
728
|
// A read waits for the head in one of a few places in line, or is refused at once.
|
|
550
729
|
...(reading ? { waiters: READ_HEAD_WAITERS } : {}),
|
|
551
730
|
...(released === undefined ? {} : { released: { ...released, windowMs: this.#config.bulkLimit.windowMs, clockOffsetMs: this.#prepared.options.clockOffset(), cap: this.#config.bulkLimit.count } }),
|
|
@@ -574,6 +753,8 @@ class VaultService implements Vault {
|
|
|
574
753
|
|
|
575
754
|
/** `#decide`, once the vault is settled. */
|
|
576
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();
|
|
577
758
|
const reports: NewEntry[] = [];
|
|
578
759
|
// A read's call is due from its first decision, unless its caller set when.
|
|
579
760
|
const due = how.reads === true ? { ...how, deadline: how.deadline ?? Date.now() + READ_DEADLINE_MS } : how;
|
|
@@ -583,7 +764,8 @@ class VaultService implements Vault {
|
|
|
583
764
|
} catch (error) {
|
|
584
765
|
// A member who had no row when this decision locked theirs, and has
|
|
585
766
|
// one now: another decision admitted them meanwhile. Again, with it.
|
|
586
|
-
|
|
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;
|
|
587
769
|
throw error;
|
|
588
770
|
}
|
|
589
771
|
}
|
|
@@ -591,15 +773,19 @@ class VaultService implements Vault {
|
|
|
591
773
|
|
|
592
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>> {
|
|
593
775
|
try {
|
|
594
|
-
const
|
|
776
|
+
const committed = await this.#db.transaction(async (tx) => {
|
|
595
777
|
await store.boundLockWaits(tx, shared ? within(deadline, READ_LOCK_TIMEOUT_MS) : LOCK_TIMEOUT_MS, shared);
|
|
596
778
|
const members = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals, shared);
|
|
597
779
|
// Then the head, and everything the decision reads, in one statement (`snapshot` says why it holds).
|
|
598
780
|
const reads = await this.#snapshot(tx, principals, asked, !late, shared);
|
|
781
|
+
const log015 = this.#prepared.log015;
|
|
599
782
|
const d: Decision = {
|
|
600
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),
|
|
601
785
|
};
|
|
602
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);
|
|
603
789
|
// A read is logged once per place, whichever way it ends (`readsByPlace`).
|
|
604
790
|
const entries = [...reports, ...readsByPlace(d.log)];
|
|
605
791
|
let at = d.at;
|
|
@@ -620,12 +806,27 @@ class VaultService implements Vault {
|
|
|
620
806
|
const sealing = new Set([...d.touched, ...accessSeq.keys()]);
|
|
621
807
|
// Under a share lock, a write to the row would wait for every other read holding it, and they for it.
|
|
622
808
|
if (shared && sealing.size > 0) throw new Error('a read changes no member, and seals no row');
|
|
623
|
-
|
|
624
|
-
|
|
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 };
|
|
625
822
|
});
|
|
626
823
|
this.#reported(reports);
|
|
627
|
-
|
|
628
|
-
|
|
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;
|
|
629
830
|
if (!(error instanceof Refused || error instanceof Outage)) throw error;
|
|
630
831
|
const entries = [...reports, ...readsByPlace(error.entries)];
|
|
631
832
|
if (entries.length > 0) {
|
|
@@ -649,19 +850,23 @@ class VaultService implements Vault {
|
|
|
649
850
|
* naming `accessSeq`, their newest access entry, when it wrote one. The
|
|
650
851
|
* table must hold exactly that: a grant written around the vault while it
|
|
651
852
|
* decided (its row lock does not stop one) is refused, with the decision,
|
|
652
|
-
* 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.
|
|
653
855
|
*/
|
|
654
|
-
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> {
|
|
655
857
|
const row = await store.member(d.tx, principal);
|
|
656
|
-
if (row === undefined) return;
|
|
858
|
+
if (row === undefined) return null;
|
|
657
859
|
const decided = d.grants.get(principal);
|
|
658
860
|
if (decided === undefined) throw new Error(`sealing ${principal} without the grants this decision verified`);
|
|
659
861
|
if (!sameGrants(await store.grants(d.tx, principal), decided)) {
|
|
660
862
|
this.#report(d.reports, principal, 'mac', row.mac.toString('hex'));
|
|
661
|
-
throw new Refused(refusal('tampered',
|
|
863
|
+
throw new Refused(refusal('tampered', REFUSAL_MESSAGES.tampered), []);
|
|
662
864
|
}
|
|
663
865
|
const next = { ...row, accessSeq: accessSeq ?? row.accessSeq };
|
|
664
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 };
|
|
665
870
|
}
|
|
666
871
|
|
|
667
872
|
/**
|
|
@@ -688,6 +893,89 @@ class VaultService implements Vault {
|
|
|
688
893
|
return 'stale';
|
|
689
894
|
}
|
|
690
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
|
+
|
|
691
979
|
/** The newest access entry about `principal` that carries the vault's MAC (`newestAccess` in @coffre/db/grants). */
|
|
692
980
|
#newestAccess(db: Queryable, principal: string, reports: NewEntry[], reads?: Snapshot): Promise<Newest> {
|
|
693
981
|
if (reads === undefined || !reads.grants.has(principal)) return newestAccess(db, principal, this.#check(reports, principal));
|
|
@@ -745,6 +1033,18 @@ class VaultService implements Vault {
|
|
|
745
1033
|
reports.push(entry);
|
|
746
1034
|
}
|
|
747
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
|
+
|
|
748
1048
|
/** Mark these reports as logged, once their transaction has committed. */
|
|
749
1049
|
#reported(reports: readonly NewEntry[]): void {
|
|
750
1050
|
for (const entry of reports) {
|
|
@@ -773,8 +1073,15 @@ class VaultService implements Vault {
|
|
|
773
1073
|
// database; calls that race settle it alike, and a rotation they race
|
|
774
1074
|
// to write goes in once (`#rotate`).
|
|
775
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
|
+
}
|
|
776
1082
|
// A log with no entry of the vault's holds nothing to convert, and stays empty until someone acts.
|
|
777
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();
|
|
778
1085
|
return prepared.superseded;
|
|
779
1086
|
}
|
|
780
1087
|
|
|
@@ -806,6 +1113,30 @@ class VaultService implements Vault {
|
|
|
806
1113
|
this.#prepared.converted = await this.#declare(this.#config.owners.map((email) => `user:${email}`));
|
|
807
1114
|
}
|
|
808
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
|
+
|
|
809
1140
|
async #replaceEveryProject(d: Decision, principal: string): Promise<void> {
|
|
810
1141
|
const row = d.members.get(principal);
|
|
811
1142
|
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
|
|
@@ -813,9 +1144,11 @@ class VaultService implements Vault {
|
|
|
813
1144
|
const everyProject = subject.stored.filter((grant) => grant.projectId === null && grant.environmentId === null);
|
|
814
1145
|
if (everyProject.length === 0) return;
|
|
815
1146
|
if (await reportStands(d.tx, principal, this.#check(d.reports, principal))) return;
|
|
816
|
-
// 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.
|
|
817
1150
|
const before = roleOf(principal, row);
|
|
818
|
-
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));
|
|
819
1152
|
const after = { role: conversion.role, scope: conversion.scope };
|
|
820
1153
|
const replacedBy = { role: after.role, scope: after.scope, grants: conversion.grants.length };
|
|
821
1154
|
d.grants.set(principal, [...subject.stored]);
|
|
@@ -829,6 +1162,7 @@ class VaultService implements Vault {
|
|
|
829
1162
|
|
|
830
1163
|
for (const grant of everyProject) {
|
|
831
1164
|
drop(grant);
|
|
1165
|
+
d.changes?.stage(memberRef(principal), { op: 'access.revoke', place: placeOf(grant) }, VAULT_ACTOR);
|
|
832
1166
|
// A lapsed one goes with no entry: it changes nothing anyone holds.
|
|
833
1167
|
if (!live(grant, d.at)) continue;
|
|
834
1168
|
const narrowed = conversion.narrowed.filter((loss) => loss.grant.environmentSlug === grant.environmentSlug);
|
|
@@ -849,6 +1183,7 @@ class VaultService implements Vault {
|
|
|
849
1183
|
reason: 'every-project',
|
|
850
1184
|
}));
|
|
851
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);
|
|
852
1187
|
}
|
|
853
1188
|
for (const given of conversion.grants) {
|
|
854
1189
|
const place = { projectId: given.projectId, environmentId: given.environmentId, environmentSlug: null };
|
|
@@ -860,6 +1195,7 @@ class VaultService implements Vault {
|
|
|
860
1195
|
await store.insertGrant(d.tx, grant);
|
|
861
1196
|
d.grants.get(principal)!.push(grant);
|
|
862
1197
|
});
|
|
1198
|
+
d.changes?.stage(memberRef(principal), { op: 'access.grant', grant: inputGrant({ ...place, role: given.role, expiresAt: given.expiresAt }) }, VAULT_ACTOR);
|
|
863
1199
|
d.log.push({
|
|
864
1200
|
...accessEntry(VAULT_ACTOR, 'access.grant', principal, 'allow', correlation, {
|
|
865
1201
|
role: given.role,
|
|
@@ -952,7 +1288,8 @@ class VaultService implements Vault {
|
|
|
952
1288
|
* is to a key it does not hold: a vault given a newer KEK has replaced
|
|
953
1289
|
* this one, which writes nothing more. Checked under the head's lock, so
|
|
954
1290
|
* that an instance still running with the replaced KEK, as during a
|
|
955
|
-
* 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.
|
|
956
1293
|
*/
|
|
957
1294
|
async #append(
|
|
958
1295
|
tx: Transaction,
|
|
@@ -970,7 +1307,7 @@ class VaultService implements Vault {
|
|
|
970
1307
|
throw new Error(prepared.superseded);
|
|
971
1308
|
}
|
|
972
1309
|
for (const check of underLock) await check(locked);
|
|
973
|
-
}, head === undefined ? undefined : { head, last: head.last, at: held!.at });
|
|
1310
|
+
}, head === undefined ? undefined : { head, last: head.last, at: held!.at }, [], prepared.log015 !== null);
|
|
974
1311
|
// The head still held, as this append left it, for the next append of the same transaction: a remote key's intent,
|
|
975
1312
|
// then the decision's reports.
|
|
976
1313
|
if (head !== undefined) {
|
|
@@ -997,7 +1334,14 @@ class VaultService implements Vault {
|
|
|
997
1334
|
const items = loaded.versions.map((version) => ({ ...version, secret: { ...version.secret } }));
|
|
998
1335
|
validateItems(items, 'wrapped');
|
|
999
1336
|
const sealed = await this.#referencesAt(vias);
|
|
1000
|
-
|
|
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);
|
|
1001
1345
|
// A reference reads only the current version of the source its entry names.
|
|
1002
1346
|
const viaOk = items.map((item, i) => vias[i] === null || (sealed[i] !== null && sealed[i]!.source.secretId === item.secret.secretId && item.current));
|
|
1003
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,
|
|
@@ -1230,12 +1574,12 @@ class VaultService implements Vault {
|
|
|
1230
1574
|
principal: string,
|
|
1231
1575
|
code: 'bad_claim' | 'deleted' | 'invalid',
|
|
1232
1576
|
entries: NewEntry[],
|
|
1233
|
-
{ message =
|
|
1577
|
+
{ message = REFUSAL_MESSAGES[code], asked }: { message?: string; asked?: string } = {},
|
|
1234
1578
|
): Promise<Outcome<never>> {
|
|
1235
1579
|
return this.#decide([principal], async (d) => {
|
|
1236
1580
|
if (asked !== undefined) {
|
|
1237
1581
|
const standing = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads);
|
|
1238
|
-
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), []);
|
|
1239
1583
|
}
|
|
1240
1584
|
throw new Refused(refusal(code, message), entries);
|
|
1241
1585
|
}, { reads: true });
|
|
@@ -1323,7 +1667,7 @@ class VaultService implements Vault {
|
|
|
1323
1667
|
const standing = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads);
|
|
1324
1668
|
// A read decided on an earlier answer about its reader, only while that is still the answer: nothing read, nothing logged.
|
|
1325
1669
|
const asked = call.input.accessRevision;
|
|
1326
|
-
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), []);
|
|
1327
1671
|
const reader = through(standing, call.input);
|
|
1328
1672
|
// One read of the environments the decision is about, as the store has them, not as the app sent them:
|
|
1329
1673
|
// each secret's and, for a read through a reference, its holder's. A grant on one slug in every project
|
|
@@ -1331,6 +1675,9 @@ class VaultService implements Vault {
|
|
|
1331
1675
|
const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
|
|
1332
1676
|
// Each a personal config is a config of too, whose read its owner needs (`places`).
|
|
1333
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);
|
|
1334
1681
|
owners.clear();
|
|
1335
1682
|
for (const [id, environment] of environments) if (environment.owner !== null) owners.set(id, ownerKey(environment.owner));
|
|
1336
1683
|
const needs = call.needs === undefined ? [] : await call.needs(d.tx);
|
|
@@ -1345,7 +1692,7 @@ class VaultService implements Vault {
|
|
|
1345
1692
|
if (first === null && action === 'secret.read' && this.#overBulkLimit(d)) first = 'bulk_limit';
|
|
1346
1693
|
if (first !== null) {
|
|
1347
1694
|
throw new Refused(
|
|
1348
|
-
refusal(first,
|
|
1695
|
+
refusal(first, REFUSAL_MESSAGES[first]),
|
|
1349
1696
|
secrets.map((secret, i) => entry(secret, 'deny', codes[i] ?? first)),
|
|
1350
1697
|
);
|
|
1351
1698
|
}
|
|
@@ -1427,7 +1774,7 @@ class VaultService implements Vault {
|
|
|
1427
1774
|
const codes = await vet(locked, d.reads);
|
|
1428
1775
|
const first = codes.find((code) => code !== null);
|
|
1429
1776
|
if (first === undefined || first === null) return;
|
|
1430
|
-
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)));
|
|
1431
1778
|
});
|
|
1432
1779
|
}
|
|
1433
1780
|
const { outcomes, expired } = await settle(operations((secret) => context(secret, owners.get(secret.environmentId))), within(deadline, this.#prepared.options.keyBudgetMs));
|
|
@@ -1447,7 +1794,7 @@ class VaultService implements Vault {
|
|
|
1447
1794
|
}
|
|
1448
1795
|
if (expired) throw new Outage(new KekUnavailableError('key operation exceeded its deadline'), entries());
|
|
1449
1796
|
if (done.length < outcomes.length) {
|
|
1450
|
-
throw new Refused(refusal('bad_claim',
|
|
1797
|
+
throw new Refused(refusal('bad_claim', REFUSAL_MESSAGES.bad_claim), entries());
|
|
1451
1798
|
}
|
|
1452
1799
|
if (late) {
|
|
1453
1800
|
// Again under the log's head, the append's, taken a statement early: a
|
|
@@ -1461,7 +1808,7 @@ class VaultService implements Vault {
|
|
|
1461
1808
|
await decided(d);
|
|
1462
1809
|
const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
|
|
1463
1810
|
if (places.some((place) => d.reads.environments.get(place.environmentId)?.deleted ?? false)) {
|
|
1464
|
-
throw new Refused(refusal('deleted',
|
|
1811
|
+
throw new Refused(refusal('deleted', REFUSAL_MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, 'deny', 'deleted')));
|
|
1465
1812
|
}
|
|
1466
1813
|
}
|
|
1467
1814
|
const released = secrets.map((secret, i) => outcomeEntry(secret, i, 'allow', null));
|
|
@@ -1544,15 +1891,18 @@ class VaultService implements Vault {
|
|
|
1544
1891
|
const environments = await store.environmentsById(d.tx, items.flatMap((item) => {
|
|
1545
1892
|
const source = sources.get(item.source.secretId);
|
|
1546
1893
|
return source === undefined ? [item.holder.environmentId] : [item.holder.environmentId, source.environmentId];
|
|
1547
|
-
}));
|
|
1894
|
+
}), { seals: this.#checksPlaces() });
|
|
1895
|
+
// Each as 0.15's log holds its row (T159).
|
|
1896
|
+
this.#placesHold(environments, environments.keys(), d.reports);
|
|
1548
1897
|
const codes = items.map((item): RefusalCode | null => {
|
|
1549
1898
|
const source = sources.get(item.source.secretId);
|
|
1550
|
-
|
|
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';
|
|
1551
1901
|
if (source.secretId === item.holder.secretId) return 'invalid';
|
|
1552
1902
|
return referenceRefusal(reader, item.holder, source, environments, natures.get(source.secretId) ?? 'secret');
|
|
1553
1903
|
});
|
|
1554
1904
|
const first = codes.find((code) => code !== null) ?? null;
|
|
1555
|
-
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)));
|
|
1556
1906
|
const entries = items.map((item) => entry(item, 'allow', null));
|
|
1557
1907
|
d.log.push(...entries);
|
|
1558
1908
|
const answer = { seqs: [] as number[] };
|
|
@@ -1573,7 +1923,9 @@ class VaultService implements Vault {
|
|
|
1573
1923
|
validateCorrelation(input);
|
|
1574
1924
|
if (reason !== 'replaced' && reason !== 'broken' && reason !== 'abandoned') throw new Error('a reference ends replaced, broken or abandoned');
|
|
1575
1925
|
const vias = input.items.map((item) => validateVia(item)!);
|
|
1576
|
-
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
|
+
}
|
|
1577
1929
|
if (vias.length === 0) return { ok: true, seqs: [] };
|
|
1578
1930
|
const sealed = await this.#referencesAt(vias);
|
|
1579
1931
|
// A holder's row is committed after its reference is sealed, if the write went through: an end names it only then.
|
|
@@ -1606,31 +1958,111 @@ class VaultService implements Vault {
|
|
|
1606
1958
|
});
|
|
1607
1959
|
if (sealed.some((seal) => seal === null)) {
|
|
1608
1960
|
return this.#decide([principal], async () => {
|
|
1609
|
-
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')));
|
|
1610
1962
|
});
|
|
1611
1963
|
}
|
|
1612
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).
|
|
1613
1968
|
const ended = async (db: Queryable) => {
|
|
1614
|
-
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!));
|
|
1615
1970
|
return seals.map((seal) => (done.has(seal.seq) ? ('ended' as const) : null));
|
|
1616
1971
|
};
|
|
1617
1972
|
return this.#decide([principal], async (d) => {
|
|
1618
1973
|
// Under the log's head: two ends of one reference at once, the second finds the first; and the actor's standing stands.
|
|
1619
1974
|
const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), input);
|
|
1620
1975
|
// Ending is allowed in a deleted place: deleting one ends the references into and out of it.
|
|
1621
|
-
const environments = await store.environmentsById(d.tx, seals.map((seal) => seal.holder.environmentId));
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
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);
|
|
1626
1984
|
if (reason === 'abandoned' && seal.createdBy !== principal) return 'not_allowed';
|
|
1627
|
-
// Replacing or abandoning is the holder's side: write where it is held.
|
|
1628
1985
|
if (holder === null || reason !== 'broken') return holder;
|
|
1629
|
-
return refuses(
|
|
1986
|
+
return refuses(reader, 'grant.manage', { projectId: seal.source.projectId }, false) === null ? null : holder;
|
|
1630
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
|
+
}
|
|
1631
2017
|
if (!codes.some((code) => code !== null)) (await ended(d.tx)).forEach((code, i) => (codes[i] = code));
|
|
1632
2018
|
const first = codes.find((code) => code !== null) ?? null;
|
|
1633
|
-
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
|
+
}
|
|
1634
2066
|
const entries = vias.map((via, i) => entry(via, seals[i]!, 'allow', null));
|
|
1635
2067
|
d.log.push(...entries);
|
|
1636
2068
|
const answer = { seqs: [] as number[] };
|
|
@@ -1693,6 +2125,8 @@ class VaultService implements Vault {
|
|
|
1693
2125
|
* and is asked about again at each of their requests. Whether it ran.
|
|
1694
2126
|
*/
|
|
1695
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;
|
|
1696
2130
|
const at = await this.#now(this.#db);
|
|
1697
2131
|
const due: string[] = [];
|
|
1698
2132
|
for (const principal of principals) {
|
|
@@ -1740,7 +2174,13 @@ class VaultService implements Vault {
|
|
|
1740
2174
|
const owner = { role: 'owner' as const, scope: EVERYWHERE };
|
|
1741
2175
|
d.grants.set(principal, [...subject.stored]);
|
|
1742
2176
|
d.touched.add(principal);
|
|
1743
|
-
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
|
+
}
|
|
1744
2184
|
if (row === undefined && !tampered) {
|
|
1745
2185
|
d.log.push(accessEntry(VAULT_ACTOR, 'member.add', principal, 'allow', correlation, { ...owner, reason }));
|
|
1746
2186
|
d.writes.push((at) => store.insertMember(d.tx, {
|
|
@@ -1771,7 +2211,8 @@ class VaultService implements Vault {
|
|
|
1771
2211
|
if (fault !== null) return { principal, status: 'tampered', live: none, all: none, fault, stored: grants };
|
|
1772
2212
|
if (!PRINCIPAL.test(principal)) return { principal, status: 'unknown', live: none, all: none, fault, stored: grants };
|
|
1773
2213
|
if (row?.status !== 'active') return { principal, status: row?.status ?? 'unknown', live: none, all: none, fault, stored: grants };
|
|
1774
|
-
|
|
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 }));
|
|
1775
2216
|
const { role, scope } = roleOf(principal, row);
|
|
1776
2217
|
// Every decision about them reads the policy: their roles as an Owner edited them, and which environments are protected.
|
|
1777
2218
|
const { policy } = await this.#policyAt(db, reports, at, reads);
|
|
@@ -1787,25 +2228,16 @@ class VaultService implements Vault {
|
|
|
1787
2228
|
}
|
|
1788
2229
|
|
|
1789
2230
|
#access(principal: string, row: Member | undefined, held: readonly GrantRow[], at: number, tampered: boolean, policy: Policy): Access {
|
|
1790
|
-
|
|
1791
|
-
const access: Access = {
|
|
1792
|
-
principal,
|
|
1793
|
-
status: tampered ? 'tampered' : !PRINCIPAL.test(principal) ? 'unknown' : (row?.status ?? 'unknown'),
|
|
1794
|
-
generation: row?.generation ?? 0,
|
|
1795
|
-
...(active ? roleOf(principal, row) : { role: 'member', scope: EVERYWHERE }),
|
|
1796
|
-
grants: active ? held.filter((grant) => live(grant, at) && grantKind(grant) !== null).map(view) : [],
|
|
1797
|
-
since: row ? iso(row.statusChangedAt) : null,
|
|
1798
|
-
by: row?.statusChangedBy ?? null,
|
|
1799
|
-
policy,
|
|
1800
|
-
};
|
|
1801
|
-
return { ...access, revision: accessRevision(access) };
|
|
2231
|
+
return answerFrom(principal, row, held, at, tampered, policy);
|
|
1802
2232
|
}
|
|
1803
2233
|
|
|
1804
2234
|
/** The revision of what `access` would answer about a standing a decision read: none but an active member's matches a kept answer. */
|
|
1805
2235
|
#revisionOf(standing: Standing, d: Decision): string | null {
|
|
1806
2236
|
const { policy } = standing.live;
|
|
1807
2237
|
if (standing.status !== 'active' || policy === undefined) return null;
|
|
1808
|
-
|
|
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!;
|
|
1809
2241
|
}
|
|
1810
2242
|
|
|
1811
2243
|
/**
|
|
@@ -1830,11 +2262,13 @@ class VaultService implements Vault {
|
|
|
1830
2262
|
/** `access`, as the rows are now. */
|
|
1831
2263
|
async #read(principal: string, superseded: string | null): Promise<Access> {
|
|
1832
2264
|
const read = async (db: Queryable, reports: NewEntry[]) => {
|
|
1833
|
-
const reads = await snapshot(db, { principals: [principal], members: true });
|
|
2265
|
+
const reads = await snapshot(db, { principals: [principal], members: true, seals: this.#checksPlaces() });
|
|
1834
2266
|
const [row, held, at] = [reads.members.get(principal), reads.grants.get(principal)!, this.#at(reads)];
|
|
1835
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;
|
|
1836
2270
|
const { policy } = await this.#policyAt(db, reports, at, reads);
|
|
1837
|
-
return this.#access(principal, row,
|
|
2271
|
+
return this.#access(principal, row, holding, at, fault !== null, policy);
|
|
1838
2272
|
};
|
|
1839
2273
|
const found: NewEntry[] = [];
|
|
1840
2274
|
const quick = await read(this.#db, found).catch((error: unknown) => {
|
|
@@ -1882,7 +2316,7 @@ class VaultService implements Vault {
|
|
|
1882
2316
|
const action = changes.every((change) => change.role === null) ? 'access.revoke' : 'access.grant';
|
|
1883
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.
|
|
1884
2318
|
const [only] = changes.length === 1 ? changes : [];
|
|
1885
|
-
const refused = (code: RefusalCode, message =
|
|
2319
|
+
const refused = (code: RefusalCode, message = REFUSAL_MESSAGES[code]) => {
|
|
1886
2320
|
const where = only === undefined || code === 'invalid' ? { ids: {}, detail: {} } : located(only);
|
|
1887
2321
|
return new Refused(refusal(code, message), [{
|
|
1888
2322
|
...accessEntry(actor, action, principal, 'deny', input, { changes: input.changes, ...where.detail }, code),
|
|
@@ -1920,11 +2354,10 @@ class VaultService implements Vault {
|
|
|
1920
2354
|
throw refused('invalid', `${change.role} can only be granted on a project`);
|
|
1921
2355
|
}
|
|
1922
2356
|
}
|
|
1923
|
-
const
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
);
|
|
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);
|
|
1928
2361
|
const where: Place[] = changes.map(({ projectId, environmentId, role }) => {
|
|
1929
2362
|
const environment = environmentId === null ? undefined : known.environments.get(environmentId);
|
|
1930
2363
|
if (!known.projects.has(projectId!) || (environmentId !== null && environment?.projectId !== projectId)) {
|
|
@@ -1958,7 +2391,7 @@ class VaultService implements Vault {
|
|
|
1958
2391
|
// The setting as of the log's head, which a change to it takes too.
|
|
1959
2392
|
const setting = (await this.#settingsAt(d.tx, d.reports)).serviceAccounts;
|
|
1960
2393
|
// One just admitted holds nothing yet, and is theirs.
|
|
1961
|
-
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);
|
|
1962
2395
|
// Each place's new role they must hold, and what it held before: taking a grant away is changing it.
|
|
1963
2396
|
const gives = changes.every((change, i) => {
|
|
1964
2397
|
const before = subject.live.grants.find((grant) => placeKey({ ...grant, environmentSlug: null }) === placeKey(change));
|
|
@@ -1981,7 +2414,7 @@ class VaultService implements Vault {
|
|
|
1981
2414
|
* not run the instance sets one up as they would through `admit`.
|
|
1982
2415
|
*/
|
|
1983
2416
|
async #admitWith(d: Decision, acting: Standing, principal: string, row: Member | undefined, input: SetAccessInput): Promise<Admitted> {
|
|
1984
|
-
const refused = (code: RefusalCode, message =
|
|
2417
|
+
const refused = (code: RefusalCode, message = REFUSAL_MESSAGES[code]) =>
|
|
1985
2418
|
new Refused(refusal(code, message), [accessEntry(acting.principal, 'member.add', principal, 'deny', input, { changes: input.changes }, code)]);
|
|
1986
2419
|
if (!SERVICE.test(principal)) throw refused('invalid', 'only a service account is admitted with its grants: a person, by an instance role');
|
|
1987
2420
|
if (!runsInstance(acting.live)) await this.#setsUp(d, acting, true, refused);
|
|
@@ -2023,6 +2456,7 @@ class VaultService implements Vault {
|
|
|
2023
2456
|
|
|
2024
2457
|
if (change.role === null) {
|
|
2025
2458
|
clear();
|
|
2459
|
+
if (existing !== undefined) d.changes?.stage(memberRef(principal), { op: 'access.revoke', place: placeOf(place) }, actor);
|
|
2026
2460
|
if (current === undefined) return 'unchanged';
|
|
2027
2461
|
entry('access.revoke', null);
|
|
2028
2462
|
return 'revoked';
|
|
@@ -2035,6 +2469,8 @@ class VaultService implements Vault {
|
|
|
2035
2469
|
await store.insertGrant(d.tx, grant);
|
|
2036
2470
|
d.grants.get(principal)!.push(grant);
|
|
2037
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);
|
|
2038
2474
|
entry('access.grant', role);
|
|
2039
2475
|
return current === undefined ? 'created' : 'updated';
|
|
2040
2476
|
}
|
|
@@ -2042,7 +2478,7 @@ class VaultService implements Vault {
|
|
|
2042
2478
|
admit(input: AdmitInput): Promise<Outcome<Admitted>> {
|
|
2043
2479
|
const { actor, principal } = input;
|
|
2044
2480
|
const asked = input.role === undefined ? {} : { role: input.role, scope: input.scope ?? EVERYWHERE };
|
|
2045
|
-
const refused = (code: RefusalCode, message =
|
|
2481
|
+
const refused = (code: RefusalCode, message = REFUSAL_MESSAGES[code]) =>
|
|
2046
2482
|
new Refused(refusal(code, message), [
|
|
2047
2483
|
accessEntry(actor, 'member.add', principal, 'deny', input, asked, code),
|
|
2048
2484
|
]);
|
|
@@ -2082,6 +2518,7 @@ class VaultService implements Vault {
|
|
|
2082
2518
|
if (changed) {
|
|
2083
2519
|
d.log.push(accessEntry(actor, 'member.role', principal, 'allow', input, { role: wanted.role, scope: wanted.scope, previousRole: row.role, previousScope: row.scope }));
|
|
2084
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);
|
|
2085
2522
|
return { created: false, ...wanted, generation: row.generation };
|
|
2086
2523
|
}
|
|
2087
2524
|
return { created: false, role: row.role, scope: row.scope, generation: row.generation };
|
|
@@ -2097,11 +2534,30 @@ class VaultService implements Vault {
|
|
|
2097
2534
|
*/
|
|
2098
2535
|
async #setsUp(d: Decision, acting: Standing, service: boolean, refused: (code: RefusalCode, message?: string) => Refused): Promise<void> {
|
|
2099
2536
|
const setting = (await this.#settingsAt(d.tx, d.reports)).serviceAccounts;
|
|
2100
|
-
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)))) {
|
|
2101
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');
|
|
2102
2539
|
}
|
|
2103
2540
|
}
|
|
2104
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
|
+
|
|
2105
2561
|
/**
|
|
2106
2562
|
* Admit `principal`, who has no row or a removed one: a fresh start, as a
|
|
2107
2563
|
* member unless given a role (`wanted`), at the generation a removal
|
|
@@ -2123,6 +2579,7 @@ class VaultService implements Vault {
|
|
|
2123
2579
|
if (wanted?.role === 'owner' && !ownsInstance(acting.live)) throw refused('not_allowed', OWNERS_ONLY);
|
|
2124
2580
|
const { role, scope } = wanted ?? { role: 'member' as const, scope: EVERYWHERE };
|
|
2125
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);
|
|
2126
2583
|
d.writes.push(async (at) => {
|
|
2127
2584
|
if (row === undefined) {
|
|
2128
2585
|
await store.insertMember(d.tx, {
|
|
@@ -2147,7 +2604,7 @@ class VaultService implements Vault {
|
|
|
2147
2604
|
|
|
2148
2605
|
remove(input: RemoveInput): Promise<Outcome<{ revoked: Grant[]; generation: number }>> {
|
|
2149
2606
|
const { actor, principal } = input;
|
|
2150
|
-
const refused = (code: RefusalCode, message =
|
|
2607
|
+
const refused = (code: RefusalCode, message = REFUSAL_MESSAGES[code]) =>
|
|
2151
2608
|
new Refused(refusal(code, message), [accessEntry(actor, 'member.remove', principal, 'deny', input, {}, code)]);
|
|
2152
2609
|
return this.#decide([actor, principal], async (d) => {
|
|
2153
2610
|
validateCorrelation(input);
|
|
@@ -2166,7 +2623,7 @@ class VaultService implements Vault {
|
|
|
2166
2623
|
if (!runsInstance(holder)) {
|
|
2167
2624
|
// A service account its manager removes: they reach everything it holds (`managesService`).
|
|
2168
2625
|
const theirs = SERVICE.test(principal) && PERSON.test(actor) && row?.status === 'active' &&
|
|
2169
|
-
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));
|
|
2170
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');
|
|
2171
2628
|
}
|
|
2172
2629
|
if (row?.status !== 'active') throw refused(row === undefined ? 'not_a_member' : 'removed');
|
|
@@ -2188,6 +2645,11 @@ class VaultService implements Vault {
|
|
|
2188
2645
|
}
|
|
2189
2646
|
const generation = row.generation + 1;
|
|
2190
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);
|
|
2191
2653
|
d.touched.add(principal);
|
|
2192
2654
|
d.writes.push(async (at) => {
|
|
2193
2655
|
await store.deleteGrants(d.tx, principal);
|
|
@@ -2210,8 +2672,15 @@ class VaultService implements Vault {
|
|
|
2210
2672
|
* environment by its slug, which the setting's scope matches, and who
|
|
2211
2673
|
* admitted it, as its row says.
|
|
2212
2674
|
*/
|
|
2213
|
-
async #account(db: Queryable, subject: Standing, row: Member): Promise<ServiceAccount> {
|
|
2214
|
-
|
|
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);
|
|
2215
2684
|
return {
|
|
2216
2685
|
grants: subject.live.grants.map((grant) => ({
|
|
2217
2686
|
role: grant.role,
|
|
@@ -2239,15 +2708,16 @@ class VaultService implements Vault {
|
|
|
2239
2708
|
* entries and none of its own within reach, the setting is closed, never
|
|
2240
2709
|
* the default: otherwise whoever can insert rows could open it again.
|
|
2241
2710
|
*/
|
|
2242
|
-
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.
|
|
2243
2713
|
const entry = await newestAuthentic(
|
|
2244
|
-
(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)),
|
|
2245
2717
|
this.#check(reports, null),
|
|
2246
2718
|
);
|
|
2247
|
-
if (entry === 'none') return
|
|
2248
|
-
|
|
2249
|
-
const { serviceAccounts } = JSON.parse(entry.metadata) as { serviceAccounts?: unknown };
|
|
2250
|
-
return isScope(serviceAccounts) ? { serviceAccounts } : CLOSED_SETTINGS;
|
|
2719
|
+
if (entry === 'none') return settingsFrom(null);
|
|
2720
|
+
return typeof entry === 'string' ? CLOSED_SETTINGS : settingsFrom(entry.metadata);
|
|
2251
2721
|
}
|
|
2252
2722
|
|
|
2253
2723
|
// --- the instance's access policy -------------------------------------------
|
|
@@ -2268,7 +2738,7 @@ class VaultService implements Vault {
|
|
|
2268
2738
|
* no role holds anything but Owner, otherwise whoever can insert rows
|
|
2269
2739
|
* could unprotect them. Its edits are then unknown, not none.
|
|
2270
2740
|
*/
|
|
2271
|
-
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 }> {
|
|
2272
2742
|
// The snapshot's newest first, with the rows it protects; the pages before it, as they are asked.
|
|
2273
2743
|
const entry = await newestAuthentic(
|
|
2274
2744
|
(limit, before) => (reads !== undefined && before === undefined
|
|
@@ -2276,11 +2746,13 @@ class VaultService implements Vault {
|
|
|
2276
2746
|
: store.newestVaultEntries(db, POLICY, limit, before)),
|
|
2277
2747
|
this.#check(reports, null),
|
|
2278
2748
|
);
|
|
2279
|
-
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 };
|
|
2280
2750
|
const edits = typeof entry === 'string' ? null : readEdits(JSON.parse(entry.metadata) as { policy?: unknown });
|
|
2281
|
-
if (edits === null) return { policy: CLOSED_POLICY, edits: NO_EDITS, readable: false };
|
|
2282
|
-
const rows = entry === reads?.newestPolicy
|
|
2283
|
-
|
|
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 };
|
|
2284
2756
|
}
|
|
2285
2757
|
|
|
2286
2758
|
changePolicy(input: ChangePolicyInput): Promise<Outcome<{ policy: Policy; seq: number | null }>> {
|
|
@@ -2299,7 +2771,7 @@ class VaultService implements Vault {
|
|
|
2299
2771
|
...at,
|
|
2300
2772
|
metadata: JSON.stringify({ ...detail, ...traced(input) }),
|
|
2301
2773
|
});
|
|
2302
|
-
const refused = (code: RefusalCode, message =
|
|
2774
|
+
const refused = (code: RefusalCode, message = REFUSAL_MESSAGES[code]) =>
|
|
2303
2775
|
new Refused(refusal(code, message), [entry('deny', { change: describeChange(change) }, code)]);
|
|
2304
2776
|
return this.#decide([actor], async (d) => {
|
|
2305
2777
|
validateCorrelation(input);
|
|
@@ -2319,6 +2791,8 @@ class VaultService implements Vault {
|
|
|
2319
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");
|
|
2320
2792
|
// Those the policy no longer holds stay out: a deleted environment's id, and an unwritten fork's past its window,
|
|
2321
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 }[] = [];
|
|
2322
2796
|
const next: PolicyEdits = {
|
|
2323
2797
|
roles: { ...edits.roles },
|
|
2324
2798
|
instanceRoles: { ...edits.instanceRoles },
|
|
@@ -2346,6 +2820,7 @@ class VaultService implements Vault {
|
|
|
2346
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`);
|
|
2347
2821
|
}
|
|
2348
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 } });
|
|
2349
2824
|
} else {
|
|
2350
2825
|
const problem = definitionProblem(change.definition);
|
|
2351
2826
|
if (problem !== null) throw refused('invalid', problem);
|
|
@@ -2357,6 +2832,7 @@ class VaultService implements Vault {
|
|
|
2357
2832
|
const shipped = instance ? INSTANCE_ROLES[change.role as InstanceRole] : isBuiltinRole(change.role) ? ROLES[change.role] : undefined;
|
|
2358
2833
|
if (shipped !== undefined && JSON.stringify(normalDefinition(shipped)) === JSON.stringify(definition)) delete table[change.role];
|
|
2359
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 } });
|
|
2360
2836
|
}
|
|
2361
2837
|
} else if (change?.kind === 'protect') {
|
|
2362
2838
|
if (typeof change.projectId !== 'string' || !UUID.test(change.projectId) || !Array.isArray(change.environmentIds) || change.environmentIds.length === 0
|
|
@@ -2366,28 +2842,52 @@ class VaultService implements Vault {
|
|
|
2366
2842
|
if (!(await store.projectLive(d.tx, change.projectId))) throw refused('invalid', 'no such project');
|
|
2367
2843
|
at.projectId = change.projectId;
|
|
2368
2844
|
// Each a live environment of the project, or none yet: a fork's id, protected before its row is written.
|
|
2369
|
-
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);
|
|
2370
2850
|
if ([...found.values()].some((environment) => environment.projectId !== change.projectId)) throw refused('invalid', 'each environment is one of the project\'s');
|
|
2371
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;
|
|
2372
2854
|
if (change.protected) {
|
|
2373
2855
|
if (!allows(acting.live, 'environment.manage', { projectId: change.projectId })) throw refused('not_allowed', 'protecting an environment takes managing its project\'s environments');
|
|
2374
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 } });
|
|
2375
2860
|
next.protected = [...next.protected, ...change.environmentIds.filter((id) => !known.has(id)).map((environmentId) => ({ projectId: change.projectId, environmentId, at: d.at }))]
|
|
2376
2861
|
.sort((a, b) => (a.environmentId < b.environmentId ? -1 : 1));
|
|
2377
2862
|
} else {
|
|
2378
2863
|
// Unprotecting widens what people hold there: an Owner of the project's to do, or of the whole instance's (R30).
|
|
2379
2864
|
// It takes off only what the project protects: a fork's id, with no row yet, is another project's to unprotect.
|
|
2380
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
|
+
}
|
|
2381
2871
|
next.protected = next.protected.filter((place) => place.projectId !== change.projectId || !change.environmentIds.includes(place.environmentId));
|
|
2382
2872
|
}
|
|
2383
2873
|
} else {
|
|
2384
2874
|
throw refused('invalid', 'a change is to a role, an instance role, or protection');
|
|
2385
2875
|
}
|
|
2386
2876
|
// The policy now, and the newest change, which it is as of: this one's, when it changed anything.
|
|
2387
|
-
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
|
+
};
|
|
2388
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);
|
|
2389
2885
|
const made = entry('allow', { policy: next, change: describeChange(change), before: describeBefore(change, before) });
|
|
2390
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
|
+
});
|
|
2391
2891
|
d.after.push((seqOf) => (answer.seq = seqOf(made)));
|
|
2392
2892
|
}
|
|
2393
2893
|
return answer;
|
|
@@ -2405,7 +2905,7 @@ class VaultService implements Vault {
|
|
|
2405
2905
|
requestId: input.requestId ?? null,
|
|
2406
2906
|
metadata: JSON.stringify({ ...detail, ...traced(input) }),
|
|
2407
2907
|
});
|
|
2408
|
-
const refused = (code: RefusalCode, message =
|
|
2908
|
+
const refused = (code: RefusalCode, message = REFUSAL_MESSAGES[code]) =>
|
|
2409
2909
|
new Refused(refusal(code, message), [entry('deny', { serviceAccounts: input.settings?.serviceAccounts ?? null }, code)]);
|
|
2410
2910
|
return this.#decide([actor], async (d) => {
|
|
2411
2911
|
validateCorrelation(input);
|
|
@@ -2691,7 +3191,7 @@ class VaultService implements Vault {
|
|
|
2691
3191
|
// Checkpoints one at a time, each against the one before.
|
|
2692
3192
|
const head = await lockLogHead(d.tx);
|
|
2693
3193
|
const latest = await store.latestVaultEntry(d.tx, [CHECKPOINT]);
|
|
2694
|
-
const refused = (code: RefusalCode, detail: Record<string, unknown>, message =
|
|
3194
|
+
const refused = (code: RefusalCode, detail: Record<string, unknown>, message = REFUSAL_MESSAGES[code]) =>
|
|
2695
3195
|
new Refused(refusal(code, message), [
|
|
2696
3196
|
{ actor: SCHEDULER, action: CHECKPOINT, decision: 'deny', code, metadata: JSON.stringify(detail) },
|
|
2697
3197
|
]);
|
|
@@ -2847,7 +3347,7 @@ class VaultService implements Vault {
|
|
|
2847
3347
|
return JSON.parse(refused.metadata) as { failedAtSeq?: number | null; reason?: string };
|
|
2848
3348
|
}
|
|
2849
3349
|
|
|
2850
|
-
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 }> {
|
|
2851
3351
|
await this.#settled();
|
|
2852
3352
|
const { signers, since } = this.#prepared;
|
|
2853
3353
|
// A key it replaced vouches only for prefixes before its first entry under the current one: none, before it has one.
|
|
@@ -2857,7 +3357,682 @@ class VaultService implements Vault {
|
|
|
2857
3357
|
owners: [...this.#config.owners],
|
|
2858
3358
|
// For an app of 0.5 while it runs beside this vault: there are none, and its lists and checks need the field.
|
|
2859
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`);
|
|
2860
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),
|
|
4025
|
+
};
|
|
4026
|
+
}
|
|
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;
|
|
2861
4036
|
}
|
|
2862
4037
|
|
|
2863
4038
|
async keyChecks(): Promise<KeyChecks> {
|
|
@@ -3077,6 +4252,9 @@ class VaultService implements Vault {
|
|
|
3077
4252
|
|
|
3078
4253
|
type Broken = Extract<LogVerification, { ok: false }>;
|
|
3079
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
|
+
|
|
3080
4258
|
/**
|
|
3081
4259
|
* Where a walk of the log resumes: the checkpoint's entry (null for the
|
|
3082
4260
|
* first entry), the end of the prefix it signed, the tally through there,
|
|
@@ -3468,10 +4646,6 @@ function accessEntry(
|
|
|
3468
4646
|
};
|
|
3469
4647
|
}
|
|
3470
4648
|
|
|
3471
|
-
function live(grant: GrantRow, at: number): boolean {
|
|
3472
|
-
return grant.expiresAt === null || grant.expiresAt > at;
|
|
3473
|
-
}
|
|
3474
|
-
|
|
3475
4649
|
/**
|
|
3476
4650
|
* Where an entry about a grant says it is: its project and environment; or,
|
|
3477
4651
|
* for a grant on every project of 0.4, neither, and the place as a path in
|
|
@@ -3497,67 +4671,10 @@ function placeKey(place: store.Place): string {
|
|
|
3497
4671
|
return place.projectId !== null ? `project:${place.projectId}` : `every-project:${place.environmentSlug ?? ''}`;
|
|
3498
4672
|
}
|
|
3499
4673
|
|
|
3500
|
-
/** A member's instance role, as their row has it: service accounts are members, whatever a row says. */
|
|
3501
|
-
function roleOf(principal: string, row: Member): { role: InstanceRole; scope: Scope } {
|
|
3502
|
-
return principal.startsWith('user:') ? { role: row.role, scope: row.scope } : { role: 'member', scope: EVERYWHERE };
|
|
3503
|
-
}
|
|
3504
|
-
|
|
3505
|
-
function view(grant: GrantRow): Grant {
|
|
3506
|
-
return {
|
|
3507
|
-
projectId: grant.projectId,
|
|
3508
|
-
environmentId: grant.environmentId,
|
|
3509
|
-
role: grant.role as Role,
|
|
3510
|
-
expiresAt: grant.expiresAt === null ? null : iso(grant.expiresAt),
|
|
3511
|
-
grantedAt: iso(grant.grantedAt),
|
|
3512
|
-
grantedBy: grant.grantedBy,
|
|
3513
|
-
};
|
|
3514
|
-
}
|
|
3515
|
-
|
|
3516
4674
|
function iso(ms: number): string {
|
|
3517
4675
|
return new Date(ms).toISOString();
|
|
3518
4676
|
}
|
|
3519
4677
|
|
|
3520
|
-
/** A policy entry's edits, checked as the vault wrote them, or null for any that does not hold. */
|
|
3521
|
-
function readEdits(stored: { policy?: unknown }): PolicyEdits | null {
|
|
3522
|
-
const edits = stored.policy as Partial<PolicyEdits> | undefined;
|
|
3523
|
-
if (typeof edits !== 'object' || edits === null) return null;
|
|
3524
|
-
const { roles, instanceRoles, protected: protectedIds } = edits;
|
|
3525
|
-
if (typeof roles !== 'object' || roles === null || typeof instanceRoles !== 'object' || instanceRoles === null || !Array.isArray(protectedIds)) return null;
|
|
3526
|
-
const defined = (table: object) => Object.values(table).every((definition) => definitionProblem(definition as RoleDefinition) === null);
|
|
3527
|
-
if (!defined(roles) || !defined(instanceRoles) || Object.keys(instanceRoles).some((role) => !isInstanceRole(role))) return null;
|
|
3528
|
-
if (protectedIds.some((place) => typeof place !== 'object' || place === null || !UUID.test((place as ProtectedPlace).projectId) || !UUID.test((place as ProtectedPlace).environmentId)
|
|
3529
|
-
|| !Number.isSafeInteger((place as ProtectedPlace).at))) return null;
|
|
3530
|
-
return { roles: roles as Record<string, RoleDefinition>, instanceRoles: instanceRoles as PolicyEdits['instanceRoles'], protected: protectedIds as ProtectedPlace[] };
|
|
3531
|
-
}
|
|
3532
|
-
|
|
3533
|
-
/**
|
|
3534
|
-
* The policy `edits` say as of `at`: the roles as they ship, each edited
|
|
3535
|
-
* one replaced and each made one added; and the protected environments, but
|
|
3536
|
-
* for those deleted since, the project around one's included: a deleted
|
|
3537
|
-
* prod protects nothing, and holds nobody back from giving a service
|
|
3538
|
-
* account a role on all of its project (`givesService`). A fork's id with
|
|
3539
|
-
* no row yet stays, protected before it is written, ten minutes: one with
|
|
3540
|
-
* none after that is a fork refused or abandoned, never written. Each
|
|
3541
|
-
* keeps its slug, which a scoped instance role's filters read. The edits
|
|
3542
|
-
* keep them all: no write is needed.
|
|
3543
|
-
*/
|
|
3544
|
-
function livePolicy(rows: ReadonlyMap<string, store.EnvironmentRow>, edits: PolicyEdits, at: number): Policy {
|
|
3545
|
-
const live = edits.protected.filter((place) => {
|
|
3546
|
-
const row = rows.get(place.environmentId);
|
|
3547
|
-
return row === undefined ? at - place.at < UNWRITTEN_FORK_MS : !row.deleted;
|
|
3548
|
-
});
|
|
3549
|
-
const slugOf = (place: ProtectedPlace) => rows.get(place.environmentId)?.slug ?? null;
|
|
3550
|
-
return {
|
|
3551
|
-
roles: { ...ROLES, ...edits.roles },
|
|
3552
|
-
instanceRoles: { ...INSTANCE_ROLES, ...edits.instanceRoles },
|
|
3553
|
-
protected: live.map((place) => place.environmentId),
|
|
3554
|
-
protectedIn: live.reduce<Record<string, { environmentId: string; slug: string | null }[]>>(
|
|
3555
|
-
(byProject, place) => ({ ...byProject, [place.projectId]: [...(byProject[place.projectId] ?? []), { environmentId: place.environmentId, slug: slugOf(place) }] }),
|
|
3556
|
-
{},
|
|
3557
|
-
),
|
|
3558
|
-
};
|
|
3559
|
-
}
|
|
3560
|
-
|
|
3561
4678
|
/** A change, as its entry names it. */
|
|
3562
4679
|
function describeChange(change: PolicyChange | undefined): Record<string, unknown> | null {
|
|
3563
4680
|
if (change === undefined || change === null || typeof change !== 'object') return null;
|
|
@@ -3599,3 +4716,32 @@ function serialisable(wrapped: { kekProvider: string; kekId: string; kekVersion:
|
|
|
3599
4716
|
function base64(bytes: Uint8Array): string {
|
|
3600
4717
|
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64');
|
|
3601
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
|
+
}
|