@coffre/vault 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cloudflare.d.ts +11 -11
- package/dist/cloudflare.js +8 -2
- package/dist/{index-BkHJR9Gt.d.ts → index-CByXzyi-.d.ts} +7 -12
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/node.d.ts +13 -2
- package/dist/node.js +4 -2
- package/dist/{src-BcttALFQ.js → src-D82jyprb.js} +2 -6
- package/dist/{vault-lBbyZ3kQ.js → vault-B0YiPBgi.js} +1460 -471
- package/package.json +3 -3
- package/src/accounting.ts +37 -8
- package/src/cloudflare.ts +3 -0
- package/src/config.ts +12 -19
- package/src/index.ts +1 -1
- package/src/local.ts +2 -0
- package/src/log.ts +91 -60
- package/src/replay.ts +1 -1
- package/src/store.ts +179 -22
- package/src/tally.ts +241 -0
- package/src/vault.ts +831 -254
package/src/vault.ts
CHANGED
|
@@ -3,25 +3,39 @@ import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
|
3
3
|
import {
|
|
4
4
|
allows,
|
|
5
5
|
assignableToEnvironment,
|
|
6
|
+
DEFAULT_POLICY,
|
|
7
|
+
definitionProblem,
|
|
8
|
+
EVERY_ENVIRONMENT,
|
|
6
9
|
EVERYWHERE,
|
|
10
|
+
FIXED_ROLES,
|
|
7
11
|
givesService,
|
|
12
|
+
guarded,
|
|
8
13
|
grantKind,
|
|
14
|
+
INSTANCE_ROLES,
|
|
15
|
+
isBuiltinRole,
|
|
9
16
|
isCredentialScope,
|
|
10
17
|
isInstanceRole,
|
|
11
18
|
isRole,
|
|
12
19
|
isScope,
|
|
13
20
|
managesService,
|
|
14
21
|
mayManageAccess,
|
|
22
|
+
normalDefinition,
|
|
15
23
|
normalScope,
|
|
16
24
|
NOTHING,
|
|
17
25
|
ownsInstance,
|
|
26
|
+
PROJECT_ONLY_PERMISSIONS,
|
|
27
|
+
ROLE_ID,
|
|
28
|
+
ROLES,
|
|
18
29
|
runsInstance,
|
|
19
30
|
setsUpServices,
|
|
31
|
+
UNWRITTEN_FORK_MS,
|
|
20
32
|
type Holdings,
|
|
21
33
|
type InstanceRole,
|
|
22
34
|
type Permission,
|
|
23
35
|
type Place,
|
|
36
|
+
type Policy,
|
|
24
37
|
type Role,
|
|
38
|
+
type RoleDefinition,
|
|
25
39
|
type Scope,
|
|
26
40
|
type ServiceAccount,
|
|
27
41
|
withinCredential,
|
|
@@ -46,11 +60,14 @@ import {
|
|
|
46
60
|
checkpointMessage,
|
|
47
61
|
checkpointVerifier,
|
|
48
62
|
describeAccessFault,
|
|
49
|
-
|
|
63
|
+
handsBack,
|
|
64
|
+
POLICY_REVISION,
|
|
65
|
+
policyRevision,
|
|
50
66
|
type Access,
|
|
51
67
|
type AccessChange,
|
|
52
68
|
type AccessFault,
|
|
53
69
|
type AdmitInput,
|
|
70
|
+
type ChangePolicyInput,
|
|
54
71
|
type Admitted,
|
|
55
72
|
type Checkpoint,
|
|
56
73
|
type CheckpointKey,
|
|
@@ -61,6 +78,7 @@ import {
|
|
|
61
78
|
type LogHead,
|
|
62
79
|
type LogVerification,
|
|
63
80
|
type Outcome,
|
|
81
|
+
type PolicyChange,
|
|
64
82
|
type Purpose,
|
|
65
83
|
type ReferenceInput,
|
|
66
84
|
type Refusal,
|
|
@@ -73,6 +91,7 @@ import {
|
|
|
73
91
|
type Settings,
|
|
74
92
|
type Through,
|
|
75
93
|
type UnwrapInput,
|
|
94
|
+
type Wrapped,
|
|
76
95
|
type Vault,
|
|
77
96
|
type Via,
|
|
78
97
|
type VerifyLogInput,
|
|
@@ -80,17 +99,17 @@ import {
|
|
|
80
99
|
type WrappedKey,
|
|
81
100
|
} from '@coffre/core/vault';
|
|
82
101
|
import type { Database, Queryable, Transaction } from '@coffre/db';
|
|
83
|
-
import { isUniqueViolation, SNAPSHOT } from '@coffre/db/dialect';
|
|
102
|
+
import { isUniqueViolation, isUnreachable, SNAPSHOT } from '@coffre/db/dialect';
|
|
84
103
|
import { everyProjectConversion, newestAccess, reportStands } from '@coffre/db/grants';
|
|
85
|
-
import { appendEntries,
|
|
104
|
+
import { appendEntries, lockLogHead, missingEntry, newestAuthentic, newestVaultSeq, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
|
|
86
105
|
|
|
87
|
-
import { KeyAccounting } from './accounting.ts';
|
|
88
106
|
import { signer, type Signer } from './checkpoint.ts';
|
|
89
107
|
import type { ResolvedVaultConfig } from './config.ts';
|
|
90
|
-
import { carries,
|
|
108
|
+
import { carries, further, ownEntries, rehash, relink, START, vaultLogKey, VERIFY_BATCH, withCause, type Position } from './log.ts';
|
|
91
109
|
import { apply, replayFault, type LoggedMember, type Replayed } from './replay.ts';
|
|
92
110
|
import { memberMac, rowKey, sameGrants, sealed, standing } from './rows.ts';
|
|
93
111
|
import * as store from './store.ts';
|
|
112
|
+
import { accessDigest, CHECKPOINT, EMPTY_TALLY, expectedKey, TALLY_VERSION, Walk, type AccessTally, type Expected, type TalliedCheckpoint, type Tally } from './tally.ts';
|
|
94
113
|
import { ACCESS_ACTIONS, type GrantRow, type Member } from './store.ts';
|
|
95
114
|
|
|
96
115
|
export type VaultOptions = {
|
|
@@ -101,6 +120,8 @@ export type VaultOptions = {
|
|
|
101
120
|
keyBudgetMs?: number;
|
|
102
121
|
/** Milliseconds added to the database's clock where the vault decides by it. Tests move time with it. */
|
|
103
122
|
clockOffset?: () => number;
|
|
123
|
+
/** How far one call walks the log (`WALKS`); tests shorten them. */
|
|
124
|
+
walks?: Partial<Walks>;
|
|
104
125
|
};
|
|
105
126
|
|
|
106
127
|
/**
|
|
@@ -121,9 +142,9 @@ export type PreparedVault = {
|
|
|
121
142
|
logKey: LogKey;
|
|
122
143
|
/** Each signing key's log key: those after the first verify only entries before `since`. */
|
|
123
144
|
logKeys: LogKey[];
|
|
124
|
-
options: Required<VaultOptions
|
|
125
|
-
/** How far the
|
|
126
|
-
verified:
|
|
145
|
+
options: Required<Omit<VaultOptions, 'walks'>> & { walks: Walks };
|
|
146
|
+
/** How far the chain is rehashed, by any call of this process: its head must still be there. */
|
|
147
|
+
verified: Position;
|
|
127
148
|
/** What member rows are sealed under now; rows.ts. */
|
|
128
149
|
rowKey: Buffer;
|
|
129
150
|
/** Each signing key's row key: those after the first vouch for rows only until `since`, when the rows are sealed again. */
|
|
@@ -150,8 +171,8 @@ export async function prepareVault(config: ResolvedVaultConfig, options: VaultOp
|
|
|
150
171
|
return {
|
|
151
172
|
config,
|
|
152
173
|
...(await keysOf(config.signingKeys)),
|
|
153
|
-
options: { keyBudgetMs: options.keyBudgetMs ?? KEY_BUDGET_MS, clockOffset: options.clockOffset ?? (() => 0) },
|
|
154
|
-
verified:
|
|
174
|
+
options: { keyBudgetMs: options.keyBudgetMs ?? KEY_BUDGET_MS, clockOffset: options.clockOffset ?? (() => 0), walks: { ...WALKS, ...options.walks } },
|
|
175
|
+
verified: START,
|
|
155
176
|
reported: new Set(),
|
|
156
177
|
since: null,
|
|
157
178
|
superseded: null,
|
|
@@ -208,15 +229,48 @@ const UNSEALED = { accessSeq: 0n, mac: Buffer.alloc(32) };
|
|
|
208
229
|
/** Why a change to a tampered member is refused. */
|
|
209
230
|
const TAMPERED_SUBJECT = "this member's record failed the vault's integrity check: remove them to start over";
|
|
210
231
|
|
|
211
|
-
/** The action of the vault's entry that signs a prefix of the log. */
|
|
212
|
-
const CHECKPOINT = 'audit.checkpoint';
|
|
213
|
-
|
|
214
232
|
/** The action of the vault's entry that holds the instance's settings, and changes them (`settings`). */
|
|
215
233
|
const SETTINGS = 'settings.change';
|
|
216
234
|
|
|
217
235
|
/** The settings before anyone changed them: people set up service accounts anywhere they hold access. */
|
|
218
236
|
const DEFAULT_SETTINGS: Settings = { serviceAccounts: EVERYWHERE };
|
|
219
237
|
|
|
238
|
+
/** The action of the vault's entry that holds the instance's access policy, and changes it (`policy`). */
|
|
239
|
+
const POLICY = 'policy.change';
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* What a `policy.change` entry keeps: the roles an Owner edited or made,
|
|
243
|
+
* each whole, and the protected environments. A role as it ships is kept by
|
|
244
|
+
* none, so a release that changes a default changes it where nobody edited it.
|
|
245
|
+
*/
|
|
246
|
+
type PolicyEdits = { roles: Record<string, RoleDefinition>; instanceRoles: Partial<Record<InstanceRole, RoleDefinition>>; protected: ProtectedPlace[] };
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* A protected environment, with its project's id, what a grant on the whole
|
|
250
|
+
* project reaches of it, and when it was protected, by the vault's clock:
|
|
251
|
+
* an id with no row ten minutes later is a fork that was never written.
|
|
252
|
+
*/
|
|
253
|
+
type ProtectedPlace = { projectId: string; environmentId: string; at: number };
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
const NO_EDITS: PolicyEdits = { roles: {}, instanceRoles: {}, protected: [] };
|
|
257
|
+
|
|
258
|
+
/** A role holding nothing, which every role but Owner is under the closed policy. */
|
|
259
|
+
const NOTHING_HELD = (definition: RoleDefinition): RoleDefinition => ({ ...definition, permissions: [], protected: [] });
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The policy when the log has some, but none the vault wrote within reach:
|
|
263
|
+
* whoever inserted rows may have hidden an Owner's narrowing, so no role
|
|
264
|
+
* holds anything, a service account's neither, but Owner, which holds
|
|
265
|
+
* everything always and so may write the policy again.
|
|
266
|
+
*/
|
|
267
|
+
const CLOSED_POLICY: Policy = {
|
|
268
|
+
roles: Object.fromEntries(Object.entries(ROLES).map(([id, definition]) => [id, id === 'owner' ? definition : NOTHING_HELD(definition)])),
|
|
269
|
+
instanceRoles: Object.fromEntries(Object.entries(INSTANCE_ROLES).map(([id, definition]) => [id, id === 'owner' ? definition : NOTHING_HELD(definition)])) as Policy['instanceRoles'],
|
|
270
|
+
protected: [EVERY_ENVIRONMENT],
|
|
271
|
+
protectedIn: {},
|
|
272
|
+
};
|
|
273
|
+
|
|
220
274
|
/** The settings when the log has some, but none the vault wrote within reach: people set up no service accounts. */
|
|
221
275
|
const CLOSED_SETTINGS: Settings = { serviceAccounts: { projects: { only: [] }, environments: 'all' } };
|
|
222
276
|
|
|
@@ -234,13 +288,6 @@ const NOT_THEIRS = 'a person gives a service account at most what they hold, whe
|
|
|
234
288
|
/** Who asks for checkpoints: the app's scheduled job. */
|
|
235
289
|
const SCHEDULER = 'system:coffre-scheduler';
|
|
236
290
|
|
|
237
|
-
/**
|
|
238
|
-
* How often a checkpoint recomputes the chain from its first entry: the
|
|
239
|
-
* first of each hour, by the vault's clock. The others resume from the
|
|
240
|
-
* prefix the last one signed (`#recompute`).
|
|
241
|
-
*/
|
|
242
|
-
const FULL_RECOMPUTE_MS = 60 * 60 * 1000;
|
|
243
|
-
|
|
244
291
|
/** A refusal and the entries that record it. */
|
|
245
292
|
class Refused {
|
|
246
293
|
readonly refusal: Refusal;
|
|
@@ -269,6 +316,9 @@ class Outage {
|
|
|
269
316
|
}
|
|
270
317
|
}
|
|
271
318
|
|
|
319
|
+
/** A rewrap that would make a value less sensitive than it is. */
|
|
320
|
+
const LOOSER = 'a value is made less sensitive only by writing it again: an old version comes back, and a key is made stricter, as sensitive as its newest version or more';
|
|
321
|
+
|
|
272
322
|
function refusal(code: RefusalCode, message: string): Refusal {
|
|
273
323
|
return { code, message };
|
|
274
324
|
}
|
|
@@ -283,10 +333,11 @@ const MESSAGES: Record<RefusalCode, string> = {
|
|
|
283
333
|
deleted: 'this project or environment was deleted',
|
|
284
334
|
ended: 'the reference read through was ended',
|
|
285
335
|
personal: "a personal config's values are its owner's alone",
|
|
286
|
-
|
|
336
|
+
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",
|
|
287
337
|
not_allowed: 'not allowed to change this',
|
|
288
338
|
last_owner: 'the last Owner of the whole instance stays one: make someone else an Owner first',
|
|
289
339
|
invalid: 'not something the rules allow',
|
|
340
|
+
policy_changed: "the instance's roles or protected environments changed while this was asked: try again",
|
|
290
341
|
log_broken: 'the vault log does not hold from the last checkpoint',
|
|
291
342
|
wrong_kek: "this vault's key does not open the data it holds",
|
|
292
343
|
tampered: "this member's record failed the vault's integrity check",
|
|
@@ -398,23 +449,29 @@ class VaultService implements Vault {
|
|
|
398
449
|
}
|
|
399
450
|
|
|
400
451
|
/**
|
|
401
|
-
* Decide in one transaction, with `principals`' rows locked first
|
|
402
|
-
* `Refused` or an `Outage` rolls back all but its own
|
|
403
|
-
* commit on their own: then the refusal is the answer, and
|
|
404
|
-
* fails the call.
|
|
452
|
+
* Decide in one transaction, with `principals`' rows locked first, then
|
|
453
|
+
* the log's head. A `Refused` or an `Outage` rolls back all but its own
|
|
454
|
+
* entries, which commit on their own: then the refusal is the answer, and
|
|
455
|
+
* the outage fails the call.
|
|
456
|
+
*
|
|
457
|
+
* Every change to the policy, the settings, a member or a place appends
|
|
458
|
+
* under the head, so whatever the decision reads of them stands until it
|
|
459
|
+
* commits: no decision acts on a policy read before it. A key operation
|
|
460
|
+
* alone takes the head later (`late`), once its key service has answered,
|
|
461
|
+
* which it must not wait on holding it, and decides again then (`#keys`).
|
|
405
462
|
*/
|
|
406
|
-
async #decide<T>(principals: readonly string[], decide: (d: Decision) => Promise<T
|
|
463
|
+
async #decide<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false): Promise<Outcome<T>> {
|
|
407
464
|
const superseded = await this.#settled();
|
|
408
465
|
if (superseded !== null) return { ok: false, refusal: refusal('wrong_kek', superseded) };
|
|
409
|
-
return this.#decideNow(principals, decide);
|
|
466
|
+
return this.#decideNow(principals, decide, late);
|
|
410
467
|
}
|
|
411
468
|
|
|
412
469
|
/** `#decide`, once the vault is settled. */
|
|
413
|
-
async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T
|
|
470
|
+
async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false): Promise<Outcome<T>> {
|
|
414
471
|
const reports: NewEntry[] = [];
|
|
415
472
|
for (let attempt = 1; ; attempt += 1) {
|
|
416
473
|
try {
|
|
417
|
-
return await this.#decideOnce(principals, decide, reports);
|
|
474
|
+
return await this.#decideOnce(principals, decide, reports, late);
|
|
418
475
|
} catch (error) {
|
|
419
476
|
// A member who had no row when this decision locked theirs, and has
|
|
420
477
|
// one now: another decision admitted them meanwhile. Again, with it.
|
|
@@ -424,11 +481,13 @@ class VaultService implements Vault {
|
|
|
424
481
|
}
|
|
425
482
|
}
|
|
426
483
|
|
|
427
|
-
async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[]): Promise<Outcome<T>> {
|
|
484
|
+
async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], late: boolean): Promise<Outcome<T>> {
|
|
428
485
|
try {
|
|
429
486
|
const result = await this.#db.transaction(async (tx) => {
|
|
430
487
|
await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
431
488
|
const members = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals);
|
|
489
|
+
if (!late) await lockLogHead(tx);
|
|
490
|
+
// The clock once the locks are held, in a statement of its own: one that waited for a lock is decided as of when it got it.
|
|
432
491
|
const d: Decision = {
|
|
433
492
|
tx, members, at: await this.#now(tx), log: [], writes: [], touched: new Set(), grants: new Map(), reports, after: [], underLock: [],
|
|
434
493
|
};
|
|
@@ -529,9 +588,13 @@ class VaultService implements Vault {
|
|
|
529
588
|
|
|
530
589
|
/** Whether `entry` carries the vault's MAC: under any of its keys before `since`, and only its current one from there. */
|
|
531
590
|
#authentic(entry: StoredEntry): boolean {
|
|
591
|
+
return verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys: this.#keysFor(entry) }).ok;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** The vault's log keys an entry may be under: any of them before `since`, and only its current one from there. */
|
|
595
|
+
#keysFor(entry: StoredEntry): LogKey[] {
|
|
532
596
|
const { logKeys, since } = this.#prepared;
|
|
533
|
-
|
|
534
|
-
return verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys }).ok;
|
|
597
|
+
return since !== null && entry.seq >= since ? logKeys.slice(0, 1) : logKeys;
|
|
535
598
|
}
|
|
536
599
|
|
|
537
600
|
/**
|
|
@@ -630,7 +693,6 @@ class VaultService implements Vault {
|
|
|
630
693
|
if (everyProject.length === 0) return;
|
|
631
694
|
if (await reportStands(d.tx, principal, this.#check(d.reports, principal))) return;
|
|
632
695
|
// The projects there are now, read under the log's head, which a deletion holds.
|
|
633
|
-
await lockLogHead(d.tx);
|
|
634
696
|
const before = roleOf(principal, row);
|
|
635
697
|
const conversion = everyProjectConversion(principal, before.role, subject.stored, d.at, await store.liveProjects(d.tx));
|
|
636
698
|
const after = { role: conversion.role, scope: conversion.scope };
|
|
@@ -790,6 +852,7 @@ class VaultService implements Vault {
|
|
|
790
852
|
async unwrap(input: UnwrapInput): Promise<Outcome<{ keys: string[] }>> {
|
|
791
853
|
const { principal } = input;
|
|
792
854
|
validateText(input.purpose);
|
|
855
|
+
if (input.keepsSensitive !== undefined && typeof input.keepsSensitive !== 'boolean') throw new Error('keepsSensitive is true, false or absent');
|
|
793
856
|
const vias = input.items.map((item) => validateVia(item.via));
|
|
794
857
|
const throughs = input.items.map((item) => validateThrough(item.through));
|
|
795
858
|
const loaded = await this.#versions(input, 'secret.read', { purpose: input.purpose });
|
|
@@ -838,7 +901,8 @@ class VaultService implements Vault {
|
|
|
838
901
|
{
|
|
839
902
|
action: 'secret.read',
|
|
840
903
|
principal,
|
|
841
|
-
|
|
904
|
+
// The proxy's use opens a value for coffre to use for its reader, never to hand back: `secret.use`. Anything else reads.
|
|
905
|
+
permission: input.purpose === 'use' ? 'secret.use' : 'secret.read',
|
|
842
906
|
secrets: items.map((item) => item.secret),
|
|
843
907
|
// Through a reference, the reader's grants on the holder's environment decide.
|
|
844
908
|
places: sealed.map((seal, i) => seal?.holder ?? items[i]!.secret),
|
|
@@ -867,7 +931,7 @@ class VaultService implements Vault {
|
|
|
867
931
|
);
|
|
868
932
|
}
|
|
869
933
|
|
|
870
|
-
async wrap(input: WrapInput): Promise<Outcome<
|
|
934
|
+
async wrap(input: WrapInput): Promise<Outcome<Wrapped>> {
|
|
871
935
|
const { principal, items } = input;
|
|
872
936
|
validateItems(items, 'key');
|
|
873
937
|
validateText(principal);
|
|
@@ -893,11 +957,11 @@ class VaultService implements Vault {
|
|
|
893
957
|
dek.fill(0);
|
|
894
958
|
}
|
|
895
959
|
}),
|
|
896
|
-
(done) => ({ wrapped: done.map(({ wrapped }) => wrapped), seqs: [] as number[] }),
|
|
960
|
+
(done) => ({ wrapped: done.map(({ wrapped }) => wrapped), seqs: [] as number[], sensitive: items.map(({ secret }) => secret.sensitive === true) }),
|
|
897
961
|
);
|
|
898
962
|
}
|
|
899
963
|
|
|
900
|
-
async rewrap(input: RewrapInput): Promise<Outcome<
|
|
964
|
+
async rewrap(input: RewrapInput): Promise<Outcome<Wrapped>> {
|
|
901
965
|
const { principal } = input;
|
|
902
966
|
const loaded = await this.#versions(input, 'key.rewrap');
|
|
903
967
|
if (!loaded.ok) return loaded;
|
|
@@ -911,6 +975,13 @@ class VaultService implements Vault {
|
|
|
911
975
|
...keyEntry('key.rewrap', principal, source.secret, 'deny', 'bad_claim', input), secretVersionId: source.id,
|
|
912
976
|
})));
|
|
913
977
|
}
|
|
978
|
+
// A value is made looser only by writing it again: a sensitive version's key, or one of a secret whose newest is,
|
|
979
|
+
// is never wrapped again as anything but sensitive, so that a person who may not read it never makes it readable.
|
|
980
|
+
if (items.some((item, i) => item.secret.sensitive !== true && (loaded.versions[i].secret.sensitive || loaded.versions[i].newestSensitive))) {
|
|
981
|
+
return this.#refuseVersions(principal, 'invalid', loaded.versions.map((source) => ({
|
|
982
|
+
...keyEntry('key.rewrap', principal, source.secret, 'deny', 'invalid', input), secretVersionId: source.id,
|
|
983
|
+
})), LOOSER);
|
|
984
|
+
}
|
|
914
985
|
const sources = new Map(items.map((item, i) => [item.secret, loaded.versions[i]]));
|
|
915
986
|
const entry = (secret: SecretRef, decision: 'allow' | 'deny', code: string | null): NewEntry => ({
|
|
916
987
|
...keyEntry('key.rewrap', principal, secret, decision, code, input, { from: sources.get(secret)!.secret.version }),
|
|
@@ -919,10 +990,26 @@ class VaultService implements Vault {
|
|
|
919
990
|
const remote =
|
|
920
991
|
this.#remote(this.#config.keks.primary) || items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
|
|
921
992
|
return this.#keys(
|
|
922
|
-
{
|
|
993
|
+
{
|
|
994
|
+
action: 'key.rewrap', principal, permission: 'secret.write', secrets: items.map((item) => item.secret), remote, entry, input,
|
|
995
|
+
// Again in the decision and under the log's head: a sensitive version written since it was loaded is as newest as any.
|
|
996
|
+
vet: async (db) => {
|
|
997
|
+
const newest = await store.newestSensitive(db, items.map(({ secret }) => secret.secretId));
|
|
998
|
+
return items.map(({ secret }) => (secret.sensitive !== true && newest.has(secret.secretId) ? 'invalid' : null));
|
|
999
|
+
},
|
|
1000
|
+
// An old value brought back, not the current one, is made live by whoever could have read it: a secret by its
|
|
1001
|
+
// readers, a sensitive one, which nobody reads, by those who use it, a plain one by any writer, as it was shown
|
|
1002
|
+
// to them. Whatever reads the key next, a reference to it included, then reads what its restorer could. Typing a
|
|
1003
|
+
// value, or tightening the current one, takes no more than writing.
|
|
1004
|
+
needs: async (db) => {
|
|
1005
|
+
const restored = await store.restoredNatures(db, items.map(({ secret }) => ({ secretId: secret.secretId, from: sources.get(secret)!.id })));
|
|
1006
|
+
return restored.map((nature) => (nature === 'secret' ? ['secret.read'] as const : nature === 'sensitive' ? ['secret.use'] as const : null));
|
|
1007
|
+
},
|
|
1008
|
+
},
|
|
923
1009
|
(contextOf) =>
|
|
924
1010
|
items.map(({ secret, wrapped }) => async (operation: KeyOperation) => {
|
|
925
|
-
|
|
1011
|
+
// Opened as the source was written, sensitive or not, and wrapped as the new version is.
|
|
1012
|
+
const key = await this.#open(wrapped, contextOf(sources.get(secret)!.secret), operation);
|
|
926
1013
|
if (key === null) return null;
|
|
927
1014
|
try {
|
|
928
1015
|
return { wrapped: serialisable(await this.#config.keks.wrap(key, contextOf(secret), operation)), wipe: () => {} };
|
|
@@ -930,7 +1017,7 @@ class VaultService implements Vault {
|
|
|
930
1017
|
key.fill(0);
|
|
931
1018
|
}
|
|
932
1019
|
}),
|
|
933
|
-
(done) => ({ wrapped: done.map(({ wrapped }) => wrapped), seqs: [] as number[] }),
|
|
1020
|
+
(done) => ({ wrapped: done.map(({ wrapped }) => wrapped), seqs: [] as number[], sensitive: items.map(({ secret }) => secret.sensitive === true) }),
|
|
934
1021
|
);
|
|
935
1022
|
}
|
|
936
1023
|
|
|
@@ -968,9 +1055,9 @@ class VaultService implements Vault {
|
|
|
968
1055
|
return { ok: true, versions: ids.map((id) => found.get(id)!) };
|
|
969
1056
|
}
|
|
970
1057
|
|
|
971
|
-
async #refuseVersions(principal: string, code: 'bad_claim' | 'deleted', entries: NewEntry[]): Promise<Outcome<never>> {
|
|
1058
|
+
async #refuseVersions(principal: string, code: 'bad_claim' | 'deleted' | 'invalid', entries: NewEntry[], message = MESSAGES[code]): Promise<Outcome<never>> {
|
|
972
1059
|
return this.#decide([principal], async () => {
|
|
973
|
-
throw new Refused(refusal(code,
|
|
1060
|
+
throw new Refused(refusal(code, message), entries);
|
|
974
1061
|
});
|
|
975
1062
|
}
|
|
976
1063
|
|
|
@@ -1013,9 +1100,14 @@ class VaultService implements Vault {
|
|
|
1013
1100
|
* once the log's head is locked, before its entries append.
|
|
1014
1101
|
*/
|
|
1015
1102
|
vet?: (db: Queryable) => Promise<(RefusalCode | null)[]>;
|
|
1103
|
+
/**
|
|
1104
|
+
* Any one of which each secret takes besides `permission`, where it
|
|
1105
|
+
* is, read in the decision and again under the head; none for null.
|
|
1106
|
+
*/
|
|
1107
|
+
needs?: (db: Queryable) => Promise<(readonly Permission[] | null)[]>;
|
|
1016
1108
|
remote: boolean;
|
|
1017
1109
|
entry: (secret: SecretRef, decision: 'allow' | 'deny', code: string | null) => NewEntry;
|
|
1018
|
-
input: Correlation & Through & { purpose?: string };
|
|
1110
|
+
input: Correlation & Through & { purpose?: string; keepsSensitive?: boolean };
|
|
1019
1111
|
},
|
|
1020
1112
|
/**
|
|
1021
1113
|
* One per secret; null for a bad claim, an error for an outage or an
|
|
@@ -1048,17 +1140,35 @@ class VaultService implements Vault {
|
|
|
1048
1140
|
const gone = (place: SecretPlace) => environments.get(place.environmentId)?.deleted ?? false;
|
|
1049
1141
|
owners.clear();
|
|
1050
1142
|
for (const [id, environment] of environments) if (environment.owner !== null) owners.set(id, ownerKey(environment.owner));
|
|
1143
|
+
const needs = call.needs === undefined ? [] : await call.needs(d.tx);
|
|
1051
1144
|
const codes: (RefusalCode | null)[] = secrets.map((secret, i) => {
|
|
1052
1145
|
const personal = personalRefusal(reader, principal, call.permission, secret, at[i]!, environments);
|
|
1053
1146
|
if (personal !== undefined) return personal;
|
|
1147
|
+
const also = needs[i];
|
|
1148
|
+
if (also != null) {
|
|
1149
|
+
const place = placeIn(at[i]!, environments);
|
|
1150
|
+
const refused = also.map((permission) => refuses(reader, permission, place, gone(secret) || gone(at[i]!)));
|
|
1151
|
+
if (refused.every((code) => code !== null)) return refused[0]!;
|
|
1152
|
+
}
|
|
1153
|
+
// Through a reference, the holder's grants decide, its source's never: a reference takes its value and nature
|
|
1154
|
+
// (a sensitive one stays use-only) from its source, and its permissions from where it lives. Its maker held what
|
|
1155
|
+
// the source's nature asks when it was made (`reference`).
|
|
1054
1156
|
return refuses(reader, call.permission, placeIn(at[i]!, environments), gone(secret) || gone(at[i]!));
|
|
1055
1157
|
});
|
|
1056
1158
|
if (call.vet !== undefined && !codes.some((code) => code !== null)) {
|
|
1057
1159
|
(await call.vet(d.tx)).forEach((code, i) => (codes[i] = code));
|
|
1058
1160
|
}
|
|
1161
|
+
// A sensitive value is only ever used for a person, never handed back, by whatever credential of theirs asks. And
|
|
1162
|
+
// a fork copies one, for anyone, a service account too, only where its app says it keeps it sensitive: an app of
|
|
1163
|
+
// 0.9, mid-deploy, would write the copy as a secret its people read.
|
|
1164
|
+
const copying = action === 'secret.read' && call.input.purpose === 'copy';
|
|
1165
|
+
const handedBack = action === 'secret.read' && principal.startsWith('user:') && handsBack({ purpose: call.input.purpose ?? '', keepsSensitive: call.input.keepsSensitive === true });
|
|
1166
|
+
if (handedBack || (copying && call.input.keepsSensitive !== true)) {
|
|
1167
|
+
secrets.forEach((secret, i) => {
|
|
1168
|
+
if (codes[i] === null && secret.sensitive === true) codes[i] = 'sensitive';
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1059
1171
|
let first = codes.find((code) => code !== null) ?? null;
|
|
1060
|
-
// A person's values are only ever used for them, where people get stand-ins: by whatever credential of theirs asks.
|
|
1061
|
-
if (first === null && action === 'secret.read' && this.#config.people === 'stand-ins' && principal.startsWith('user:') && HANDED_BACK.includes(call.input.purpose as Purpose)) first = 'stand_ins';
|
|
1062
1172
|
if (first === null && action === 'secret.read' && (await this.#overBulkLimit(d, principal, secrets.length))) first = 'bulk_limit';
|
|
1063
1173
|
if (first !== null) {
|
|
1064
1174
|
throw new Refused(
|
|
@@ -1087,7 +1197,6 @@ class VaultService implements Vault {
|
|
|
1087
1197
|
if (call.remote) {
|
|
1088
1198
|
const intent = await this.#decide([principal], async (d) => {
|
|
1089
1199
|
await check(d);
|
|
1090
|
-
await lockLogHead(d.tx);
|
|
1091
1200
|
const at = await this.#now(d.tx);
|
|
1092
1201
|
const appended = await this.#append(d.tx, [{
|
|
1093
1202
|
actor: principal,
|
|
@@ -1111,7 +1220,12 @@ class VaultService implements Vault {
|
|
|
1111
1220
|
intentSeq = intent.seq;
|
|
1112
1221
|
}
|
|
1113
1222
|
|
|
1114
|
-
|
|
1223
|
+
// Under the reader's row: the check, the key operations, and their outcome. With a key in memory, under the
|
|
1224
|
+
// log's head too, from the start (`#decideOnce`): what the check read stands until the commit, as every change it
|
|
1225
|
+
// reads appends under the head, and the head waits on nothing slow. With a key service, which nothing may wait on
|
|
1226
|
+
// holding the head, the head is taken once the keys are open, and what the check read is read again under it.
|
|
1227
|
+
const late = call.remote;
|
|
1228
|
+
const decided = async (d: Decision) => {
|
|
1115
1229
|
try {
|
|
1116
1230
|
await check(d);
|
|
1117
1231
|
} catch (error) {
|
|
@@ -1121,9 +1235,12 @@ class VaultService implements Vault {
|
|
|
1121
1235
|
}
|
|
1122
1236
|
throw error;
|
|
1123
1237
|
}
|
|
1238
|
+
};
|
|
1239
|
+
return this.#decide([principal], async (d) => {
|
|
1240
|
+
await decided(d);
|
|
1124
1241
|
// A reference ended while this decided refuses it, rather than a read logged after its end.
|
|
1125
1242
|
const { vet } = call;
|
|
1126
|
-
if (vet !== undefined) {
|
|
1243
|
+
if (late && vet !== undefined) {
|
|
1127
1244
|
d.underLock.push(async (locked) => {
|
|
1128
1245
|
const codes = await vet(locked);
|
|
1129
1246
|
const first = codes.find((code) => code !== null);
|
|
@@ -1150,15 +1267,20 @@ class VaultService implements Vault {
|
|
|
1150
1267
|
if (done.length < outcomes.length) {
|
|
1151
1268
|
throw new Refused(refusal('bad_claim', MESSAGES.bad_claim), entries());
|
|
1152
1269
|
}
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1270
|
+
if (late) {
|
|
1271
|
+
// Again under the log's head, the append's, taken a statement early: a
|
|
1272
|
+
// deletion renames its place under it, so one that committed since the
|
|
1273
|
+
// check above is seen here, and nothing is released after it.
|
|
1274
|
+
// A read through a reference: its holder's place too, which a deletion may have taken.
|
|
1275
|
+
await lockLogHead(d.tx);
|
|
1276
|
+
// And as the policy is now, under the head every change to it takes: an environment protected, or a role narrowed,
|
|
1277
|
+
// since the check above is seen here, and nothing it refuses now is released.
|
|
1278
|
+
await decided(d);
|
|
1279
|
+
const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
|
|
1280
|
+
const now = await store.environmentsById(d.tx, places.map((place) => place.environmentId));
|
|
1281
|
+
if (places.some((place) => now.get(place.environmentId)?.deleted ?? false)) {
|
|
1282
|
+
throw new Refused(refusal('deleted', MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, 'deny', 'deleted')));
|
|
1283
|
+
}
|
|
1162
1284
|
}
|
|
1163
1285
|
const released = secrets.map((secret, i) => outcomeEntry(secret, i, 'allow', null));
|
|
1164
1286
|
d.log.push(...released);
|
|
@@ -1169,7 +1291,7 @@ class VaultService implements Vault {
|
|
|
1169
1291
|
} finally {
|
|
1170
1292
|
for (const value of done) value.wipe();
|
|
1171
1293
|
}
|
|
1172
|
-
});
|
|
1294
|
+
}, late);
|
|
1173
1295
|
}
|
|
1174
1296
|
|
|
1175
1297
|
/** Whether `n` more keys would take `principal` past the bulk limit; counted under their row's lock, so exactly. */
|
|
@@ -1225,7 +1347,11 @@ class VaultService implements Vault {
|
|
|
1225
1347
|
};
|
|
1226
1348
|
};
|
|
1227
1349
|
return this.#decide([principal], async (d) => {
|
|
1350
|
+
// Under the log's head, which a deletion and every change to the policy take: neither side deleted, nor protected,
|
|
1351
|
+
// nor a role narrowed, before this commits.
|
|
1228
1352
|
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
|
|
1353
|
+
// Each source's nature, its current version's, under the head: what its maker must hold there.
|
|
1354
|
+
const natures = await store.natures(d.tx, [...sources.values()].map((source) => source.secretId));
|
|
1229
1355
|
// Both sides as the store has them: a grant on a slug matches there, and neither side may be deleted.
|
|
1230
1356
|
const environments = await store.environmentsById(d.tx, items.flatMap((item) => {
|
|
1231
1357
|
const source = sources.get(item.source.secretId);
|
|
@@ -1238,17 +1364,15 @@ class VaultService implements Vault {
|
|
|
1238
1364
|
if (source.secretId === item.holder.secretId) return 'invalid';
|
|
1239
1365
|
// A personal config's values are bound to its owner: no reference holds one, nor reads one.
|
|
1240
1366
|
if (environments.get(item.holder.environmentId)?.owner != null || environments.get(source.environmentId)?.owner != null) return 'personal';
|
|
1367
|
+
// Making one hands its source's value to whoever reads the holder, whatever its nature becomes: its maker reads
|
|
1368
|
+
// it at the source, as any direct access there, and, for a sensitive one, which nobody reads, uses it there too.
|
|
1369
|
+
const at = placeIn(source, environments);
|
|
1241
1370
|
return refuses(reader, 'secret.write', placeIn(item.holder, environments), gone(item.holder))
|
|
1242
|
-
?? refuses(reader, 'secret.read',
|
|
1371
|
+
?? refuses(reader, 'secret.read', at, gone(source))
|
|
1372
|
+
?? (natures.get(source.secretId) === 'sensitive' ? refuses(reader, 'secret.use', at, gone(source)) : null);
|
|
1243
1373
|
});
|
|
1244
1374
|
const first = codes.find((code) => code !== null) ?? null;
|
|
1245
1375
|
if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), items.map((item, i) => entry(item, 'deny', codes[i] ?? first)));
|
|
1246
|
-
// Again under the log's head, as a read is: neither side was deleted since the check above.
|
|
1247
|
-
d.underLock.push(async (locked) => {
|
|
1248
|
-
const now = await store.environmentsById(locked, [...environments.keys()]);
|
|
1249
|
-
if (![...now.values()].some((environment) => environment.deleted)) return;
|
|
1250
|
-
throw new Refused(refusal('deleted', MESSAGES.deleted), items.map((item) => entry(item, 'deny', 'deleted')));
|
|
1251
|
-
});
|
|
1252
1376
|
const entries = items.map((item) => entry(item, 'allow', null));
|
|
1253
1377
|
d.log.push(...entries);
|
|
1254
1378
|
const answer = { seqs: [] as number[] };
|
|
@@ -1311,6 +1435,7 @@ class VaultService implements Vault {
|
|
|
1311
1435
|
return seals.map((seal) => (done.has(seal.seq) ? ('ended' as const) : null));
|
|
1312
1436
|
};
|
|
1313
1437
|
return this.#decide([principal], async (d) => {
|
|
1438
|
+
// Under the log's head: two ends of one reference at once, the second finds the first; and the actor's standing stands.
|
|
1314
1439
|
const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
|
|
1315
1440
|
// Ending is allowed in a deleted place: deleting one ends the references into and out of it.
|
|
1316
1441
|
const environments = await store.environmentsById(d.tx, seals.map((seal) => seal.holder.environmentId));
|
|
@@ -1326,11 +1451,6 @@ class VaultService implements Vault {
|
|
|
1326
1451
|
if (!codes.some((code) => code !== null)) (await ended(d.tx)).forEach((code, i) => (codes[i] = code));
|
|
1327
1452
|
const first = codes.find((code) => code !== null) ?? null;
|
|
1328
1453
|
if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), vias.map((via, i) => entry(via, seals[i]!, 'deny', codes[i] ?? first)));
|
|
1329
|
-
// Two ends of one reference at once: the second finds the first under the head's lock.
|
|
1330
|
-
d.underLock.push(async (locked) => {
|
|
1331
|
-
if ((await ended(locked)).every((code) => code === null)) return;
|
|
1332
|
-
throw new Refused(refusal('ended', MESSAGES.ended), vias.map((via, i) => entry(via, seals[i]!, 'deny', 'ended')));
|
|
1333
|
-
});
|
|
1334
1454
|
const entries = vias.map((via, i) => entry(via, seals[i]!, 'allow', null));
|
|
1335
1455
|
d.log.push(...entries);
|
|
1336
1456
|
const answer = { seqs: [] as number[] };
|
|
@@ -1412,11 +1532,11 @@ class VaultService implements Vault {
|
|
|
1412
1532
|
/**
|
|
1413
1533
|
* Refuse a change that would leave `principal`, an Owner of the whole
|
|
1414
1534
|
* instance, no longer one, unless another is. Counted under the log's
|
|
1415
|
-
* head: two changes at once, each demoting or
|
|
1416
|
-
* Owner, lock different rows, and only the head puts
|
|
1535
|
+
* head, which every decision holds: two changes at once, each demoting or
|
|
1536
|
+
* removing a different Owner, lock different rows, and only the head puts
|
|
1537
|
+
* one after the other.
|
|
1417
1538
|
*/
|
|
1418
1539
|
async #keepAnOwner(d: Decision, actor: string, principal: string, refused: (code: RefusalCode, message?: string) => Refused): Promise<void> {
|
|
1419
|
-
await lockLogHead(d.tx);
|
|
1420
1540
|
if ((await this.#anOwner(d.tx, d.reports, principal)) !== null) return;
|
|
1421
1541
|
throw refused('last_owner', actor === principal
|
|
1422
1542
|
? "you're the last Owner of the whole instance: make someone else an Owner first"
|
|
@@ -1473,17 +1593,20 @@ class VaultService implements Vault {
|
|
|
1473
1593
|
if (row?.status !== 'active') return { principal, status: row?.status ?? 'unknown', live: none, all: none, fault, stored: grants };
|
|
1474
1594
|
const held = grants.map((grant) => ({ ...grant, role: grant.role as Role }));
|
|
1475
1595
|
const { role, scope } = roleOf(principal, row);
|
|
1596
|
+
// Every decision about them reads the policy: their roles as an Owner edited them, and which environments are protected.
|
|
1597
|
+
const { policy } = await this.#policyAt(db, reports, at);
|
|
1598
|
+
const service = !principal.startsWith('user:');
|
|
1476
1599
|
return {
|
|
1477
1600
|
principal,
|
|
1478
1601
|
status: 'active',
|
|
1479
|
-
live: { role, scope, grants: held.filter((grant) => live(grant, at)) },
|
|
1480
|
-
all: { role, scope, grants: held },
|
|
1602
|
+
live: { role, scope, grants: held.filter((grant) => live(grant, at)), policy, service },
|
|
1603
|
+
all: { role, scope, grants: held, policy, service },
|
|
1481
1604
|
fault,
|
|
1482
1605
|
stored: grants,
|
|
1483
1606
|
};
|
|
1484
1607
|
}
|
|
1485
1608
|
|
|
1486
|
-
#access(principal: string, row: Member | undefined, held: readonly GrantRow[], at: number, tampered: boolean): Access {
|
|
1609
|
+
#access(principal: string, row: Member | undefined, held: readonly GrantRow[], at: number, tampered: boolean, policy: Policy): Access {
|
|
1487
1610
|
const active = PRINCIPAL.test(principal) && !tampered && row?.status === 'active';
|
|
1488
1611
|
return {
|
|
1489
1612
|
principal,
|
|
@@ -1493,6 +1616,7 @@ class VaultService implements Vault {
|
|
|
1493
1616
|
grants: active ? held.filter((grant) => live(grant, at) && grantKind(grant) !== null).map(view) : [],
|
|
1494
1617
|
since: row ? iso(row.statusChangedAt) : null,
|
|
1495
1618
|
by: row?.statusChangedBy ?? null,
|
|
1619
|
+
policy,
|
|
1496
1620
|
};
|
|
1497
1621
|
}
|
|
1498
1622
|
|
|
@@ -1520,7 +1644,8 @@ class VaultService implements Vault {
|
|
|
1520
1644
|
const read = async (db: Queryable, reports: NewEntry[]) => {
|
|
1521
1645
|
const [row, held, at] = await Promise.all([store.member(db, principal), store.grants(db, principal), this.#now(db)]);
|
|
1522
1646
|
const fault = await this.#integrity(db, principal, row, held, reports);
|
|
1523
|
-
|
|
1647
|
+
const { policy } = await this.#policyAt(db, reports, at);
|
|
1648
|
+
return this.#access(principal, row, held, at, fault !== null, policy);
|
|
1524
1649
|
};
|
|
1525
1650
|
const found: NewEntry[] = [];
|
|
1526
1651
|
const quick = await read(this.#db, found).catch((error: unknown) => {
|
|
@@ -1588,10 +1713,7 @@ class VaultService implements Vault {
|
|
|
1588
1713
|
const key = placeKey(change);
|
|
1589
1714
|
if (places.has(key)) throw refused('invalid', 'each place may be changed once per call');
|
|
1590
1715
|
places.add(key);
|
|
1591
|
-
if (change.role !== null &&
|
|
1592
|
-
if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role)) {
|
|
1593
|
-
throw refused('invalid', `${change.role} can only be granted on a project`);
|
|
1594
|
-
}
|
|
1716
|
+
if (change.role !== null && typeof change.role !== 'string') throw refused('invalid', `no such role: ${String(change.role)}`);
|
|
1595
1717
|
const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
|
|
1596
1718
|
if (Number.isNaN(expiresAt) || (expiresAt !== null && expiresAt <= d.at)) {
|
|
1597
1719
|
throw refused('invalid', 'an end date must be in the future');
|
|
@@ -1600,7 +1722,15 @@ class VaultService implements Vault {
|
|
|
1600
1722
|
// A project or an environment must be there, and to be granted, not
|
|
1601
1723
|
// deleted. Read under the log's head, which a deletion holds while it
|
|
1602
1724
|
// renames the place, so the place is as it commits.
|
|
1603
|
-
|
|
1725
|
+
// The roles as the policy defines them now, under the head every change to it takes: a role an Owner made is
|
|
1726
|
+
// granted as one that ships, and one deleted meanwhile is granted by nobody.
|
|
1727
|
+
const { policy } = await this.#policyAt(d.tx, d.reports, d.at);
|
|
1728
|
+
for (const change of changes) {
|
|
1729
|
+
if (change.role !== null && !isRole(change.role, policy)) throw refused('invalid', `no such role: ${change.role}`);
|
|
1730
|
+
if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role, policy)) {
|
|
1731
|
+
throw refused('invalid', `${change.role} can only be granted on a project`);
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1604
1734
|
const known = await store.places(
|
|
1605
1735
|
d.tx,
|
|
1606
1736
|
changes.map((change) => change.projectId!),
|
|
@@ -1922,7 +2052,7 @@ class VaultService implements Vault {
|
|
|
1922
2052
|
*/
|
|
1923
2053
|
async #settingsAt(db: Queryable, reports: NewEntry[]): Promise<Settings> {
|
|
1924
2054
|
const entry = await newestAuthentic(
|
|
1925
|
-
(before) => store.newestVaultEntries(db, SETTINGS,
|
|
2055
|
+
(limit, before) => store.newestVaultEntries(db, SETTINGS, limit, before),
|
|
1926
2056
|
this.#check(reports, null),
|
|
1927
2057
|
);
|
|
1928
2058
|
if (entry === 'none') return DEFAULT_SETTINGS;
|
|
@@ -1931,6 +2061,143 @@ class VaultService implements Vault {
|
|
|
1931
2061
|
return isScope(serviceAccounts) ? { serviceAccounts } : CLOSED_SETTINGS;
|
|
1932
2062
|
}
|
|
1933
2063
|
|
|
2064
|
+
// --- the instance's access policy -------------------------------------------
|
|
2065
|
+
|
|
2066
|
+
async policy(): Promise<Policy> {
|
|
2067
|
+
await this.#settled();
|
|
2068
|
+
const reports: NewEntry[] = [];
|
|
2069
|
+
const { policy } = await this.#policyAt(this.#db, reports, await this.#now(this.#db));
|
|
2070
|
+
await this.#record(reports);
|
|
2071
|
+
return policy;
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
/**
|
|
2075
|
+
* The policy the vault's newest `policy.change` says, by its MAC, and its
|
|
2076
|
+
* edits, as of `at`: one in its name it did not write is passed over, and
|
|
2077
|
+
* reported. With such entries and none of its own within reach, the
|
|
2078
|
+
* policy is unreadable, and closed: every environment is protected, and
|
|
2079
|
+
* no role holds anything but Owner, otherwise whoever can insert rows
|
|
2080
|
+
* could unprotect them. Its edits are then unknown, not none.
|
|
2081
|
+
*/
|
|
2082
|
+
async #policyAt(db: Queryable, reports: NewEntry[], at: number): Promise<{ policy: Policy; edits: PolicyEdits; readable: boolean }> {
|
|
2083
|
+
const entry = await newestAuthentic(
|
|
2084
|
+
(limit, before) => store.newestVaultEntries(db, POLICY, limit, before),
|
|
2085
|
+
this.#check(reports, null),
|
|
2086
|
+
);
|
|
2087
|
+
if (entry === 'none') return { policy: DEFAULT_POLICY, edits: NO_EDITS, readable: true };
|
|
2088
|
+
const edits = typeof entry === 'string' ? null : readEdits(JSON.parse(entry.metadata) as { policy?: unknown });
|
|
2089
|
+
return edits === null ? { policy: CLOSED_POLICY, edits: NO_EDITS, readable: false } : { policy: await livePolicy(db, edits, at), edits, readable: true };
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
changePolicy(input: ChangePolicyInput): Promise<Outcome<{ policy: Policy; seq: number | null }>> {
|
|
2093
|
+
const { actor, change, revision } = input;
|
|
2094
|
+
if (revision !== undefined && (typeof revision !== 'string' || !POLICY_REVISION.test(revision))) throw new Error('revision names a policy as policyRevision writes it');
|
|
2095
|
+
// Where a protection is, once its rows are found: its project, and its one environment if that has a row yet. A fork's,
|
|
2096
|
+
// protected before its row is written, is named by the change alone, as the log's columns name only rows.
|
|
2097
|
+
const at: { projectId?: string; environmentId?: string } = {};
|
|
2098
|
+
const entry = (decision: 'allow' | 'deny', detail: Record<string, unknown>, code: RefusalCode | null = null): NewEntry => ({
|
|
2099
|
+
actor,
|
|
2100
|
+
action: POLICY,
|
|
2101
|
+
decision,
|
|
2102
|
+
code,
|
|
2103
|
+
operationId: input.operationId ?? null,
|
|
2104
|
+
requestId: input.requestId ?? null,
|
|
2105
|
+
...at,
|
|
2106
|
+
metadata: JSON.stringify({ ...detail, ...traced(input) }),
|
|
2107
|
+
});
|
|
2108
|
+
const refused = (code: RefusalCode, message = MESSAGES[code]) =>
|
|
2109
|
+
new Refused(refusal(code, message), [entry('deny', { change: describeChange(change) }, code)]);
|
|
2110
|
+
return this.#decide([actor], async (d) => {
|
|
2111
|
+
validateCorrelation(input);
|
|
2112
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
2113
|
+
if (acting.status === 'tampered') throw refused('tampered');
|
|
2114
|
+
if (acting.status !== 'active') throw refused(acting.status === 'removed' ? 'removed' : 'not_a_member');
|
|
2115
|
+
// Under the log's head, as the actor's standing above: two changes at once each start from the one before. One the
|
|
2116
|
+
// caller weighed from a revision of the policy, its newest change and its protected environments' rows, starts
|
|
2117
|
+
// from that one, or is refused, to be asked again: a protected prod renamed since is a policy changed too.
|
|
2118
|
+
const newest = await newestVaultSeq(d.tx, POLICY);
|
|
2119
|
+
const { policy: before, edits, readable } = await this.#policyAt(d.tx, d.reports, d.at);
|
|
2120
|
+
if (revision !== undefined && policyRevision(newest, before) !== revision) throw refused('policy_changed');
|
|
2121
|
+
const owner = acting.live.credentialScope == null && ownsInstance(acting.live);
|
|
2122
|
+
// Unreadable, its edits are unknown: a change made from none would erase every protection and role edit the forged
|
|
2123
|
+
// rows hide. An Owner of the whole instance alone writes it again, from the roles as they ship and nothing protected.
|
|
2124
|
+
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");
|
|
2125
|
+
// Those the policy no longer holds stay out: a deleted environment's id, and an unwritten fork's past its window,
|
|
2126
|
+
// neither of which comes back, the window being one a fork writes its row well within.
|
|
2127
|
+
const next: PolicyEdits = {
|
|
2128
|
+
roles: { ...edits.roles },
|
|
2129
|
+
instanceRoles: { ...edits.instanceRoles },
|
|
2130
|
+
protected: edits.protected.filter((place) => before.protected.includes(place.environmentId)).map((place) => ({ ...place })),
|
|
2131
|
+
};
|
|
2132
|
+
if (change?.kind === 'role' || change?.kind === 'instance-role') {
|
|
2133
|
+
// Roles are the whole instance's: an Owner of all of it alone changes them (R1, R15), never through a scoped credential.
|
|
2134
|
+
if (!owner) throw refused('not_allowed', 'only an Owner of the whole instance edits roles');
|
|
2135
|
+
const instance = change.kind === 'instance-role';
|
|
2136
|
+
const fixed = instance ? FIXED_ROLES.instanceRoles.includes(change.role as InstanceRole) : FIXED_ROLES.roles.includes(change.role);
|
|
2137
|
+
if (typeof change.role !== 'string' || fixed) throw refused('invalid', `${change.role} is not edited: it holds ${change.role === 'member' ? 'nothing' : 'everything'}, always`);
|
|
2138
|
+
if (instance && !isInstanceRole(change.role)) throw refused('invalid', `no instance role ${change.role}: an Owner makes project roles`);
|
|
2139
|
+
if (!instance && !isBuiltinRole(change.role) && !ROLE_ID.test(change.role)) throw refused('invalid', 'a role is named by lowercase letters, digits and dashes, 2 to 32, starting with a letter');
|
|
2140
|
+
const table = instance ? next.instanceRoles as Record<string, RoleDefinition> : next.roles;
|
|
2141
|
+
if (change.definition === null) {
|
|
2142
|
+
// Put back as it ships, or, one an Owner made, gone: while no grant names it.
|
|
2143
|
+
if (!instance && !isBuiltinRole(change.role)) {
|
|
2144
|
+
if (table[change.role] === undefined) throw refused('invalid', `no role ${change.role}`);
|
|
2145
|
+
const held = await store.grantsNaming(d.tx, change.role);
|
|
2146
|
+
if (held > 0) throw refused('invalid', `${held === 1 ? '1 grant names' : `${held} grants name`} ${change.role}: revoke ${held === 1 ? 'it' : 'them'} first`);
|
|
2147
|
+
}
|
|
2148
|
+
// What it ships as is held to the same rule as any edit: a project-wide permission back on a role granted on environments.
|
|
2149
|
+
const shipped = instance ? undefined : isBuiltinRole(change.role) ? ROLES[change.role] : undefined;
|
|
2150
|
+
if (shipped !== undefined && shipped.permissions.some((permission) => PROJECT_ONLY_PERMISSIONS.includes(permission)) && (await store.grantsNaming(d.tx, change.role, { onEnvironments: true })) > 0) {
|
|
2151
|
+
throw refused('invalid', `${change.role} is granted on environments: as it ships, it holds a project-wide permission, which would make those grants incoherent`);
|
|
2152
|
+
}
|
|
2153
|
+
delete table[change.role];
|
|
2154
|
+
} else {
|
|
2155
|
+
const problem = definitionProblem(change.definition);
|
|
2156
|
+
if (problem !== null) throw refused('invalid', problem);
|
|
2157
|
+
const definition = normalDefinition(change.definition);
|
|
2158
|
+
// A project role granted on an environment keeps no project-wide permission while any grant there names it.
|
|
2159
|
+
if (!instance && definition.permissions.some((permission) => PROJECT_ONLY_PERMISSIONS.includes(permission)) && (await store.grantsNaming(d.tx, change.role, { onEnvironments: true })) > 0) {
|
|
2160
|
+
throw refused('invalid', `${change.role} is granted on environments: a project-wide permission would make those grants incoherent`);
|
|
2161
|
+
}
|
|
2162
|
+
const shipped = instance ? INSTANCE_ROLES[change.role as InstanceRole] : isBuiltinRole(change.role) ? ROLES[change.role] : undefined;
|
|
2163
|
+
if (shipped !== undefined && JSON.stringify(normalDefinition(shipped)) === JSON.stringify(definition)) delete table[change.role];
|
|
2164
|
+
else table[change.role] = definition;
|
|
2165
|
+
}
|
|
2166
|
+
} else if (change?.kind === 'protect') {
|
|
2167
|
+
if (typeof change.projectId !== 'string' || !UUID.test(change.projectId) || !Array.isArray(change.environmentIds) || change.environmentIds.length === 0
|
|
2168
|
+
|| change.environmentIds.some((id) => typeof id !== 'string' || !UUID.test(id)) || typeof change.protected !== 'boolean') {
|
|
2169
|
+
throw refused('invalid', 'protection names a project and some of its environments, by id, and whether they are protected');
|
|
2170
|
+
}
|
|
2171
|
+
if (!(await store.projectLive(d.tx, change.projectId))) throw refused('invalid', 'no such project');
|
|
2172
|
+
at.projectId = change.projectId;
|
|
2173
|
+
// Each a live environment of the project, or none yet: a fork's id, protected before its row is written.
|
|
2174
|
+
const found = await store.environmentsById(d.tx, change.environmentIds);
|
|
2175
|
+
if ([...found.values()].some((environment) => environment.projectId !== change.projectId)) throw refused('invalid', 'each environment is one of the project\'s');
|
|
2176
|
+
if (change.environmentIds.length === 1 && found.has(change.environmentIds[0]!)) at.environmentId = change.environmentIds[0];
|
|
2177
|
+
if (change.protected) {
|
|
2178
|
+
if (!allows(acting.live, 'environment.manage', { projectId: change.projectId })) throw refused('not_allowed', 'protecting an environment takes managing its project\'s environments');
|
|
2179
|
+
const known = new Set(next.protected.map((place) => place.environmentId));
|
|
2180
|
+
next.protected = [...next.protected, ...change.environmentIds.filter((id) => !known.has(id)).map((environmentId) => ({ projectId: change.projectId, environmentId, at: d.at }))]
|
|
2181
|
+
.sort((a, b) => (a.environmentId < b.environmentId ? -1 : 1));
|
|
2182
|
+
} else {
|
|
2183
|
+
// Unprotecting widens what people hold there: an Owner of the whole instance's to do (R15).
|
|
2184
|
+
if (!owner) throw refused('not_allowed', 'only an Owner of the whole instance unprotects an environment');
|
|
2185
|
+
next.protected = next.protected.filter((place) => !change.environmentIds.includes(place.environmentId));
|
|
2186
|
+
}
|
|
2187
|
+
} else {
|
|
2188
|
+
throw refused('invalid', 'a change is to a role, an instance role, or protection');
|
|
2189
|
+
}
|
|
2190
|
+
// The policy now, and the newest change, which it is as of: this one's, when it changed anything.
|
|
2191
|
+
const answer = { policy: await livePolicy(d.tx, next, d.at), seq: newest === null ? null : Number(newest) };
|
|
2192
|
+
if (!readable || JSON.stringify(next) !== JSON.stringify(edits)) {
|
|
2193
|
+
const made = entry('allow', { policy: next, change: describeChange(change), before: describeBefore(change, before) });
|
|
2194
|
+
d.log.push(made);
|
|
2195
|
+
d.after.push((seqOf) => (answer.seq = seqOf(made)));
|
|
2196
|
+
}
|
|
2197
|
+
return answer;
|
|
2198
|
+
});
|
|
2199
|
+
}
|
|
2200
|
+
|
|
1934
2201
|
setSettings(input: SetSettingsInput): Promise<Outcome<{ settings: Settings }>> {
|
|
1935
2202
|
const { actor } = input;
|
|
1936
2203
|
const entry = (decision: 'allow' | 'deny', detail: Record<string, unknown>, code: RefusalCode | null = null): NewEntry => ({
|
|
@@ -1951,7 +2218,6 @@ class VaultService implements Vault {
|
|
|
1951
2218
|
if (!runsInstance(acting.live)) throw refused('not_allowed', "only admins and owners of the whole instance change its settings");
|
|
1952
2219
|
if (!isScope(input.settings?.serviceAccounts)) throw refused('invalid', 'who sets up service accounts is a scope: { projects, environments }, each all, only or except');
|
|
1953
2220
|
// Under the log's head, so two changes at once each name the one before.
|
|
1954
|
-
await lockLogHead(d.tx);
|
|
1955
2221
|
const previous = await this.#settingsAt(d.tx, d.reports);
|
|
1956
2222
|
const settings: Settings = { serviceAccounts: normalScope(input.settings.serviceAccounts) };
|
|
1957
2223
|
if (JSON.stringify(settings) !== JSON.stringify(previous)) d.log.push(entry('allow', { ...settings, previous }));
|
|
@@ -2097,73 +2363,137 @@ class VaultService implements Vault {
|
|
|
2097
2363
|
|
|
2098
2364
|
// --- checkpoints and the log --------------------------------------------------
|
|
2099
2365
|
|
|
2100
|
-
/**
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2366
|
+
/**
|
|
2367
|
+
* Where a walk of the log resumes: the first entry (`start`), a
|
|
2368
|
+
* checkpoint's entry that keeps a tally, or the newest such entry, the
|
|
2369
|
+
* first entry while there is none. The entry must be the vault's, by its
|
|
2370
|
+
* MAC, and its tally whole: the members and grants it keeps, or the
|
|
2371
|
+
* earlier checkpoint it names keeps, match the digest it carries.
|
|
2372
|
+
*
|
|
2373
|
+
* Its signature is left to the walk: the entry comes after the prefix it
|
|
2374
|
+
* signs, so a walk from there reads it, as it reads every other.
|
|
2375
|
+
*/
|
|
2376
|
+
async #resume(db: Queryable, from: 'start' | 'newest' | bigint): Promise<Resumed | Broken> {
|
|
2377
|
+
if (from === 'start') return FIRST_ENTRY;
|
|
2378
|
+
const entry = from === 'newest' ? await store.latestTallied(db) : (await store.entriesAt(db, [from])).get(from);
|
|
2379
|
+
if (entry === undefined) return from === 'newest' ? FIRST_ENTRY : { ok: false, failedAtSeq: Number(from), reason: 'no checkpoint at this entry' };
|
|
2380
|
+
return (await this.#resumeEach(db, [entry]))[0]!;
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
/** `#resume` for each of these checkpoint entries, the entries their members and grants are kept in read at once. */
|
|
2384
|
+
async #resumeEach(db: Queryable, entries: readonly StoredEntry[]): Promise<(Resumed | Broken)[]> {
|
|
2385
|
+
const reads = entries.map((entry) => this.#tallied(entry));
|
|
2386
|
+
const named = reads.flatMap((read) => (typeof read !== 'string' && 'at' in read.tally.access ? [BigInt(read.tally.access.at)] : []));
|
|
2387
|
+
const carriers = new Map([...(await store.entriesAt(db, named))].map(([seq, held]) => [seq, this.#tallied(held)]));
|
|
2388
|
+
return entries.map((entry, i): Resumed | Broken => {
|
|
2389
|
+
const broken = (reason: string): Broken => ({ ok: false, failedAtSeq: Number(entry.seq), reason });
|
|
2390
|
+
const read = reads[i]!;
|
|
2391
|
+
if (typeof read === 'string') return broken(read);
|
|
2392
|
+
let carrier = entry.seq;
|
|
2393
|
+
let { access } = read.tally;
|
|
2394
|
+
if ('at' in access) {
|
|
2395
|
+
carrier = BigInt(access.at);
|
|
2396
|
+
const holder = carrier < entry.seq ? carriers.get(carrier) : undefined;
|
|
2397
|
+
if (holder === undefined || typeof holder === 'string' || 'at' in holder.tally.access) {
|
|
2398
|
+
return broken(`its tally keeps its members and grants in entry ${carrier}, which holds none`);
|
|
2399
|
+
}
|
|
2400
|
+
access = holder.tally.access;
|
|
2401
|
+
}
|
|
2402
|
+
const kept = { members: access.members, grants: access.grants, newest: access.newest };
|
|
2403
|
+
if (keptDigest(access) !== read.tally.access.digest) return broken("its tally's members and grants do not match their digest");
|
|
2404
|
+
return {
|
|
2405
|
+
ok: true,
|
|
2406
|
+
entry: entry.seq,
|
|
2407
|
+
at: { seq: BigInt(read.seq), hash: Buffer.from(read.hash, 'hex') },
|
|
2408
|
+
tally: { ...read.tally, access: kept },
|
|
2409
|
+
digest: read.tally.access.digest,
|
|
2410
|
+
carrier,
|
|
2411
|
+
scrub: read.scrub === null ? null : { at: positionOf(read.scrub.at), end: positionOf(read.scrub.end) },
|
|
2412
|
+
};
|
|
2413
|
+
});
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
/** A checkpoint's entry that keeps a tally, as it says, or why it is not one the vault wrote. */
|
|
2417
|
+
#tallied(entry: StoredEntry): TalliedCheckpoint | string {
|
|
2418
|
+
const not = "not a checkpoint of the vault's that keeps a tally";
|
|
2419
|
+
if (entry.author !== 'vault' || entry.action !== CHECKPOINT || entry.decision !== 'allow' || entry.relatedSeq === null) return not;
|
|
2420
|
+
const authentic = verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys: this.#keysFor(entry) });
|
|
2421
|
+
if (!authentic.ok) return withCause(authentic.reason);
|
|
2422
|
+
const read = metadataOf(entry) as Partial<TalliedCheckpoint> | null;
|
|
2423
|
+
if (read?.tally?.v !== TALLY_VERSION || read.scrub === undefined || typeof read.seq !== 'number' || BigInt(read.seq) !== entry.relatedSeq) return not;
|
|
2424
|
+
return read as TalliedCheckpoint;
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
/** Which of `heads` the log still holds where they were, in one read. */
|
|
2428
|
+
async #holding(db: Queryable, heads: readonly LogHead[]): Promise<(head: LogHead) => boolean> {
|
|
2429
|
+
const hashes = await store.hashesAt(db, [...new Set(heads.filter((head) => head.seq >= 0).map((head) => BigInt(head.seq)))]);
|
|
2430
|
+
return (head) => head.hash === GENESIS_HASH.toString('hex') || hashes.get(BigInt(head.seq))?.toString('hex') === head.hash;
|
|
2104
2431
|
}
|
|
2105
2432
|
|
|
2106
2433
|
/**
|
|
2107
|
-
*
|
|
2108
|
-
* its first entry at the first checkpoint of each hour, and otherwise from
|
|
2109
|
-
* the prefix the last checkpoint signed, if that one was signed this hour.
|
|
2110
|
-
*
|
|
2111
|
-
* Resuming never signs what the vault did not recompute. The vault
|
|
2112
|
-
* recomputed the last checkpoint's prefix itself, from entry 0 at the
|
|
2113
|
-
* hour's first checkpoint and onward at each one since, and the entry
|
|
2114
|
-
* that holds it is the first one checked again, by its MAC. The next
|
|
2115
|
-
* signature extends that same chain.
|
|
2434
|
+
* Sign the log up to its last entry, from the newest checkpoint's tally.
|
|
2116
2435
|
*
|
|
2117
|
-
*
|
|
2118
|
-
*
|
|
2119
|
-
*
|
|
2120
|
-
*
|
|
2121
|
-
*
|
|
2122
|
-
*
|
|
2123
|
-
*
|
|
2436
|
+
* In one snapshot, and without the log's lock, so that no append waits on
|
|
2437
|
+
* it: every member's row checked, since rows changed around the vault
|
|
2438
|
+
* write nothing to the log; the prefix the newest checkpoint signed still
|
|
2439
|
+
* there, its hash where it was, which a rewrite chained again changes, and
|
|
2440
|
+
* every entry of it, which a cut changes; the chain recomputed from there,
|
|
2441
|
+
* every hash from content and every vault entry by its MAC, no further
|
|
2442
|
+
* than one walk (`walks.checkpoint`); and a stretch of the prefixes signed
|
|
2443
|
+
* before rehashed (`#scrub`). Then, under the lock, the entries appended
|
|
2444
|
+
* since the snapshot, so that it signs the head.
|
|
2124
2445
|
*
|
|
2125
|
-
*
|
|
2126
|
-
*
|
|
2127
|
-
*
|
|
2446
|
+
* A walk cut short signs where it got to, behind the head: the log after
|
|
2447
|
+
* an upgrade from 0.10.0, whose checkpoints kept no tally, is signed again
|
|
2448
|
+
* from the first entry, one walk a call, and `/readyz` stays red until the
|
|
2449
|
+
* checkpoints reach the newest heartbeat.
|
|
2128
2450
|
*/
|
|
2129
|
-
async
|
|
2130
|
-
const { logKeys } = this.#prepared;
|
|
2131
|
-
const latest = await this.#latest(tx);
|
|
2132
|
-
const hour = (ms: number) => Math.floor(ms / FULL_RECOMPUTE_MS);
|
|
2133
|
-
const resumable =
|
|
2134
|
-
latest !== null &&
|
|
2135
|
-
latest.keyId === logKeys[0].keyId &&
|
|
2136
|
-
latest.seq === BigInt(latest.checkpoint.seq) + 1n &&
|
|
2137
|
-
hour(Date.parse(latest.checkpoint.signedAt)) === hour(await this.#now(tx)) &&
|
|
2138
|
-
(await carries(tx, latest.checkpoint)) &&
|
|
2139
|
-
(await store.complete(tx, BigInt(latest.checkpoint.seq)));
|
|
2140
|
-
if (!resumable) return verifyChain(tx, logKeys, [], UNVERIFIED);
|
|
2141
|
-
const { seq, hash } = latest.checkpoint;
|
|
2142
|
-
const signed: Anchor = { nextSeq: BigInt(seq) + 1n, hash: Buffer.from(hash, 'hex'), vaultEntries: 0, vaultKeys: [logKeys[0].keyId] };
|
|
2143
|
-
return verifyChain(tx, logKeys, [], signed);
|
|
2144
|
-
}
|
|
2145
|
-
|
|
2146
|
-
async checkpoint(): Promise<Outcome<{ checkpoint: Checkpoint }>> {
|
|
2451
|
+
async checkpoint(): Promise<Outcome<{ checkpoint: Checkpoint; warning?: string }>> {
|
|
2147
2452
|
await this.#settled();
|
|
2148
2453
|
// A KEK found wrong turns readiness red: the checkpoint is refused. It does no key work of its
|
|
2149
2454
|
// own, so it writes nothing more and never waits on a key service; a check under way, or none yet, is no verdict.
|
|
2150
|
-
const { wrongKek } = this.#prepared;
|
|
2151
|
-
|
|
2152
|
-
// waits on it: every member's row checked, since rows changed around the
|
|
2153
|
-
// vault write nothing to the log; and the chain recomputed, every hash
|
|
2154
|
-
// from content and every vault entry by its MAC, from the first entry
|
|
2155
|
-
// once an hour, since an entry cut from the middle leaves the hashes
|
|
2156
|
-
// around a later checkpoint as they were (`#recompute`).
|
|
2455
|
+
const { wrongKek, logKeys } = this.#prepared;
|
|
2456
|
+
const { walks } = this.#prepared.options;
|
|
2157
2457
|
const found: NewEntry[] = [];
|
|
2158
|
-
|
|
2458
|
+
// The newest checkpoint entry, signed or refused, as this call's snapshot found it.
|
|
2459
|
+
let seen: bigint | null = null;
|
|
2460
|
+
const walked = await this.#db.transaction(async (tx) => {
|
|
2461
|
+
seen = (await store.newestCheckpointEntry(tx))?.seq ?? null;
|
|
2159
2462
|
await this.#sweep(tx, found);
|
|
2160
|
-
|
|
2463
|
+
const from = await this.#resume(tx, 'newest');
|
|
2464
|
+
if (!from.ok) return from;
|
|
2465
|
+
const signed = headOf(from.at);
|
|
2466
|
+
if (!(await this.#holding(tx, [signed]))(signed)) {
|
|
2467
|
+
return { ok: false as const, failedAtSeq: null, reason: `the log up to entry ${signed.seq} is not the prefix the last checkpoint signed` };
|
|
2468
|
+
}
|
|
2469
|
+
const gap = await missingEntry(tx, from.at.seq);
|
|
2470
|
+
if (gap !== null) return { ok: false as const, failedAtSeq: Number(gap.found), reason: `sequence gap: expected seq ${gap.expected}, found ${gap.found}` };
|
|
2471
|
+
const walk = new Walk(from.tally, from.digest);
|
|
2472
|
+
const chain = await rehash(tx, logKeys, from.at, walk, { limit: walks.checkpoint });
|
|
2473
|
+
if (!chain.ok) return chain;
|
|
2474
|
+
const unsigned = await this.#checkpointFault(tx, walk.checkpoints);
|
|
2475
|
+
if (unsigned !== null) return unsigned;
|
|
2476
|
+
return { ok: true as const, from, tally: walk.tally(), digest: walk.digest(), at: chain.at, done: chain.done };
|
|
2161
2477
|
}, SNAPSHOT);
|
|
2478
|
+
// The past rehashed, in a snapshot of its own. The database failing it leaves the stretch for the
|
|
2479
|
+
// next call, where it was, and the checkpoint is signed all the same, saying why (`warning`); a bug
|
|
2480
|
+
// throws, as anywhere else.
|
|
2481
|
+
let scrub: Scrubbed = { ok: true, next: walked.ok ? walked.from.scrub : null };
|
|
2482
|
+
let stalled: string | null = null;
|
|
2483
|
+
if (walked.ok) {
|
|
2484
|
+
try {
|
|
2485
|
+
scrub = await this.#db.transaction((tx) => this.#scrub(tx, walked.from), SNAPSHOT);
|
|
2486
|
+
} catch (error) {
|
|
2487
|
+
// Only the database not answering, or a statement it gave up on: anything else is coffre's to fix.
|
|
2488
|
+
if (!isUnreachable(error) && !timedOut(error)) throw error;
|
|
2489
|
+
stalled = `the rehash of the past did not run, and goes on where it was next time: ${(error as Error).message}`;
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2162
2492
|
return this.#decide([], async (d) => {
|
|
2163
2493
|
for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
|
|
2164
2494
|
// Checkpoints one at a time, each against the one before.
|
|
2165
2495
|
const head = await lockLogHead(d.tx);
|
|
2166
|
-
const latest = await
|
|
2496
|
+
const latest = await store.latestVaultEntry(d.tx, [CHECKPOINT]);
|
|
2167
2497
|
const refused = (code: RefusalCode, detail: Record<string, unknown>, message = MESSAGES[code]) =>
|
|
2168
2498
|
new Refused(refusal(code, message), [
|
|
2169
2499
|
{ actor: SCHEDULER, action: CHECKPOINT, decision: 'deny', code, metadata: JSON.stringify(detail) },
|
|
@@ -2171,33 +2501,104 @@ class VaultService implements Vault {
|
|
|
2171
2501
|
// Logging the refusal would give the next call something to sign.
|
|
2172
2502
|
if (head.nextSeq === 0n) throw new Refused(refusal('invalid', 'the log is empty'), []);
|
|
2173
2503
|
if (wrongKek !== null) throw refused('wrong_kek', { reason: wrongKek }, wrongKek);
|
|
2174
|
-
if (!
|
|
2175
|
-
|
|
2504
|
+
if (!walked.ok) throw refused('log_broken', { failedAtSeq: walked.failedAtSeq, reason: walked.reason });
|
|
2505
|
+
if (!scrub.ok) throw refused('log_broken', { failedAtSeq: scrub.failedAtSeq, reason: scrub.reason });
|
|
2506
|
+
const newest = latest === undefined ? null : (JSON.parse(latest.metadata) as Checkpoint);
|
|
2507
|
+
// Another call checkpointed, or refused to, since this one's snapshot, or this one's rehash of the past
|
|
2508
|
+
// could not run: what it would sign over is not what it checked. A refusal for a broken log stands,
|
|
2509
|
+
// and a checkpoint signed meanwhile is the newest prefix; the next call starts from them.
|
|
2510
|
+
if (stalled !== null || ((await store.newestCheckpointEntry(d.tx))?.seq ?? null) !== seen) {
|
|
2511
|
+
const standing = await this.#standingRefusal(d.tx);
|
|
2512
|
+
if (standing !== null) throw refused('log_broken', standing);
|
|
2513
|
+
if (stalled === null && newest !== null) return { checkpoint: signedPart(newest) };
|
|
2176
2514
|
}
|
|
2177
|
-
// Nothing since the last one: it is still the newest prefix.
|
|
2178
|
-
if (
|
|
2515
|
+
// Nothing since the last one, which signed every entry before its own: it is still the newest prefix.
|
|
2516
|
+
if (newest !== null && latest!.seq === head.nextSeq - 1n && latest!.relatedSeq === latest!.seq - 1n) return { checkpoint: signedPart(newest) };
|
|
2179
2517
|
// Under the lock, the rest: the prefix signed last still where it was,
|
|
2180
|
-
// and every entry since the snapshot
|
|
2181
|
-
|
|
2182
|
-
|
|
2518
|
+
// and every entry since the snapshot, unless the walk is catching up,
|
|
2519
|
+
// when what it walked must still be there. A rewrite is never signed over.
|
|
2520
|
+
if (newest !== null && !(await carries(d.tx, newest))) {
|
|
2521
|
+
throw refused('log_broken', { reason: `the log up to entry ${newest.seq} is not the prefix the last checkpoint signed` });
|
|
2183
2522
|
}
|
|
2184
|
-
const
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2523
|
+
const walk = new Walk(walked.tally, walked.digest);
|
|
2524
|
+
let { at } = walked;
|
|
2525
|
+
if (walked.done) {
|
|
2526
|
+
// Where the snapshot's walk ended still has the hash it found there: what follows links to it.
|
|
2527
|
+
if (!(await carries(d.tx, headOf(at)))) {
|
|
2528
|
+
throw refused('log_broken', { failedAtSeq: Number(at.seq), reason: `the log up to entry ${at.seq} changed while the vault walked it` });
|
|
2529
|
+
}
|
|
2530
|
+
const rest = await rehash(d.tx, logKeys, at, walk, {});
|
|
2531
|
+
if (!rest.ok) throw refused('log_broken', { failedAtSeq: rest.failedAtSeq, reason: rest.reason });
|
|
2532
|
+
const unsigned = await this.#checkpointFault(d.tx, walk.checkpoints);
|
|
2533
|
+
if (unsigned !== null) throw refused('log_broken', { failedAtSeq: unsigned.failedAtSeq, reason: unsigned.reason });
|
|
2534
|
+
at = rest.at;
|
|
2535
|
+
// It signs the entry it verified to, which the head, locked, must name.
|
|
2536
|
+
if (at.seq !== head.nextSeq - 1n || !at.hash.equals(head.headHash)) {
|
|
2537
|
+
throw refused('log_broken', { reason: 'the chain head does not name the last entry' });
|
|
2538
|
+
}
|
|
2539
|
+
} else if (!(await carries(d.tx, headOf(at)))) {
|
|
2540
|
+
throw refused('log_broken', { reason: `the log up to entry ${at.seq} changed while the vault walked it` });
|
|
2189
2541
|
}
|
|
2190
|
-
|
|
2542
|
+
// Another call signed as far, or further, while this one walked behind the head: that is the newest prefix, never one shorter.
|
|
2543
|
+
if (newest !== null && latest!.relatedSeq !== null && latest!.relatedSeq >= at.seq) return { checkpoint: signedPart(newest) };
|
|
2544
|
+
this.#prepared.verified = further(this.#prepared.verified, at);
|
|
2545
|
+
const tally = walk.tally();
|
|
2546
|
+
// Its members and grants once, in the first checkpoint after they change; the others name that one.
|
|
2547
|
+
const digest = walk.digest();
|
|
2548
|
+
const { from } = walked;
|
|
2549
|
+
const access = digest === from.digest && from.carrier !== null ? { digest, at: Number(from.carrier) } : { digest, ...tally.access };
|
|
2550
|
+
const signed = { seq: Number(at.seq), hash: at.hash.toString('hex'), signedAt: iso(d.at) };
|
|
2191
2551
|
const checkpoint = {
|
|
2192
2552
|
...signed,
|
|
2193
2553
|
keyId: this.#prepared.signer.keyId,
|
|
2194
2554
|
signature: await this.#prepared.signer.sign(checkpointMessage(signed)),
|
|
2195
2555
|
};
|
|
2196
|
-
|
|
2197
|
-
|
|
2556
|
+
const lap = scrub.next === null ? null : { at: headOf(scrub.next.at), end: headOf(scrub.next.end) };
|
|
2557
|
+
const kept: TalliedCheckpoint = { ...checkpoint, tally: { v: TALLY_VERSION, ...tally, access }, scrub: lap };
|
|
2558
|
+
d.log.push({ actor: SCHEDULER, action: CHECKPOINT, decision: 'allow', relatedSeq: at.seq, metadata: JSON.stringify(kept) });
|
|
2559
|
+
return stalled === null ? { checkpoint } : { checkpoint, warning: stalled };
|
|
2198
2560
|
});
|
|
2199
2561
|
}
|
|
2200
2562
|
|
|
2563
|
+
/**
|
|
2564
|
+
* The prefixes signed before, rehashed a stretch at a time, in laps: a lap
|
|
2565
|
+
* runs from the first entry to the prefix the newest checkpoint signed
|
|
2566
|
+
* when it began, which it keeps (`end`), `walks.scrub` entries a call, each
|
|
2567
|
+
* hash recomputed from its entry and each link checked (`relink`); the
|
|
2568
|
+
* next lap runs to the prefix signed then. A checkpoint resumes from the
|
|
2569
|
+
* newest one's tally and never reads what it signed again, and an entry
|
|
2570
|
+
* before it edited in place, its stored hash left as it was, keeps the
|
|
2571
|
+
* chain: this is what finds it, within two laps at most, each the length
|
|
2572
|
+
* of the log when it began over `walks.scrub` beats, however fast the log
|
|
2573
|
+
* grows meanwhile. The lap as it goes on, null between laps.
|
|
2574
|
+
*/
|
|
2575
|
+
async #scrub(db: Queryable, newest: Resumed): Promise<Scrubbed> {
|
|
2576
|
+
const lap = newest.scrub ?? { at: START, end: newest.at };
|
|
2577
|
+
if (lap.end.seq < 0n) return { ok: true, next: null };
|
|
2578
|
+
const relinked = await relink(db, lap.at, lap.end.seq, this.#prepared.options.walks.scrub);
|
|
2579
|
+
if (!relinked.ok) return relinked;
|
|
2580
|
+
if (relinked.at.seq < lap.end.seq) return { ok: true, next: { at: relinked.at, end: lap.end } };
|
|
2581
|
+
// A lap ends at the prefix a checkpoint signed, or it was not the chain the checkpoints signed.
|
|
2582
|
+
if (!relinked.at.hash.equals(lap.end.hash)) {
|
|
2583
|
+
return { ok: false, failedAtSeq: Number(lap.end.seq), reason: `the log up to entry ${lap.end.seq} is not the prefix a checkpoint signed` };
|
|
2584
|
+
}
|
|
2585
|
+
return { ok: true, next: null };
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
/**
|
|
2589
|
+
* The newest refusal for a broken log since the newest checkpoint was
|
|
2590
|
+
* signed, by the vault's MAC, as it said: it stands, for every
|
|
2591
|
+
* verification and checkpoint, until a checkpoint is signed, which takes
|
|
2592
|
+
* the check that found it running again and passing. A refusal of
|
|
2593
|
+
* another kind since, a wrong KEK, hides none.
|
|
2594
|
+
*/
|
|
2595
|
+
async #standingRefusal(db: Queryable): Promise<{ failedAtSeq?: number | null; reason?: string } | null> {
|
|
2596
|
+
const signed = await store.latestVaultEntry(db, [CHECKPOINT]);
|
|
2597
|
+
const refused = await store.newestRefusal(db, CHECKPOINT, 'log_broken', signed?.seq ?? -1n);
|
|
2598
|
+
if (refused === undefined || !this.#authentic(refused)) return null;
|
|
2599
|
+
return JSON.parse(refused.metadata) as { failedAtSeq?: number | null; reason?: string };
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2201
2602
|
async about(): Promise<{ checkpointKeys: Record<string, CheckpointKey>; owners: string[]; rootAdmins: [] }> {
|
|
2202
2603
|
await this.#settled();
|
|
2203
2604
|
const { signers, since } = this.#prepared;
|
|
@@ -2221,41 +2622,123 @@ class VaultService implements Vault {
|
|
|
2221
2622
|
return { current: { kekProvider: provider, kekId: keyId }, checks };
|
|
2222
2623
|
}
|
|
2223
2624
|
|
|
2224
|
-
verifyLog(input: VerifyLogInput): Promise<LogVerification> {
|
|
2225
|
-
return this.#verifyAll(input.upTo ?? null);
|
|
2226
|
-
}
|
|
2227
|
-
|
|
2228
2625
|
/**
|
|
2229
|
-
* The
|
|
2230
|
-
* each by its MAC, the keys it moved through, and what the
|
|
2231
|
-
* need of it
|
|
2232
|
-
* heads that must still be there,
|
|
2233
|
-
* members and grants
|
|
2626
|
+
* The log from a checkpoint's tally, in one snapshot: the vault's
|
|
2627
|
+
* entries since, each by its MAC, the keys it moved through, and what the
|
|
2628
|
+
* other checks need of it (`Walk`); then every checkpoint walked, the
|
|
2629
|
+
* heads that must still be there, and, for a walk that ends at the
|
|
2630
|
+
* present, the key batches accounted for and the members and grants
|
|
2631
|
+
* replayed against their rows.
|
|
2234
2632
|
*
|
|
2235
2633
|
* With `upTo`, the head the app verified to, the links and hashes of the
|
|
2236
|
-
* chain are the app's to recompute, as it just has, over its own
|
|
2237
|
-
* through that head; `
|
|
2238
|
-
* the vault reads only its own entries, each checked against its
|
|
2239
|
-
* and MAC over the link it names. Without `upTo`, nobody else
|
|
2240
|
-
* the chain: the vault reads it all, and rehashes every link
|
|
2634
|
+
* chain are the app's to recompute, as it just has, over its own
|
|
2635
|
+
* snapshot, through that head; holding `upTo` ties this snapshot to that
|
|
2636
|
+
* one. So the vault reads only its own entries, each checked against its
|
|
2637
|
+
* own hash and MAC over the link it names. Without `upTo`, nobody else
|
|
2638
|
+
* recomputed the chain: the vault reads it all, and rehashes every link,
|
|
2639
|
+
* no further than one walk (`walks.verify`).
|
|
2241
2640
|
*/
|
|
2242
|
-
async
|
|
2641
|
+
async verifyLog(input: VerifyLogInput): Promise<LogVerification> {
|
|
2243
2642
|
await this.#settled();
|
|
2244
|
-
|
|
2643
|
+
const upTo = input.upTo ?? null;
|
|
2644
|
+
const { logKeys } = this.#prepared;
|
|
2645
|
+
const { walks } = this.#prepared.options;
|
|
2646
|
+
return this.#db.transaction(async (tx): Promise<LogVerification> => {
|
|
2245
2647
|
const remembered = this.#prepared.verified;
|
|
2246
|
-
const
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
//
|
|
2250
|
-
const
|
|
2251
|
-
const
|
|
2252
|
-
if (
|
|
2253
|
-
|
|
2254
|
-
|
|
2648
|
+
const from = await this.#resume(tx, input.from === undefined ? 'newest' : input.from === 'start' ? 'start' : BigInt(input.from));
|
|
2649
|
+
if (!from.ok) return from;
|
|
2650
|
+
// Where a stretch ends, the last entry its checkpoint names: that checkpoint, and each it passes on the
|
|
2651
|
+
// way, are held to the walk as it gets there, so a fault on the way is named where it is.
|
|
2652
|
+
const to = input.to === undefined ? null : BigInt(input.to);
|
|
2653
|
+
const ends = to === null ? null : (await store.entriesAt(tx, [to])).get(to)?.relatedSeq ?? -1n;
|
|
2654
|
+
if (ends !== null && (ends <= from.at.seq || (upTo !== null && BigInt(upTo.seq) !== ends))) {
|
|
2655
|
+
return { ok: false, failedAtSeq: Number(to), reason: 'not a checkpoint after the one the walk starts from, whose prefix ends where the app verified to' };
|
|
2656
|
+
}
|
|
2657
|
+
// The prefix the walk starts from still there, as it was signed, and whole: a rewrite chained again
|
|
2658
|
+
// changes its hash, and a cut its count.
|
|
2659
|
+
if (!(await this.#holding(tx, [headOf(from.at)]))(headOf(from.at))) {
|
|
2660
|
+
return { ok: false, failedAtSeq: Number(from.entry), reason: `the log up to entry ${from.at.seq} is not the prefix this checkpoint signed: it was rewritten` };
|
|
2661
|
+
}
|
|
2662
|
+
const gap = await missingEntry(tx, from.at.seq);
|
|
2663
|
+
if (gap !== null) return { ok: false, failedAtSeq: Number(gap.found), reason: `sequence gap: expected seq ${gap.expected}, found ${gap.found}` };
|
|
2664
|
+
// Every checkpoint after it whose prefix the walk may pass, a stretch's end among them, signed while
|
|
2665
|
+
// the app walked or not: each tally is held to the walk where it gets there (`Walk.expect`).
|
|
2666
|
+
const walk = new Walk(from.tally, from.digest);
|
|
2667
|
+
const later = await store.talliedAfter(tx, from.entry ?? -1n, walks.verify);
|
|
2668
|
+
const passed = later.filter(({ seq }) => to === null || seq <= to);
|
|
2669
|
+
if (to !== null && passed.at(-1)?.seq !== to) {
|
|
2670
|
+
return { ok: false, failedAtSeq: Number(to), reason: `a stretch passes no more than ${walks.verify} checkpoints: end one sooner` };
|
|
2671
|
+
}
|
|
2672
|
+
// Each one's tally as what the walk must come to, a few at a time, so that members and grants kept whole
|
|
2673
|
+
// in many of them are never all in memory: the digest it keeps of them stands for them.
|
|
2674
|
+
// Each one's members and grants are as its digest says, kept whole in it or in the earlier checkpoint it
|
|
2675
|
+
// names, each such checkpoint read once: what a walk resuming from it would find.
|
|
2676
|
+
const holding = new Map<bigint, string | null>();
|
|
2677
|
+
const carried = async (carrier: bigint): Promise<string | null> => {
|
|
2678
|
+
if (!holding.has(carrier)) {
|
|
2679
|
+
const entry = (await store.entriesAt(tx, [carrier])).get(carrier);
|
|
2680
|
+
const read = entry === undefined ? 'missing' : this.#tallied(entry);
|
|
2681
|
+
holding.set(carrier, typeof read === 'string' || 'at' in read.tally.access ? null : keptDigest(read.tally.access));
|
|
2682
|
+
}
|
|
2683
|
+
return holding.get(carrier)!;
|
|
2684
|
+
};
|
|
2685
|
+
const expected: Expected[] = [];
|
|
2686
|
+
// The hash the stretch's own checkpoint signed, as it is read among the others.
|
|
2687
|
+
let endSigned: string | null = null;
|
|
2688
|
+
for (let after = from.entry ?? -1n, last = passed.at(-1)?.seq; last !== undefined && after < last; ) {
|
|
2689
|
+
const batch = await store.talliedWithin(tx, after, last, walks.verify + 1);
|
|
2690
|
+
if (batch.length === 0) break;
|
|
2691
|
+
for (const entry of batch) {
|
|
2692
|
+
const read = this.#tallied(entry);
|
|
2693
|
+
const point = { entry: entry.seq, through: entry.relatedSeq! };
|
|
2694
|
+
if (typeof read === 'string') {
|
|
2695
|
+
expected.push({ ...point, unreadable: read });
|
|
2696
|
+
continue;
|
|
2697
|
+
}
|
|
2698
|
+
if (entry.seq === to) endSigned = read.hash;
|
|
2699
|
+
const { access } = read.tally;
|
|
2700
|
+
if (!('at' in access)) holding.set(entry.seq, keptDigest(access));
|
|
2701
|
+
const kept = 'at' in access ? (BigInt(access.at) < entry.seq ? await carried(BigInt(access.at)) : null) : holding.get(entry.seq)!;
|
|
2702
|
+
expected.push(kept === access.digest
|
|
2703
|
+
? { ...point, key: expectedKey(read.tally) }
|
|
2704
|
+
: { ...point, unreadable: kept === null ? `its tally keeps its members and grants in entry ${'at' in access ? access.at : entry.seq}, which holds none` : "its tally's members and grants do not match their digest" });
|
|
2705
|
+
}
|
|
2706
|
+
after = batch.at(-1)!.seq;
|
|
2707
|
+
}
|
|
2708
|
+
walk.expect(expected);
|
|
2709
|
+
// How far the walk got, the hash there, and whether that is the present: the rows and the key batches
|
|
2710
|
+
// are held to the log only when the walk read every entry of the vault's there is.
|
|
2711
|
+
let through = from.at.seq;
|
|
2712
|
+
let reached = headOf(from.at).hash;
|
|
2713
|
+
let present = false;
|
|
2714
|
+
if (upTo === null) {
|
|
2715
|
+
const chain = await rehash(tx, logKeys, from.at, walk, ends === null ? { limit: walks.verify } : { to: ends });
|
|
2716
|
+
if (!chain.ok) return chain;
|
|
2717
|
+
[through, reached, present] = [chain.at.seq, headOf(chain.at).hash, chain.done && ends === null];
|
|
2718
|
+
this.#prepared.verified = further(this.#prepared.verified, chain.at);
|
|
2719
|
+
} else {
|
|
2720
|
+
// On past the app's head, to the vault's last entry in this snapshot: the rows are as they are at
|
|
2721
|
+
// this one's. The app's head bounds what is verified; the vault's own walk, what the rows meet.
|
|
2722
|
+
const own = await ownEntries(tx, logKeys, from.at.seq, walk, ends === null ? { limit: walks.verify } : { to: ends });
|
|
2723
|
+
if (!own.ok) return own;
|
|
2724
|
+
// An app older than 0.10.1 names no `from`, and takes an ok for the whole of `upTo`: it gets one only for that.
|
|
2725
|
+
if (!own.done && input.from === undefined) {
|
|
2726
|
+
return { ok: false, failedAtSeq: null, reason: `the vault verifies no more than ${walks.verify} of its entries a call, and its checkpoints are catching up: update the app` };
|
|
2727
|
+
}
|
|
2728
|
+
// Short of its own last entry, it has not held the rows to the log: never say it verified all the app did.
|
|
2729
|
+
const short = own.at < BigInt(upTo.seq) ? own.at : BigInt(upTo.seq) - 1n;
|
|
2730
|
+
[through, reached, present] = [own.done ? BigInt(upTo.seq) : short, upTo.hash, own.done && ends === null];
|
|
2731
|
+
}
|
|
2732
|
+
walk.finish(ends ?? through);
|
|
2733
|
+
// Each head the log must still hold, in one read: the app's, every
|
|
2734
|
+
// checkpoint's walked, and the last this vault rehashed.
|
|
2735
|
+
const rememberedHead = remembered.seq >= 0n ? headOf(remembered) : null;
|
|
2736
|
+
const heads = [...(upTo === null ? [] : [upTo]), ...(rememberedHead === null ? [] : [rememberedHead])];
|
|
2737
|
+
const holds = await this.#holding(tx, [...heads, ...walk.checkpoints.map(({ checkpoint }) => checkpoint)]);
|
|
2255
2738
|
if (upTo !== null && !holds(upTo)) {
|
|
2256
2739
|
return { ok: false, failedAtSeq: upTo.seq, reason: 'not the entry the app verified up to: the log changed between the two checks' };
|
|
2257
2740
|
}
|
|
2258
|
-
const unsigned = await this.#checkpointFault(
|
|
2741
|
+
const unsigned = await this.#checkpointFault(tx, walk.checkpoints, holds);
|
|
2259
2742
|
if (unsigned !== null) return unsigned;
|
|
2260
2743
|
// What this vault last verified must still be there: whoever holds
|
|
2261
2744
|
// its key can seal a rewrite, but cannot put back the head it saw. A
|
|
@@ -2263,57 +2746,46 @@ class VaultService implements Vault {
|
|
|
2263
2746
|
if (rememberedHead !== null && !holds(rememberedHead)) {
|
|
2264
2747
|
return { ok: false, failedAtSeq: rememberedHead.seq, reason: 'changed since the vault last verified it' };
|
|
2265
2748
|
}
|
|
2749
|
+
if (walk.mismatch !== null) return { ok: false, failedAtSeq: Number(walk.mismatch.entry), reason: walk.mismatch.reason };
|
|
2750
|
+
if (ends !== null) {
|
|
2751
|
+
// The walk ends at the prefix the stretch's checkpoint signed, by its hash too: a chain made again from before it is not.
|
|
2752
|
+
if ((upTo === null && through !== ends) || endSigned !== reached) {
|
|
2753
|
+
return { ok: false, failedAtSeq: Number(to), reason: `the log up to entry ${ends} is not the prefix this checkpoint signed: it was rewritten` };
|
|
2754
|
+
}
|
|
2755
|
+
return { ok: true, entries: walk.entries, through: Number(ends) };
|
|
2756
|
+
}
|
|
2757
|
+
// A checkpoint refused since the newest was signed, for a broken log: what it found, the stretch of the
|
|
2758
|
+
// past it rehashed among it, which no walk from a tally reads again.
|
|
2759
|
+
const standing = await this.#standingRefusal(tx);
|
|
2760
|
+
if (standing !== null) {
|
|
2761
|
+
return { ok: false, failedAtSeq: standing.failedAtSeq ?? null, reason: `the last checkpoint was refused: ${standing.reason ?? 'the log is broken'}` };
|
|
2762
|
+
}
|
|
2763
|
+
const verified = { ok: true as const, entries: walk.entries, through: Number(through) };
|
|
2764
|
+
if (!present) return verified;
|
|
2266
2765
|
const at = await this.#now(tx);
|
|
2267
|
-
const accounting =
|
|
2766
|
+
const accounting = walk.accounting.result(at);
|
|
2268
2767
|
if (!accounting.ok) return accounting;
|
|
2269
2768
|
const [rows, held] = await Promise.all([store.allMembers(tx), store.grants(tx)]);
|
|
2270
|
-
const fault = this.#unsealed(rows, held,
|
|
2769
|
+
const fault = this.#unsealed(rows, held, walk.newestAccess) ?? walk.replayFault ?? replayFault(walk.replayed, rows, held, at);
|
|
2271
2770
|
if (fault !== null) return { ok: false, failedAtSeq: null, reason: describeAccessFault(fault), fault };
|
|
2272
|
-
const verified = { ok: true as const, entries: findings.entries };
|
|
2273
2771
|
return accounting.pending === 0 ? verified : { ...verified, pending: accounting.pending };
|
|
2274
2772
|
}, SNAPSHOT);
|
|
2275
2773
|
}
|
|
2276
2774
|
|
|
2277
2775
|
/**
|
|
2278
|
-
*
|
|
2279
|
-
*
|
|
2280
|
-
*
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
const { logKeys } = this.#prepared;
|
|
2284
|
-
let keys: readonly string[] = [];
|
|
2285
|
-
for (let after = -1n; ; ) {
|
|
2286
|
-
const batch = await store.vaultEntriesAfter(tx, after, VERIFY_BATCH);
|
|
2287
|
-
if (batch.length === 0) return { ok: true };
|
|
2288
|
-
for (const entry of batch) {
|
|
2289
|
-
const result = verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys: logKeys });
|
|
2290
|
-
if (!result.ok) return { ok: false, failedAtSeq: Number(result.failedAtSeq), reason: withCause(result.reason) };
|
|
2291
|
-
}
|
|
2292
|
-
const moved = forward(batch, keys, logKeys[0]!.keyId);
|
|
2293
|
-
if ('failedAtSeq' in moved) return { ok: false, failedAtSeq: Number(moved.failedAtSeq), reason: moved.reason };
|
|
2294
|
-
keys = moved.keys;
|
|
2295
|
-
for (const entry of batch) findings.add(entry);
|
|
2296
|
-
after = batch[batch.length - 1]!.seq;
|
|
2297
|
-
}
|
|
2298
|
-
}
|
|
2299
|
-
|
|
2300
|
-
/** The whole chain from its first entry: every link and hash, the vault's MACs; the furthest verified is kept for checkpoints. */
|
|
2301
|
-
async #rehashed(tx: Queryable, findings: Findings): Promise<{ ok: true } | Extract<LogVerification, { ok: false }>> {
|
|
2302
|
-
const { verification, anchor } = await verifyChain(tx, this.#prepared.logKeys, [], UNVERIFIED, (batch) => {
|
|
2303
|
-
for (const entry of batch) if (entry.author === 'vault') findings.add(entry);
|
|
2304
|
-
});
|
|
2305
|
-
if (!verification.ok) return verification;
|
|
2306
|
-
this.#prepared.verified = further(this.#prepared.verified, anchor);
|
|
2307
|
-
return { ok: true };
|
|
2308
|
-
}
|
|
2309
|
-
|
|
2310
|
-
/**
|
|
2311
|
-
* Every checkpoint, not only the newest: each signed by the key whose log
|
|
2312
|
-
* key wrote its entry, over a prefix the log still holds (`holds`), entry
|
|
2313
|
-
* for entry. Its entry's MAC is checked by now, and one under a key the
|
|
2314
|
-
* vault replaced comes before the rotation.
|
|
2776
|
+
* Every checkpoint walked, not only the newest: each signed by the key
|
|
2777
|
+
* whose log key wrote its entry, over a prefix the log still holds,
|
|
2778
|
+
* entry for entry, which one that keeps a tally names as related. Its
|
|
2779
|
+
* entry's MAC is checked by now, and one under a key the vault replaced
|
|
2780
|
+
* comes before the rotation.
|
|
2315
2781
|
*/
|
|
2316
|
-
async #checkpointFault(
|
|
2782
|
+
async #checkpointFault(
|
|
2783
|
+
db: Queryable,
|
|
2784
|
+
checkpoints: Walk['checkpoints'],
|
|
2785
|
+
known?: (head: LogHead) => boolean,
|
|
2786
|
+
): Promise<Broken | null> {
|
|
2787
|
+
if (checkpoints.length === 0) return null;
|
|
2788
|
+
const holds = known ?? (await this.#holding(db, checkpoints.map(({ checkpoint }) => checkpoint)));
|
|
2317
2789
|
const { logKeys, signers } = this.#prepared;
|
|
2318
2790
|
// Each signer's key imported once, for this check only.
|
|
2319
2791
|
const verifiers = new Map<string, Promise<(checkpoint: Checkpoint) => Promise<boolean>>>();
|
|
@@ -2322,7 +2794,6 @@ class VaultService implements Vault {
|
|
|
2322
2794
|
if (verifier === undefined) verifiers.set(by.keyId, (verifier = checkpointVerifier(by.publicKey)));
|
|
2323
2795
|
return verifier.then((verify) => verify(checkpoint));
|
|
2324
2796
|
};
|
|
2325
|
-
const { checkpoints } = findings;
|
|
2326
2797
|
for (let from = 0; from < checkpoints.length; from += SIGNATURES_AT_ONCE) {
|
|
2327
2798
|
const chunk = checkpoints.slice(from, from + SIGNATURES_AT_ONCE);
|
|
2328
2799
|
// The signatures of a chunk at once, where crypto runs beside the code; the first fault, in the log's order.
|
|
@@ -2330,7 +2801,7 @@ class VaultService implements Vault {
|
|
|
2330
2801
|
const by = signers[logKeys.findIndex((key) => key.keyId === entry.keyId)];
|
|
2331
2802
|
if (by?.keyId !== checkpoint.keyId) return `a checkpoint signed under ${checkpoint.keyId}, in an entry written under ${entry.keyId}: each key signs only its own`;
|
|
2332
2803
|
if (!(await signed(checkpoint, by))) return 'a checkpoint the vault did not sign';
|
|
2333
|
-
if (BigInt(checkpoint.seq) >= entry.seq || !holds(checkpoint)) {
|
|
2804
|
+
if (BigInt(checkpoint.seq) >= entry.seq || !holds(checkpoint) || (entry.relatedSeq !== null && entry.relatedSeq !== BigInt(checkpoint.seq))) {
|
|
2334
2805
|
return `the log up to entry ${checkpoint.seq} is not the prefix this checkpoint signed: it was rewritten`;
|
|
2335
2806
|
}
|
|
2336
2807
|
return null;
|
|
@@ -2343,8 +2814,8 @@ class VaultService implements Vault {
|
|
|
2343
2814
|
|
|
2344
2815
|
/**
|
|
2345
2816
|
* The first member whose row fails its MAC, or names an older access
|
|
2346
|
-
* entry than the log's newest about them (`newest`, from the
|
|
2347
|
-
*
|
|
2817
|
+
* entry than the log's newest about them (`newest`, from the walk, where
|
|
2818
|
+
* each entry's MAC was checked, on from the tally).
|
|
2348
2819
|
*/
|
|
2349
2820
|
#unsealed(rows: readonly Member[], held: readonly GrantRow[], newest: ReadonlyMap<string, bigint>): AccessFault | null {
|
|
2350
2821
|
for (const row of rows) {
|
|
@@ -2356,40 +2827,80 @@ class VaultService implements Vault {
|
|
|
2356
2827
|
}
|
|
2357
2828
|
}
|
|
2358
2829
|
|
|
2830
|
+
type Broken = Extract<LogVerification, { ok: false }>;
|
|
2831
|
+
|
|
2359
2832
|
/**
|
|
2360
|
-
*
|
|
2361
|
-
*
|
|
2362
|
-
*
|
|
2363
|
-
*
|
|
2833
|
+
* Where a walk of the log resumes: the checkpoint's entry (null for the
|
|
2834
|
+
* first entry), the end of the prefix it signed, the tally through there,
|
|
2835
|
+
* its members and grants' digest and the entry that keeps them, and where
|
|
2836
|
+
* the background rehash goes on from (`#scrub`).
|
|
2364
2837
|
*/
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
if (
|
|
2382
|
-
this.replayFault ??= apply(this.replayed, entry);
|
|
2383
|
-
if (entry.subjectPrincipal !== null) this.newestAccess.set(entry.subjectPrincipal, entry.seq);
|
|
2384
|
-
} else if (entry.action === CHECKPOINT) {
|
|
2385
|
-
this.checkpoints.push({ entry: { seq: entry.seq, keyId: entry.keyId }, checkpoint: JSON.parse(entry.metadata) as Checkpoint });
|
|
2386
|
-
}
|
|
2838
|
+
type Resumed = {
|
|
2839
|
+
ok: true;
|
|
2840
|
+
entry: bigint | null;
|
|
2841
|
+
at: Position;
|
|
2842
|
+
tally: Tally;
|
|
2843
|
+
digest: string;
|
|
2844
|
+
carrier: bigint | null;
|
|
2845
|
+
scrub: Lap | null;
|
|
2846
|
+
};
|
|
2847
|
+
|
|
2848
|
+
/** A lap of the rehash of the past: the last entry rehashed, and the prefix it rehashes to, signed when it began. */
|
|
2849
|
+
type Lap = { at: Position; end: Position };
|
|
2850
|
+
|
|
2851
|
+
/** Whether a statement was cancelled for taking too long: Postgres's `query_canceled`, a statement timeout among them. */
|
|
2852
|
+
function timedOut(error: unknown): boolean {
|
|
2853
|
+
for (let cause = error; typeof cause === 'object' && cause !== null; cause = (cause as { cause?: unknown }).cause) {
|
|
2854
|
+
if ((cause as { code?: unknown }).code === '57014') return true;
|
|
2387
2855
|
}
|
|
2856
|
+
return false;
|
|
2388
2857
|
}
|
|
2389
2858
|
|
|
2390
|
-
|
|
2859
|
+
/** Where the next rehash of the past goes on from, null for the first entry; or what it found. */
|
|
2860
|
+
type Scrubbed = { ok: true; next: Lap | null } | Broken;
|
|
2861
|
+
|
|
2862
|
+
const FIRST_ENTRY: Resumed = { ok: true, entry: null, at: START, tally: EMPTY_TALLY, digest: accessDigest(EMPTY_TALLY.access), carrier: null, scrub: null };
|
|
2863
|
+
|
|
2864
|
+
/** How far one call walks the log, in entries: a checkpoint's walk from the last, its rehash of the past, and a verification's own walk. */
|
|
2865
|
+
export type Walks = { checkpoint: number; scrub: number; verify: number };
|
|
2866
|
+
|
|
2867
|
+
/**
|
|
2868
|
+
* A walk rehashes, MACs and replays some 40 µs of CPU an entry, and 95
|
|
2869
|
+
* with V8's optimizer off, as on a cold isolate: 10,000 entries is a beat
|
|
2870
|
+
* of catching up, about a second, and a team's instance writes fewer than
|
|
2871
|
+
* a thousand a day. The rehash of the past is a hash an entry, 14 µs warm
|
|
2872
|
+
* and 55 cold, beside two reads: 100 entries keep it near 6 ms a beat.
|
|
2873
|
+
*/
|
|
2874
|
+
const WALKS: Walks = { checkpoint: 10_000, scrub: 100, verify: 10_000 };
|
|
2391
2875
|
|
|
2392
|
-
/**
|
|
2876
|
+
/** The digest of the members and grants a checkpoint keeps whole, beside the digest it says they have. */
|
|
2877
|
+
function keptDigest({ members, grants, newest }: AccessTally): string {
|
|
2878
|
+
return accessDigest({ members, grants, newest });
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
/** Each checkpoint entry's metadata, parsed once however many times it is asked about: one may hold every member and grant. */
|
|
2882
|
+
const parsed = new WeakMap<StoredEntry, unknown>();
|
|
2883
|
+
function metadataOf(entry: StoredEntry): unknown {
|
|
2884
|
+
if (!parsed.has(entry)) parsed.set(entry, JSON.parse(entry.metadata));
|
|
2885
|
+
return parsed.get(entry);
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2888
|
+
/** A head, as an entry keeps it, as a walk goes on from it. */
|
|
2889
|
+
function positionOf(head: LogHead): Position {
|
|
2890
|
+
return { seq: BigInt(head.seq), hash: Buffer.from(head.hash, 'hex') };
|
|
2891
|
+
}
|
|
2892
|
+
|
|
2893
|
+
/** A position as the log's heads are named, in hex. */
|
|
2894
|
+
function headOf(position: Position): LogHead {
|
|
2895
|
+
return { seq: Number(position.seq), hash: position.hash.toString('hex') };
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
/** What a checkpoint's signature covers, and nothing it keeps beside it. */
|
|
2899
|
+
function signedPart({ seq, hash, signedAt, keyId, signature }: Checkpoint): Checkpoint {
|
|
2900
|
+
return { seq, hash, signedAt, keyId, signature };
|
|
2901
|
+
}
|
|
2902
|
+
|
|
2903
|
+
/** Checkpoint signatures checked at once. */
|
|
2393
2904
|
const SIGNATURES_AT_ONCE = 256;
|
|
2394
2905
|
|
|
2395
2906
|
/** Settle every operation before releasing the member lock, including cancelled requests. */
|
|
@@ -2442,6 +2953,7 @@ function validateItems(items: readonly { secret: SecretRef; key?: string; wrappe
|
|
|
2442
2953
|
if (field === 'key' && typeof key !== 'string') throw new Error('DEK must be base64');
|
|
2443
2954
|
if (field === 'wrapped' && (wrapped === undefined || wrapped === null)) throw new Error('wrapped key is required');
|
|
2444
2955
|
checkContext(context(secret));
|
|
2956
|
+
if (secret.sensitive !== undefined && typeof secret.sensitive !== 'boolean') throw new Error('a secret is sensitive or not: true, false or absent');
|
|
2445
2957
|
if (!Number.isSafeInteger(secret.version) || secret.version < 1) throw new Error('secret version must be a positive integer');
|
|
2446
2958
|
if (typeof secret.path !== 'string' || /[\uD800-\uDFFF]/u.test(secret.path)) throw new Error('secret path must be a well-formed string');
|
|
2447
2959
|
if (key !== undefined) {
|
|
@@ -2578,10 +3090,13 @@ function personalRefusal(
|
|
|
2578
3090
|
if (own?.owner == null && held?.owner == null) return undefined;
|
|
2579
3091
|
if (own !== held || principal !== own?.owner || own.parentId === null) return 'personal';
|
|
2580
3092
|
const parent = placeIn({ projectId: secret.projectId, environmentId: own.parentId }, environments);
|
|
2581
|
-
|
|
3093
|
+
// Its owner reads what they wrote while they read or use the environment it configures (R8).
|
|
3094
|
+
const gone = own.deleted || (environments.get(own.parentId)?.deleted ?? true);
|
|
3095
|
+
const refused = refuses(reader, 'secret.use', parent, gone) === null ? null : refuses(reader, 'secret.read', parent, gone);
|
|
2582
3096
|
if (refused !== null) return refused;
|
|
2583
|
-
// The credential's scope caps the owner still: their viewer token on the parent reads, and wraps no key.
|
|
2584
|
-
|
|
3097
|
+
// The credential's scope caps the owner still: their viewer token on the parent reads, and wraps no key. Reading their
|
|
3098
|
+
// own values takes use there: a Contributor's handle reads what its owner wrote, and nothing of the parent's.
|
|
3099
|
+
return withinCredential(reader.live, permission === 'secret.read' ? 'secret.use' : permission, parent) ? null : 'no_grant';
|
|
2585
3100
|
}
|
|
2586
3101
|
|
|
2587
3102
|
function refuses(reader: Standing, permission: Permission, where: Place, deleted: boolean): RefusalCode | null {
|
|
@@ -2781,9 +3296,71 @@ function iso(ms: number): string {
|
|
|
2781
3296
|
return new Date(ms).toISOString();
|
|
2782
3297
|
}
|
|
2783
3298
|
|
|
2784
|
-
/** A
|
|
3299
|
+
/** A policy entry's edits, checked as the vault wrote them, or null for any that does not hold. */
|
|
3300
|
+
function readEdits(stored: { policy?: unknown }): PolicyEdits | null {
|
|
3301
|
+
const edits = stored.policy as Partial<PolicyEdits> | undefined;
|
|
3302
|
+
if (typeof edits !== 'object' || edits === null) return null;
|
|
3303
|
+
const { roles, instanceRoles, protected: protectedIds } = edits;
|
|
3304
|
+
if (typeof roles !== 'object' || roles === null || typeof instanceRoles !== 'object' || instanceRoles === null || !Array.isArray(protectedIds)) return null;
|
|
3305
|
+
const defined = (table: object) => Object.values(table).every((definition) => definitionProblem(definition as RoleDefinition) === null);
|
|
3306
|
+
if (!defined(roles) || !defined(instanceRoles) || Object.keys(instanceRoles).some((role) => !isInstanceRole(role))) return null;
|
|
3307
|
+
if (protectedIds.some((place) => typeof place !== 'object' || place === null || !UUID.test((place as ProtectedPlace).projectId) || !UUID.test((place as ProtectedPlace).environmentId)
|
|
3308
|
+
|| !Number.isSafeInteger((place as ProtectedPlace).at))) return null;
|
|
3309
|
+
return { roles: roles as Record<string, RoleDefinition>, instanceRoles: instanceRoles as PolicyEdits['instanceRoles'], protected: protectedIds as ProtectedPlace[] };
|
|
3310
|
+
}
|
|
3311
|
+
|
|
3312
|
+
/**
|
|
3313
|
+
* The policy `edits` say as of `at`: the roles as they ship, each edited
|
|
3314
|
+
* one replaced and each made one added; and the protected environments, but
|
|
3315
|
+
* for those deleted since, the project around one's included: a deleted
|
|
3316
|
+
* prod protects nothing, and holds nobody back from giving a service
|
|
3317
|
+
* account a role on all of its project (`givesService`). A fork's id with
|
|
3318
|
+
* no row yet stays, protected before it is written, ten minutes: one with
|
|
3319
|
+
* none after that is a fork refused or abandoned, never written. Each
|
|
3320
|
+
* keeps its slug, which a scoped instance role's filters read. The edits
|
|
3321
|
+
* keep them all: no write is needed.
|
|
3322
|
+
*/
|
|
3323
|
+
async function livePolicy(db: Queryable, edits: PolicyEdits, at: number): Promise<Policy> {
|
|
3324
|
+
const rows = await store.environmentsById(db, edits.protected.map((place) => place.environmentId));
|
|
3325
|
+
const live = edits.protected.filter((place) => {
|
|
3326
|
+
const row = rows.get(place.environmentId);
|
|
3327
|
+
return row === undefined ? at - place.at < UNWRITTEN_FORK_MS : !row.deleted;
|
|
3328
|
+
});
|
|
3329
|
+
const slugOf = (place: ProtectedPlace) => rows.get(place.environmentId)?.slug ?? null;
|
|
3330
|
+
return {
|
|
3331
|
+
roles: { ...ROLES, ...edits.roles },
|
|
3332
|
+
instanceRoles: { ...INSTANCE_ROLES, ...edits.instanceRoles },
|
|
3333
|
+
protected: live.map((place) => place.environmentId),
|
|
3334
|
+
protectedIn: live.reduce<Record<string, { environmentId: string; slug: string | null }[]>>(
|
|
3335
|
+
(byProject, place) => ({ ...byProject, [place.projectId]: [...(byProject[place.projectId] ?? []), { environmentId: place.environmentId, slug: slugOf(place) }] }),
|
|
3336
|
+
{},
|
|
3337
|
+
),
|
|
3338
|
+
};
|
|
3339
|
+
}
|
|
3340
|
+
|
|
3341
|
+
/** A change, as its entry names it. */
|
|
3342
|
+
function describeChange(change: PolicyChange | undefined): Record<string, unknown> | null {
|
|
3343
|
+
if (change === undefined || change === null || typeof change !== 'object') return null;
|
|
3344
|
+
if (change.kind === 'protect') return { kind: 'protect', projectId: change.projectId, environmentIds: change.environmentIds, protected: change.protected };
|
|
3345
|
+
return { kind: change.kind, role: change.role, definition: change.definition };
|
|
3346
|
+
}
|
|
3347
|
+
|
|
3348
|
+
/** What a change changed, as it was: the role's definition, or which environments were protected. */
|
|
3349
|
+
function describeBefore(change: PolicyChange, before: Policy): Record<string, unknown> {
|
|
3350
|
+
if (change.kind === 'protect') return { protected: change.environmentIds.filter((id) => before.protected.includes(id)) };
|
|
3351
|
+
const table = change.kind === 'instance-role' ? before.instanceRoles as Record<string, RoleDefinition> : before.roles;
|
|
3352
|
+
return { definition: Object.hasOwn(table, change.role) ? table[change.role] : null };
|
|
3353
|
+
}
|
|
3354
|
+
|
|
3355
|
+
/** A secret's binding context: its ids, a personal config's owner, hashed, as the decision read it, and whether it is sensitive. */
|
|
2785
3356
|
function context(secret: SecretRef, owner?: string): SecretContext {
|
|
2786
|
-
return {
|
|
3357
|
+
return {
|
|
3358
|
+
projectId: secret.projectId,
|
|
3359
|
+
environmentId: secret.environmentId,
|
|
3360
|
+
secretId: secret.secretId,
|
|
3361
|
+
...(owner === undefined ? {} : { owner }),
|
|
3362
|
+
...(secret.sensitive === true ? { sensitive: true as const } : {}),
|
|
3363
|
+
};
|
|
2787
3364
|
}
|
|
2788
3365
|
|
|
2789
3366
|
function unwrappable(wrapped: WrappedKey) {
|