@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/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, newestVaultSeq, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
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
- 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.
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: await this.#now(tx), log: [], writes: [], touched: new Set(), grants: new Map(), reports, after: [], underLock: [],
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
- const appended = await this.#append(tx, entries, d.underLock);
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(tx: Transaction, entries: readonly NewEntry[], underLock: readonly ((locked: Queryable) => Promise<void>)[] = []): Promise<Appended> {
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
- return appendEntries(tx, prepared.logKey, entries, async (locked) => {
839
- const rotation = await store.latestVaultEntry(locked, [KEY_ROTATE]);
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
- vet: ends.length === 0 ? undefined : async (db) => {
910
- const ended = await store.endedReferences(db, ends);
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
- const environments = await store.environmentsById(d.tx, [...secrets, ...at].map((place) => place.environmentId));
1137
- // A personal config's parent, whose read its owner needs.
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' && (await this.#overBulkLimit(d, principal, secrets.length))) first = 'bulk_limit';
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 = await this.#now(d.tx);
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
- const now = await store.environmentsById(d.tx, places.map((place) => place.environmentId));
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
- /** Whether `n` more keys would take `principal` past the bulk limit; counted under their row's lock, so exactly. */
1298
- async #overBulkLimit(d: Decision, principal: string, n: number): Promise<boolean> {
1299
- const { count, windowMs } = this.#config.bulkLimit;
1300
- return (await store.releasesSince(d.tx, principal, d.at - windowMs)) + n > count;
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 [row, held, at] = await Promise.all([store.member(db, principal), store.grants(db, principal), this.#now(db)]);
1646
- const fault = await this.#integrity(db, principal, row, held, reports);
1647
- const { policy } = await this.#policyAt(db, reports, at);
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) => store.newestVaultEntries(db, POLICY, 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
- return edits === null ? { policy: CLOSED_POLICY, edits: NO_EDITS, readable: false } : { policy: await livePolicy(db, edits, at), edits, readable: true };
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
- const newest = await newestVaultSeq(d.tx, POLICY);
2119
- const { policy: before, edits, readable } = await this.#policyAt(d.tx, d.reports, d.at);
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 livePolicy(d.tx, next, d.at), seq: newest === null ? null : Number(newest) };
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
- async function livePolicy(db: Queryable, edits: PolicyEdits, at: number): Promise<Policy> {
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;