@coffre/vault 0.10.1 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cloudflare.d.ts +1 -1
- package/dist/cloudflare.js +2 -2
- package/dist/{index-CByXzyi-.d.ts → index-zkW6DEm9.d.ts} +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/node.d.ts +1 -1
- package/dist/node.js +2 -2
- package/dist/{src-D82jyprb.js → src-B0d-W-SI.js} +4 -3
- package/dist/{vault-B0YiPBgi.js → vault-66Xf0Hw1.js} +381 -103
- package/package.json +3 -3
- package/src/config.ts +6 -5
- package/src/snapshot.postgres.ts +97 -0
- package/src/snapshot.sqlite.ts +103 -0
- package/src/snapshot.ts +191 -0
- package/src/store.ts +2 -34
- package/src/vault.ts +134 -72
package/src/vault.ts
CHANGED
|
@@ -101,13 +101,14 @@ import {
|
|
|
101
101
|
import type { Database, Queryable, Transaction } from '@coffre/db';
|
|
102
102
|
import { isUniqueViolation, isUnreachable, SNAPSHOT } from '@coffre/db/dialect';
|
|
103
103
|
import { everyProjectConversion, newestAccess, reportStands } from '@coffre/db/grants';
|
|
104
|
-
import { appendEntries, lockLogHead, missingEntry, newestAuthentic,
|
|
104
|
+
import { appendEntries, lockLogHead, missingEntry, newestAuthentic, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
|
|
105
105
|
|
|
106
106
|
import { signer, type Signer } from './checkpoint.ts';
|
|
107
107
|
import type { ResolvedVaultConfig } from './config.ts';
|
|
108
108
|
import { carries, further, ownEntries, rehash, relink, START, vaultLogKey, VERIFY_BATCH, withCause, type Position } from './log.ts';
|
|
109
109
|
import { apply, replayFault, type LoggedMember, type Replayed } from './replay.ts';
|
|
110
110
|
import { memberMac, rowKey, sameGrants, sealed, standing } from './rows.ts';
|
|
111
|
+
import { snapshot, type Asked, type Snapshot } from './snapshot.ts';
|
|
111
112
|
import * as store from './store.ts';
|
|
112
113
|
import { accessDigest, CHECKPOINT, EMPTY_TALLY, expectedKey, TALLY_VERSION, Walk, type AccessTally, type Expected, type TalliedCheckpoint, type Tally } from './tally.ts';
|
|
113
114
|
import { ACCESS_ACTIONS, type GrantRow, type Member } from './store.ts';
|
|
@@ -354,6 +355,8 @@ type Decision = {
|
|
|
354
355
|
members: Map<string, Member>;
|
|
355
356
|
/** The database's clock, read once the members are locked. */
|
|
356
357
|
at: number;
|
|
358
|
+
/** What the decision reads, in one statement once the members are locked: under the head, unless it takes the head late. */
|
|
359
|
+
reads: Snapshot;
|
|
357
360
|
log: NewEntry[];
|
|
358
361
|
writes: ((at: number) => Promise<void>)[];
|
|
359
362
|
/** Members whose row or grants the writes change: sealed again once they have run. */
|
|
@@ -378,6 +381,13 @@ type Decision = {
|
|
|
378
381
|
underLock: ((locked: Queryable) => Promise<void>)[];
|
|
379
382
|
};
|
|
380
383
|
|
|
384
|
+
/**
|
|
385
|
+
* What a decision reads besides its members' standing, with it (`Snapshot`):
|
|
386
|
+
* places, references' ends, and the distinct keys a principal would hold
|
|
387
|
+
* released in the vault's window, with `keys` too: the bulk limit's count.
|
|
388
|
+
*/
|
|
389
|
+
type Reads = Pick<Asked, 'environments' | 'references'> & { released?: { principal: string; keys: readonly string[] } };
|
|
390
|
+
|
|
381
391
|
/** The actions of the entries the vault writes about keys. */
|
|
382
392
|
type KeyAction = 'secret.read' | 'key.wrap' | 'key.rewrap';
|
|
383
393
|
|
|
@@ -448,6 +458,22 @@ class VaultService implements Vault {
|
|
|
448
458
|
return (await store.now(db)) + this.#prepared.options.clockOffset();
|
|
449
459
|
}
|
|
450
460
|
|
|
461
|
+
/** A snapshot's clock, as the vault keeps time (`#now`). */
|
|
462
|
+
#at(reads: Snapshot): number {
|
|
463
|
+
return reads.at + this.#prepared.options.clockOffset();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** What a decision about `principals` reads, and `asked` besides, in one statement: under the head when `head` is set. */
|
|
467
|
+
#snapshot(tx: Transaction, principals: readonly string[], asked: Reads, head: boolean): Promise<Snapshot> {
|
|
468
|
+
const { released, ...rest } = asked;
|
|
469
|
+
return snapshot(tx, {
|
|
470
|
+
...rest,
|
|
471
|
+
principals,
|
|
472
|
+
head,
|
|
473
|
+
...(released === undefined ? {} : { released: { ...released, windowMs: this.#config.bulkLimit.windowMs, clockOffsetMs: this.#prepared.options.clockOffset() } }),
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
451
477
|
/**
|
|
452
478
|
* Decide in one transaction, with `principals`' rows locked first, then
|
|
453
479
|
* the log's head. A `Refused` or an `Outage` rolls back all but its own
|
|
@@ -460,18 +486,18 @@ class VaultService implements Vault {
|
|
|
460
486
|
* alone takes the head later (`late`), once its key service has answered,
|
|
461
487
|
* which it must not wait on holding it, and decides again then (`#keys`).
|
|
462
488
|
*/
|
|
463
|
-
async #decide<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false): Promise<Outcome<T>> {
|
|
489
|
+
async #decide<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false, asked: Reads = {}): Promise<Outcome<T>> {
|
|
464
490
|
const superseded = await this.#settled();
|
|
465
491
|
if (superseded !== null) return { ok: false, refusal: refusal('wrong_kek', superseded) };
|
|
466
|
-
return this.#decideNow(principals, decide, late);
|
|
492
|
+
return this.#decideNow(principals, decide, late, asked);
|
|
467
493
|
}
|
|
468
494
|
|
|
469
495
|
/** `#decide`, once the vault is settled. */
|
|
470
|
-
async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false): Promise<Outcome<T>> {
|
|
496
|
+
async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false, asked: Reads = {}): Promise<Outcome<T>> {
|
|
471
497
|
const reports: NewEntry[] = [];
|
|
472
498
|
for (let attempt = 1; ; attempt += 1) {
|
|
473
499
|
try {
|
|
474
|
-
return await this.#decideOnce(principals, decide, reports, late);
|
|
500
|
+
return await this.#decideOnce(principals, decide, reports, late, asked);
|
|
475
501
|
} catch (error) {
|
|
476
502
|
// A member who had no row when this decision locked theirs, and has
|
|
477
503
|
// one now: another decision admitted them meanwhile. Again, with it.
|
|
@@ -481,15 +507,15 @@ class VaultService implements Vault {
|
|
|
481
507
|
}
|
|
482
508
|
}
|
|
483
509
|
|
|
484
|
-
async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], late: boolean): Promise<Outcome<T>> {
|
|
510
|
+
async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], late: boolean, asked: Reads): Promise<Outcome<T>> {
|
|
485
511
|
try {
|
|
486
512
|
const result = await this.#db.transaction(async (tx) => {
|
|
487
513
|
await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
488
514
|
const members = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals);
|
|
489
|
-
|
|
490
|
-
|
|
515
|
+
// Then the head, and everything the decision reads, in one statement (`snapshot` says why it holds).
|
|
516
|
+
const reads = await this.#snapshot(tx, principals, asked, !late);
|
|
491
517
|
const d: Decision = {
|
|
492
|
-
tx, members, at:
|
|
518
|
+
tx, members, at: this.#at(reads), reads, log: [], writes: [], touched: new Set(), grants: new Map(), reports, after: [], underLock: [],
|
|
493
519
|
};
|
|
494
520
|
const result = await decide(d);
|
|
495
521
|
// A read is logged once per place, whichever way it ends (`readsByPlace`).
|
|
@@ -498,7 +524,9 @@ class VaultService implements Vault {
|
|
|
498
524
|
// Each member's newest access entry, which their row names (rows.ts).
|
|
499
525
|
const accessSeq = new Map<string, bigint>();
|
|
500
526
|
if (entries.length > 0) {
|
|
501
|
-
|
|
527
|
+
// Under the head the decision's reads took: a remote key's decision takes it late, and reads again with it.
|
|
528
|
+
if (d.reads.head === null) throw new Error('a decision appends only under the head it read with');
|
|
529
|
+
const appended = await this.#append(tx, entries, d.underLock, d.reads);
|
|
502
530
|
at = appended.occurredAt;
|
|
503
531
|
entries.forEach((entry, i) => {
|
|
504
532
|
if (isAccessEntry(entry)) accessSeq.set(entry.subjectPrincipal!, appended.seqStart + BigInt(i));
|
|
@@ -557,12 +585,12 @@ class VaultService implements Vault {
|
|
|
557
585
|
* MAC are passed over, and reported: otherwise whoever can insert a row
|
|
558
586
|
* could lock any member out.
|
|
559
587
|
*/
|
|
560
|
-
async #integrity(db: Queryable, principal: string, row: Member | undefined, grants: readonly GrantRow[], reports: NewEntry[]): Promise<Fault | null> {
|
|
588
|
+
async #integrity(db: Queryable, principal: string, row: Member | undefined, grants: readonly GrantRow[], reports: NewEntry[], reads?: Snapshot): Promise<Fault | null> {
|
|
561
589
|
if (row !== undefined && !this.#sealed(row, grants)) {
|
|
562
590
|
this.#report(reports, principal, 'mac', row.mac.toString('hex'));
|
|
563
591
|
return 'mac';
|
|
564
592
|
}
|
|
565
|
-
const newest = await this.#newestAccess(db, principal, reports);
|
|
593
|
+
const newest = await this.#newestAccess(db, principal, reports, reads);
|
|
566
594
|
// No row, and no entry of the vault's own: someone never admitted. No
|
|
567
595
|
// row, but one: one deleted, unless it was admitted since the row was
|
|
568
596
|
// read. Too many forged to see past: neither a row nor the lack of one holds.
|
|
@@ -574,8 +602,14 @@ class VaultService implements Vault {
|
|
|
574
602
|
}
|
|
575
603
|
|
|
576
604
|
/** The newest access entry about `principal` that carries the vault's MAC (`newestAccess` in @coffre/db/grants). */
|
|
577
|
-
#newestAccess(db: Queryable, principal: string, reports: NewEntry[]): Promise<Newest> {
|
|
578
|
-
return newestAccess(db, principal, this.#check(reports, principal));
|
|
605
|
+
#newestAccess(db: Queryable, principal: string, reports: NewEntry[], reads?: Snapshot): Promise<Newest> {
|
|
606
|
+
if (reads === undefined || !reads.grants.has(principal)) return newestAccess(db, principal, this.#check(reports, principal));
|
|
607
|
+
// The snapshot's newest first, read with the rest; the pages before it, as they are asked.
|
|
608
|
+
const newest = reads.newestAccess.get(principal);
|
|
609
|
+
return newestAuthentic(
|
|
610
|
+
(limit, before) => (before === undefined ? Promise.resolve(newest === undefined ? [] : [newest]) : store.accessEntriesAbout(db, principal, limit, before)),
|
|
611
|
+
this.#check(reports, principal),
|
|
612
|
+
);
|
|
579
613
|
}
|
|
580
614
|
|
|
581
615
|
/** Entries in the vault's name told apart by their MACs, each it did not write reported, about `principal`. */
|
|
@@ -687,7 +721,7 @@ class VaultService implements Vault {
|
|
|
687
721
|
|
|
688
722
|
async #replaceEveryProject(d: Decision, principal: string): Promise<void> {
|
|
689
723
|
const row = d.members.get(principal);
|
|
690
|
-
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
724
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
|
|
691
725
|
if (row === undefined || subject.status !== 'active') return;
|
|
692
726
|
const everyProject = subject.stored.filter((grant) => grant.projectId === null && grant.environmentId === null);
|
|
693
727
|
if (everyProject.length === 0) return;
|
|
@@ -833,16 +867,29 @@ class VaultService implements Vault {
|
|
|
833
867
|
* that an instance still running with the replaced KEK, as during a
|
|
834
868
|
* deploy, cannot write after the rotation.
|
|
835
869
|
*/
|
|
836
|
-
#append(
|
|
870
|
+
async #append(
|
|
871
|
+
tx: Transaction,
|
|
872
|
+
entries: readonly NewEntry[],
|
|
873
|
+
underLock: readonly ((locked: Queryable) => Promise<void>)[] = [],
|
|
874
|
+
/** A snapshot that holds the head: what it read under it is read again by nothing. */
|
|
875
|
+
held?: Snapshot,
|
|
876
|
+
): Promise<Appended> {
|
|
837
877
|
const prepared = this.#prepared;
|
|
838
|
-
|
|
839
|
-
|
|
878
|
+
const head = held?.head ?? undefined;
|
|
879
|
+
const appended = await appendEntries(tx, prepared.logKey, entries, async (locked) => {
|
|
880
|
+
const rotation = head === undefined ? await store.latestVaultEntry(locked, [KEY_ROTATE]) : head.rotation ?? undefined;
|
|
840
881
|
if (rotation !== undefined && !prepared.logKeys.some((key) => key.keyId === rotation.keyId)) {
|
|
841
882
|
prepared.superseded = `the log moved on to ${rotation.keyId}, a key this vault does not hold: a vault given a newer vault key replaced it, and a replaced key writes nothing more`;
|
|
842
883
|
throw new Error(prepared.superseded);
|
|
843
884
|
}
|
|
844
885
|
for (const check of underLock) await check(locked);
|
|
845
|
-
});
|
|
886
|
+
}, head === undefined ? undefined : { head, last: head.last, at: held!.at });
|
|
887
|
+
// The head still held, as this append left it, for the next append of the same transaction: a remote key's intent,
|
|
888
|
+
// then the decision's reports.
|
|
889
|
+
if (head !== undefined) {
|
|
890
|
+
held!.head = { ...head, nextSeq: appended.nextSeq, headHash: appended.headHash, last: { seq: appended.nextSeq - 1n, hash: appended.headHash } };
|
|
891
|
+
}
|
|
892
|
+
return appended;
|
|
846
893
|
}
|
|
847
894
|
|
|
848
895
|
// --- keys -------------------------------------------------------------------
|
|
@@ -906,10 +953,8 @@ class VaultService implements Vault {
|
|
|
906
953
|
secrets: items.map((item) => item.secret),
|
|
907
954
|
// Through a reference, the reader's grants on the holder's environment decide.
|
|
908
955
|
places: sealed.map((seal, i) => seal?.holder ?? items[i]!.secret),
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
return sealed.map((seal) => (seal !== null && ended.has(seal.seq) ? 'ended' : null));
|
|
912
|
-
},
|
|
956
|
+
references: ends,
|
|
957
|
+
vet: ends.length === 0 ? undefined : async (_, reads) => sealed.map((seal) => (seal !== null && reads.ended.has(seal.seq) ? 'ended' : null)),
|
|
913
958
|
remote,
|
|
914
959
|
entry,
|
|
915
960
|
input,
|
|
@@ -1095,11 +1140,14 @@ class VaultService implements Vault {
|
|
|
1095
1140
|
secrets: readonly SecretRef[];
|
|
1096
1141
|
/** Where each secret's permission is checked, when not where it is: a reference's holder. */
|
|
1097
1142
|
places?: readonly SecretPlace[];
|
|
1143
|
+
/** The `reference.create` entries `vet` asks about: read with the decision (`Snapshot`'s `ended`). */
|
|
1144
|
+
references?: readonly bigint[];
|
|
1098
1145
|
/**
|
|
1099
1146
|
* What else refuses each secret, checked in the decision and again
|
|
1100
|
-
* once the log's head is locked, before its entries append
|
|
1147
|
+
* once the log's head is locked, before its entries append: from the
|
|
1148
|
+
* decision's reads, or the database.
|
|
1101
1149
|
*/
|
|
1102
|
-
vet?: (db: Queryable) => Promise<(RefusalCode | null)[]>;
|
|
1150
|
+
vet?: (db: Queryable, reads: Snapshot) => Promise<(RefusalCode | null)[]>;
|
|
1103
1151
|
/**
|
|
1104
1152
|
* Any one of which each secret takes besides `permission`, where it
|
|
1105
1153
|
* is, read in the decision and again under the head; none for null.
|
|
@@ -1128,15 +1176,13 @@ class VaultService implements Vault {
|
|
|
1128
1176
|
// Each personal config's owner, hashed, by environment, as the decision read them: what its keys' contexts bind.
|
|
1129
1177
|
const owners = new Map<string, string>();
|
|
1130
1178
|
const check = async (d: Decision) => {
|
|
1131
|
-
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), call.input);
|
|
1179
|
+
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), call.input);
|
|
1132
1180
|
// One read of the environments the decision is about, as the store has them, not as the app sent them:
|
|
1133
1181
|
// each secret's and, for a read through a reference, its holder's. A grant on one slug in every project
|
|
1134
1182
|
// matches the slug there, and a place deleted on either side opens for none.
|
|
1135
1183
|
const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
const parents = [...environments.values()].flatMap((environment) => (environment.owner === null || environment.parentId === null ? [] : [environment.parentId]));
|
|
1139
|
-
for (const [id, parent] of await store.environmentsById(d.tx, parents)) environments.set(id, parent);
|
|
1184
|
+
// Each a personal config is a config of too, whose read its owner needs (`places`).
|
|
1185
|
+
const environments = d.reads.environments;
|
|
1140
1186
|
const gone = (place: SecretPlace) => environments.get(place.environmentId)?.deleted ?? false;
|
|
1141
1187
|
owners.clear();
|
|
1142
1188
|
for (const [id, environment] of environments) if (environment.owner !== null) owners.set(id, ownerKey(environment.owner));
|
|
@@ -1156,7 +1202,7 @@ class VaultService implements Vault {
|
|
|
1156
1202
|
return refuses(reader, call.permission, placeIn(at[i]!, environments), gone(secret) || gone(at[i]!));
|
|
1157
1203
|
});
|
|
1158
1204
|
if (call.vet !== undefined && !codes.some((code) => code !== null)) {
|
|
1159
|
-
(await call.vet(d.tx)).forEach((code, i) => (codes[i] = code));
|
|
1205
|
+
(await call.vet(d.tx, d.reads)).forEach((code, i) => (codes[i] = code));
|
|
1160
1206
|
}
|
|
1161
1207
|
// A sensitive value is only ever used for a person, never handed back, by whatever credential of theirs asks. And
|
|
1162
1208
|
// a fork copies one, for anyone, a service account too, only where its app says it keeps it sensitive: an app of
|
|
@@ -1169,7 +1215,7 @@ class VaultService implements Vault {
|
|
|
1169
1215
|
});
|
|
1170
1216
|
}
|
|
1171
1217
|
let first = codes.find((code) => code !== null) ?? null;
|
|
1172
|
-
if (first === null && action === 'secret.read' &&
|
|
1218
|
+
if (first === null && action === 'secret.read' && this.#overBulkLimit(d)) first = 'bulk_limit';
|
|
1173
1219
|
if (first !== null) {
|
|
1174
1220
|
throw new Refused(
|
|
1175
1221
|
refusal(first, MESSAGES[first]),
|
|
@@ -1178,6 +1224,12 @@ class VaultService implements Vault {
|
|
|
1178
1224
|
}
|
|
1179
1225
|
};
|
|
1180
1226
|
|
|
1227
|
+
// Every decision below reads the places it is about with the reader's standing; a read, the reader's releases too.
|
|
1228
|
+
const reads: Reads = {
|
|
1229
|
+
environments: [...secrets, ...secrets.map((secret, i) => call.places?.[i] ?? secret)].map((place) => place.environmentId),
|
|
1230
|
+
references: call.references ?? [],
|
|
1231
|
+
...(action === 'secret.read' ? { released: { principal, keys: secrets.map((secret) => secret.secretId) } } : {}),
|
|
1232
|
+
};
|
|
1181
1233
|
// The intent's own identity; `operationId` is the app's, for the whole action.
|
|
1182
1234
|
const intentId = call.remote ? randomUUID() : null;
|
|
1183
1235
|
let intentSeq: bigint | null = null;
|
|
@@ -1197,7 +1249,7 @@ class VaultService implements Vault {
|
|
|
1197
1249
|
if (call.remote) {
|
|
1198
1250
|
const intent = await this.#decide([principal], async (d) => {
|
|
1199
1251
|
await check(d);
|
|
1200
|
-
const at =
|
|
1252
|
+
const at = d.at;
|
|
1201
1253
|
const appended = await this.#append(d.tx, [{
|
|
1202
1254
|
actor: principal,
|
|
1203
1255
|
action: 'key.intent',
|
|
@@ -1213,9 +1265,9 @@ class VaultService implements Vault {
|
|
|
1213
1265
|
...traced(call.input),
|
|
1214
1266
|
keys: secrets.map((secret, item) => ({ item, subject: secret.path, secretId: secret.secretId, version: secret.version })),
|
|
1215
1267
|
}),
|
|
1216
|
-
}]);
|
|
1268
|
+
}], [], d.reads);
|
|
1217
1269
|
return { seq: appended.seqStart };
|
|
1218
|
-
});
|
|
1270
|
+
}, false, reads);
|
|
1219
1271
|
if (!intent.ok) return intent;
|
|
1220
1272
|
intentSeq = intent.seq;
|
|
1221
1273
|
}
|
|
@@ -1242,7 +1294,7 @@ class VaultService implements Vault {
|
|
|
1242
1294
|
const { vet } = call;
|
|
1243
1295
|
if (late && vet !== undefined) {
|
|
1244
1296
|
d.underLock.push(async (locked) => {
|
|
1245
|
-
const codes = await vet(locked);
|
|
1297
|
+
const codes = await vet(locked, d.reads);
|
|
1246
1298
|
const first = codes.find((code) => code !== null);
|
|
1247
1299
|
if (first === undefined || first === null) return;
|
|
1248
1300
|
throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, 'deny', codes[item] ?? first)));
|
|
@@ -1272,13 +1324,13 @@ class VaultService implements Vault {
|
|
|
1272
1324
|
// deletion renames its place under it, so one that committed since the
|
|
1273
1325
|
// check above is seen here, and nothing is released after it.
|
|
1274
1326
|
// A read through a reference: its holder's place too, which a deletion may have taken.
|
|
1275
|
-
await lockLogHead(d.tx);
|
|
1276
1327
|
// 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.
|
|
1328
|
+
// since the check above is seen here, and nothing it refuses now is released. All of it read again, holding the head.
|
|
1329
|
+
d.reads = await this.#snapshot(d.tx, [principal], reads, true);
|
|
1330
|
+
d.at = this.#at(d.reads);
|
|
1278
1331
|
await decided(d);
|
|
1279
1332
|
const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
|
|
1280
|
-
|
|
1281
|
-
if (places.some((place) => now.get(place.environmentId)?.deleted ?? false)) {
|
|
1333
|
+
if (places.some((place) => d.reads.environments.get(place.environmentId)?.deleted ?? false)) {
|
|
1282
1334
|
throw new Refused(refusal('deleted', MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, 'deny', 'deleted')));
|
|
1283
1335
|
}
|
|
1284
1336
|
}
|
|
@@ -1291,13 +1343,17 @@ class VaultService implements Vault {
|
|
|
1291
1343
|
} finally {
|
|
1292
1344
|
for (const value of done) value.wipe();
|
|
1293
1345
|
}
|
|
1294
|
-
}, late);
|
|
1346
|
+
}, late, reads);
|
|
1295
1347
|
}
|
|
1296
1348
|
|
|
1297
|
-
/**
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1349
|
+
/**
|
|
1350
|
+
* Whether this read's keys would take the reader past the bulk limit: the
|
|
1351
|
+
* distinct keys released to them in the window, these among them. A key
|
|
1352
|
+
* read again exposes nothing new, so it counts once, whatever its version.
|
|
1353
|
+
* Counted under their row's lock, so exactly (`Reads`).
|
|
1354
|
+
*/
|
|
1355
|
+
#overBulkLimit(d: Decision): boolean {
|
|
1356
|
+
return d.reads.released > this.#config.bulkLimit.count;
|
|
1301
1357
|
}
|
|
1302
1358
|
|
|
1303
1359
|
// --- references ---------------------------------------------------------------
|
|
@@ -1349,7 +1405,7 @@ class VaultService implements Vault {
|
|
|
1349
1405
|
return this.#decide([principal], async (d) => {
|
|
1350
1406
|
// Under the log's head, which a deletion and every change to the policy take: neither side deleted, nor protected,
|
|
1351
1407
|
// nor a role narrowed, before this commits.
|
|
1352
|
-
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
|
|
1408
|
+
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), input);
|
|
1353
1409
|
// Each source's nature, its current version's, under the head: what its maker must hold there.
|
|
1354
1410
|
const natures = await store.natures(d.tx, [...sources.values()].map((source) => source.secretId));
|
|
1355
1411
|
// Both sides as the store has them: a grant on a slug matches there, and neither side may be deleted.
|
|
@@ -1436,7 +1492,7 @@ class VaultService implements Vault {
|
|
|
1436
1492
|
};
|
|
1437
1493
|
return this.#decide([principal], async (d) => {
|
|
1438
1494
|
// Under the log's head: two ends of one reference at once, the second finds the first; and the actor's standing stands.
|
|
1439
|
-
const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
|
|
1495
|
+
const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), input);
|
|
1440
1496
|
// Ending is allowed in a deleted place: deleting one ends the references into and out of it.
|
|
1441
1497
|
const environments = await store.environmentsById(d.tx, seals.map((seal) => seal.holder.environmentId));
|
|
1442
1498
|
const codes: (RefusalCode | null)[] = seals.map((seal) => {
|
|
@@ -1552,7 +1608,7 @@ class VaultService implements Vault {
|
|
|
1552
1608
|
*/
|
|
1553
1609
|
async #makeOwner(d: Decision, principal: string, reason: 'owners'): Promise<boolean> {
|
|
1554
1610
|
const row = d.members.get(principal);
|
|
1555
|
-
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
1611
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
|
|
1556
1612
|
if (subject.status === 'active' && ownsInstance(subject.live)) return false;
|
|
1557
1613
|
const tampered = subject.status === 'tampered';
|
|
1558
1614
|
if (tampered && (await this.#logged(d.tx, principal)) === undefined) return false;
|
|
@@ -1584,17 +1640,17 @@ class VaultService implements Vault {
|
|
|
1584
1640
|
* What `principal` holds, their row checked first (`#integrity`): a row
|
|
1585
1641
|
* that fails holds nothing, and is `tampered`.
|
|
1586
1642
|
*/
|
|
1587
|
-
async #standing(db: Queryable, principal: string, row: Member | undefined, at: number, reports: NewEntry[]): Promise<Standing> {
|
|
1643
|
+
async #standing(db: Queryable, principal: string, row: Member | undefined, at: number, reports: NewEntry[], reads?: Snapshot): Promise<Standing> {
|
|
1588
1644
|
const none = NOTHING;
|
|
1589
|
-
const grants = row === undefined ? [] : await store.grants(db, principal);
|
|
1590
|
-
const fault = await this.#integrity(db, principal, row, grants, reports);
|
|
1645
|
+
const grants = row === undefined ? [] : reads?.grants.get(principal) ?? (await store.grants(db, principal));
|
|
1646
|
+
const fault = await this.#integrity(db, principal, row, grants, reports, reads);
|
|
1591
1647
|
if (fault !== null) return { principal, status: 'tampered', live: none, all: none, fault, stored: grants };
|
|
1592
1648
|
if (!PRINCIPAL.test(principal)) return { principal, status: 'unknown', live: none, all: none, fault, stored: grants };
|
|
1593
1649
|
if (row?.status !== 'active') return { principal, status: row?.status ?? 'unknown', live: none, all: none, fault, stored: grants };
|
|
1594
1650
|
const held = grants.map((grant) => ({ ...grant, role: grant.role as Role }));
|
|
1595
1651
|
const { role, scope } = roleOf(principal, row);
|
|
1596
1652
|
// 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);
|
|
1653
|
+
const { policy } = await this.#policyAt(db, reports, at, reads);
|
|
1598
1654
|
const service = !principal.startsWith('user:');
|
|
1599
1655
|
return {
|
|
1600
1656
|
principal,
|
|
@@ -1642,9 +1698,10 @@ class VaultService implements Vault {
|
|
|
1642
1698
|
/** `access`, as the rows are now. */
|
|
1643
1699
|
async #read(principal: string, superseded: string | null): Promise<Access> {
|
|
1644
1700
|
const read = async (db: Queryable, reports: NewEntry[]) => {
|
|
1645
|
-
const
|
|
1646
|
-
const
|
|
1647
|
-
const
|
|
1701
|
+
const reads = await snapshot(db, { principals: [principal], members: true });
|
|
1702
|
+
const [row, held, at] = [reads.members.get(principal), reads.grants.get(principal)!, this.#at(reads)];
|
|
1703
|
+
const fault = await this.#integrity(db, principal, row, held, reports, reads);
|
|
1704
|
+
const { policy } = await this.#policyAt(db, reports, at, reads);
|
|
1648
1705
|
return this.#access(principal, row, held, at, fault !== null, policy);
|
|
1649
1706
|
};
|
|
1650
1707
|
const found: NewEntry[] = [];
|
|
@@ -1724,7 +1781,7 @@ class VaultService implements Vault {
|
|
|
1724
1781
|
// renames the place, so the place is as it commits.
|
|
1725
1782
|
// The roles as the policy defines them now, under the head every change to it takes: a role an Owner made is
|
|
1726
1783
|
// 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);
|
|
1784
|
+
const { policy } = await this.#policyAt(d.tx, d.reports, d.at, d.reads);
|
|
1728
1785
|
for (const change of changes) {
|
|
1729
1786
|
if (change.role !== null && !isRole(change.role, policy)) throw refused('invalid', `no such role: ${change.role}`);
|
|
1730
1787
|
if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role, policy)) {
|
|
@@ -1748,7 +1805,7 @@ class VaultService implements Vault {
|
|
|
1748
1805
|
// An environment by its slug as the store has it, which scopes match.
|
|
1749
1806
|
return environmentId === null ? { projectId: projectId! } : { projectId: projectId!, environmentId, environmentSlug: environment!.slug };
|
|
1750
1807
|
});
|
|
1751
|
-
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
1808
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
|
|
1752
1809
|
if (acting.status === 'tampered') throw refused('tampered');
|
|
1753
1810
|
// Where they manage access, anything; otherwise only a person to a service account (`givesService`).
|
|
1754
1811
|
const manages = where.every((place) => mayManageAccess(acting.live, place));
|
|
@@ -1759,7 +1816,7 @@ class VaultService implements Vault {
|
|
|
1759
1816
|
}
|
|
1760
1817
|
|
|
1761
1818
|
const row = d.members.get(principal);
|
|
1762
|
-
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
1819
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
|
|
1763
1820
|
if (subject.status === 'tampered') throw refused('tampered', TAMPERED_SUBJECT);
|
|
1764
1821
|
// A service account admitted with its first grants, as `admit` would: all of it, or none.
|
|
1765
1822
|
const admitted = input.admit === true && row?.status !== 'active' ? await this.#admitWith(d, acting, principal, row, input) : undefined;
|
|
@@ -1859,7 +1916,7 @@ class VaultService implements Vault {
|
|
|
1859
1916
|
]);
|
|
1860
1917
|
return this.#decide([actor, principal], async (d) => {
|
|
1861
1918
|
validateCorrelation(input);
|
|
1862
|
-
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
1919
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
|
|
1863
1920
|
if (acting.status === 'tampered') throw refused('tampered');
|
|
1864
1921
|
// A service account, with no grant yet, any person who could give it one sets up (`setsUpServices`).
|
|
1865
1922
|
const service = SERVICE.test(principal) && (input.role ?? 'member') === 'member' && input.scope === undefined;
|
|
@@ -1872,7 +1929,7 @@ class VaultService implements Vault {
|
|
|
1872
1929
|
throw refused('invalid', 'service accounts hold project grants only, never an instance role');
|
|
1873
1930
|
}
|
|
1874
1931
|
const row = d.members.get(principal);
|
|
1875
|
-
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
1932
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
|
|
1876
1933
|
if (subject.status === 'tampered') throw refused('tampered', TAMPERED_SUBJECT);
|
|
1877
1934
|
d.touched.add(principal);
|
|
1878
1935
|
d.grants.set(principal, [...subject.stored]);
|
|
@@ -1964,10 +2021,10 @@ class VaultService implements Vault {
|
|
|
1964
2021
|
validateCorrelation(input);
|
|
1965
2022
|
if (!PRINCIPAL.test(principal)) throw refused('invalid', `not a member: ${shownMember(principal)}`);
|
|
1966
2023
|
const row = d.members.get(principal);
|
|
1967
|
-
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
2024
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
|
|
1968
2025
|
if (acting.status === 'tampered') throw refused('tampered');
|
|
1969
2026
|
const holder = acting.live;
|
|
1970
|
-
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
2027
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
|
|
1971
2028
|
const held = subject.stored;
|
|
1972
2029
|
if (this.#listed(principal)) throw refused('not_allowed', declaredOwner(principal));
|
|
1973
2030
|
if (subject.status === 'tampered') {
|
|
@@ -2079,14 +2136,19 @@ class VaultService implements Vault {
|
|
|
2079
2136
|
* no role holds anything but Owner, otherwise whoever can insert rows
|
|
2080
2137
|
* could unprotect them. Its edits are then unknown, not none.
|
|
2081
2138
|
*/
|
|
2082
|
-
async #policyAt(db: Queryable, reports: NewEntry[], at: number): Promise<{ policy: Policy; edits: PolicyEdits; readable: boolean }> {
|
|
2139
|
+
async #policyAt(db: Queryable, reports: NewEntry[], at: number, reads?: Snapshot): Promise<{ policy: Policy; edits: PolicyEdits; readable: boolean }> {
|
|
2140
|
+
// The snapshot's newest first, with the rows it protects; the pages before it, as they are asked.
|
|
2083
2141
|
const entry = await newestAuthentic(
|
|
2084
|
-
(limit, before) =>
|
|
2142
|
+
(limit, before) => (reads !== undefined && before === undefined
|
|
2143
|
+
? Promise.resolve(reads.newestPolicy === null ? [] : [reads.newestPolicy])
|
|
2144
|
+
: store.newestVaultEntries(db, POLICY, limit, before)),
|
|
2085
2145
|
this.#check(reports, null),
|
|
2086
2146
|
);
|
|
2087
2147
|
if (entry === 'none') return { policy: DEFAULT_POLICY, edits: NO_EDITS, readable: true };
|
|
2088
2148
|
const edits = typeof entry === 'string' ? null : readEdits(JSON.parse(entry.metadata) as { policy?: unknown });
|
|
2089
|
-
|
|
2149
|
+
if (edits === null) return { policy: CLOSED_POLICY, edits: NO_EDITS, readable: false };
|
|
2150
|
+
const rows = entry === reads?.newestPolicy ? reads.environments : await store.environmentsById(db, edits.protected.map((place) => place.environmentId));
|
|
2151
|
+
return { policy: livePolicy(rows, edits, at), edits, readable: true };
|
|
2090
2152
|
}
|
|
2091
2153
|
|
|
2092
2154
|
changePolicy(input: ChangePolicyInput): Promise<Outcome<{ policy: Policy; seq: number | null }>> {
|
|
@@ -2109,14 +2171,15 @@ class VaultService implements Vault {
|
|
|
2109
2171
|
new Refused(refusal(code, message), [entry('deny', { change: describeChange(change) }, code)]);
|
|
2110
2172
|
return this.#decide([actor], async (d) => {
|
|
2111
2173
|
validateCorrelation(input);
|
|
2112
|
-
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
2174
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
|
|
2113
2175
|
if (acting.status === 'tampered') throw refused('tampered');
|
|
2114
2176
|
if (acting.status !== 'active') throw refused(acting.status === 'removed' ? 'removed' : 'not_a_member');
|
|
2115
2177
|
// Under the log's head, as the actor's standing above: two changes at once each start from the one before. One the
|
|
2116
2178
|
// caller weighed from a revision of the policy, its newest change and its protected environments' rows, starts
|
|
2117
2179
|
// from that one, or is refused, to be asked again: a protected prod renamed since is a policy changed too.
|
|
2118
|
-
|
|
2119
|
-
const
|
|
2180
|
+
// The newest change in the vault's name, read with the decision, as the app reads it: by its number, unchecked.
|
|
2181
|
+
const newest = d.reads.newestPolicy?.seq ?? null;
|
|
2182
|
+
const { policy: before, edits, readable } = await this.#policyAt(d.tx, d.reports, d.at, d.reads);
|
|
2120
2183
|
if (revision !== undefined && policyRevision(newest, before) !== revision) throw refused('policy_changed');
|
|
2121
2184
|
const owner = acting.live.credentialScope == null && ownsInstance(acting.live);
|
|
2122
2185
|
// Unreadable, its edits are unknown: a change made from none would erase every protection and role edit the forged
|
|
@@ -2188,7 +2251,7 @@ class VaultService implements Vault {
|
|
|
2188
2251
|
throw refused('invalid', 'a change is to a role, an instance role, or protection');
|
|
2189
2252
|
}
|
|
2190
2253
|
// The policy now, and the newest change, which it is as of: this one's, when it changed anything.
|
|
2191
|
-
const answer = { policy: await
|
|
2254
|
+
const answer = { policy: livePolicy(await store.environmentsById(d.tx, next.protected.map((place) => place.environmentId)), next, d.at), seq: newest === null ? null : Number(newest) };
|
|
2192
2255
|
if (!readable || JSON.stringify(next) !== JSON.stringify(edits)) {
|
|
2193
2256
|
const made = entry('allow', { policy: next, change: describeChange(change), before: describeBefore(change, before) });
|
|
2194
2257
|
d.log.push(made);
|
|
@@ -2213,7 +2276,7 @@ class VaultService implements Vault {
|
|
|
2213
2276
|
new Refused(refusal(code, message), [entry('deny', { serviceAccounts: input.settings?.serviceAccounts ?? null }, code)]);
|
|
2214
2277
|
return this.#decide([actor], async (d) => {
|
|
2215
2278
|
validateCorrelation(input);
|
|
2216
|
-
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
2279
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
|
|
2217
2280
|
if (acting.status === 'tampered') throw refused('tampered');
|
|
2218
2281
|
if (!runsInstance(acting.live)) throw refused('not_allowed', "only admins and owners of the whole instance change its settings");
|
|
2219
2282
|
if (!isScope(input.settings?.serviceAccounts)) throw refused('invalid', 'who sets up service accounts is a scope: { projects, environments }, each all, only or except');
|
|
@@ -3320,8 +3383,7 @@ function readEdits(stored: { policy?: unknown }): PolicyEdits | null {
|
|
|
3320
3383
|
* keeps its slug, which a scoped instance role's filters read. The edits
|
|
3321
3384
|
* keep them all: no write is needed.
|
|
3322
3385
|
*/
|
|
3323
|
-
|
|
3324
|
-
const rows = await store.environmentsById(db, edits.protected.map((place) => place.environmentId));
|
|
3386
|
+
function livePolicy(rows: ReadonlyMap<string, store.EnvironmentRow>, edits: PolicyEdits, at: number): Policy {
|
|
3325
3387
|
const live = edits.protected.filter((place) => {
|
|
3326
3388
|
const row = rows.get(place.environmentId);
|
|
3327
3389
|
return row === undefined ? at - place.at < UNWRITTEN_FORK_MS : !row.deleted;
|