@coffre/vault 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/vault.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  normalScope,
24
24
  NOTHING,
25
25
  ownsInstance,
26
+ ownsProject,
26
27
  PROJECT_ONLY_PERMISSIONS,
27
28
  ROLE_ID,
28
29
  ROLES,
@@ -38,7 +39,6 @@ import {
38
39
  type RoleDefinition,
39
40
  type Scope,
40
41
  type ServiceAccount,
41
- withinCredential,
42
42
  } from '@coffre/core/access';
43
43
  import { GENESIS_HASH, verifyEntries, type LogKey, type StoredEntry } from '@coffre/core/audit';
44
44
  import { checkContext, ownerKey, type SecretContext } from '@coffre/core/envelope';
@@ -60,7 +60,6 @@ import {
60
60
  checkpointMessage,
61
61
  checkpointVerifier,
62
62
  describeAccessFault,
63
- handsBack,
64
63
  POLICY_REVISION,
65
64
  policyRevision,
66
65
  type Access,
@@ -101,13 +100,15 @@ import {
101
100
  import type { Database, Queryable, Transaction } from '@coffre/db';
102
101
  import { isUniqueViolation, isUnreachable, SNAPSHOT } from '@coffre/db/dialect';
103
102
  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';
103
+ import { appendEntries, lockLogHead, missingEntry, newestAuthentic, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
105
104
 
106
105
  import { signer, type Signer } from './checkpoint.ts';
106
+ import { keyRefusals, placeIn, referenceRefusal, refuses, sensitiveRefusals, useOnlyRefusals, type SecretPlace } from './decide.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';
@@ -317,7 +318,7 @@ class Outage {
317
318
  }
318
319
 
319
320
  /** 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
+ 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; only its current sensitive value is made a secret in place, by an Owner of its project';
321
322
 
322
323
  function refusal(code: RefusalCode, message: string): Refusal {
323
324
  return { code, message };
@@ -334,6 +335,8 @@ const MESSAGES: Record<RefusalCode, string> = {
334
335
  ended: 'the reference read through was ended',
335
336
  personal: "a personal config's values are its owner's alone",
336
337
  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",
338
+ not_owner_issued: 'a service account reads a sensitive value only on a token or trust binding an Owner of its project issued',
339
+ use_only: "a workload uses this value through coffre and never holds it: list it in its binding's reads, where the value is held, if it must",
337
340
  not_allowed: 'not allowed to change this',
338
341
  last_owner: 'the last Owner of the whole instance stays one: make someone else an Owner first',
339
342
  invalid: 'not something the rules allow',
@@ -354,6 +357,8 @@ type Decision = {
354
357
  members: Map<string, Member>;
355
358
  /** The database's clock, read once the members are locked. */
356
359
  at: number;
360
+ /** What the decision reads, in one statement once the members are locked: under the head, unless it takes the head late. */
361
+ reads: Snapshot;
357
362
  log: NewEntry[];
358
363
  writes: ((at: number) => Promise<void>)[];
359
364
  /** Members whose row or grants the writes change: sealed again once they have run. */
@@ -378,6 +383,13 @@ type Decision = {
378
383
  underLock: ((locked: Queryable) => Promise<void>)[];
379
384
  };
380
385
 
386
+ /**
387
+ * What a decision reads besides its members' standing, with it (`Snapshot`):
388
+ * places, references' ends, and the distinct keys a principal would hold
389
+ * released in the vault's window, with `keys` too: the bulk limit's count.
390
+ */
391
+ type Reads = Pick<Asked, 'environments' | 'references'> & { released?: { principal: string; keys: readonly string[] } };
392
+
381
393
  /** The actions of the entries the vault writes about keys. */
382
394
  type KeyAction = 'secret.read' | 'key.wrap' | 'key.rewrap';
383
395
 
@@ -386,13 +398,21 @@ type Correlation = { requestId?: string | null; operationId?: string | null; cre
386
398
 
387
399
  /**
388
400
  * What the one asking holds through the credential their request came in
389
- * on: no more than its scope (`allows`). A scope that is not one is a bug
390
- * of the app's, which fails the call.
401
+ * on: no more than its scope (`allows`), and, a workload's, handed back
402
+ * only what the scope lists (`useOnly`). A scope that is not one, a kind of
403
+ * caller its principal cannot be, or a workload with no scope to list what
404
+ * it reads, is a bug of the app's, which fails the call. None said is an
405
+ * app older than 0.11, which has no workloads (`Through`).
391
406
  */
392
- function through(standing: Standing, { credentialScope }: Through): Standing {
393
- if (credentialScope == null) return standing;
394
- if (!isCredentialScope(credentialScope)) throw new Error('credentialScope must be a list of grants, one a place');
395
- return { ...standing, live: { ...standing.live, credentialScope }, all: { ...standing.all, credentialScope } };
407
+ function through(standing: Standing, { credentialScope, caller }: Through): Standing {
408
+ const { principal } = standing;
409
+ if (caller !== undefined && !(caller === 'person' ? principal.startsWith('user:') : (caller === 'bearer' || caller === 'workload') && principal.startsWith('token:'))) {
410
+ throw new Error(`caller must be person for a user: principal, bearer or workload for a token: one, got: ${String(caller)}`);
411
+ }
412
+ if (caller === 'workload' && credentialScope == null) throw new Error('a workload caller comes with its credential scope, which lists what it reads');
413
+ if (credentialScope != null && !isCredentialScope(credentialScope)) throw new Error('credentialScope must be a list of grants, one a place');
414
+ const held = { ...(credentialScope == null ? {} : { credentialScope }), ...(caller === undefined ? {} : { kind: caller }) };
415
+ return { ...standing, live: { ...standing.live, ...held }, all: { ...standing.all, ...held } };
396
416
  }
397
417
 
398
418
  /** The credential a CI run's request came in on, as its entries record it: copied, never decided on. */
@@ -448,6 +468,22 @@ class VaultService implements Vault {
448
468
  return (await store.now(db)) + this.#prepared.options.clockOffset();
449
469
  }
450
470
 
471
+ /** A snapshot's clock, as the vault keeps time (`#now`). */
472
+ #at(reads: Snapshot): number {
473
+ return reads.at + this.#prepared.options.clockOffset();
474
+ }
475
+
476
+ /** What a decision about `principals` reads, and `asked` besides, in one statement: under the head when `head` is set. */
477
+ #snapshot(tx: Transaction, principals: readonly string[], asked: Reads, head: boolean): Promise<Snapshot> {
478
+ const { released, ...rest } = asked;
479
+ return snapshot(tx, {
480
+ ...rest,
481
+ principals,
482
+ head,
483
+ ...(released === undefined ? {} : { released: { ...released, windowMs: this.#config.bulkLimit.windowMs, clockOffsetMs: this.#prepared.options.clockOffset() } }),
484
+ });
485
+ }
486
+
451
487
  /**
452
488
  * Decide in one transaction, with `principals`' rows locked first, then
453
489
  * the log's head. A `Refused` or an `Outage` rolls back all but its own
@@ -460,18 +496,18 @@ class VaultService implements Vault {
460
496
  * alone takes the head later (`late`), once its key service has answered,
461
497
  * which it must not wait on holding it, and decides again then (`#keys`).
462
498
  */
463
- async #decide<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false): Promise<Outcome<T>> {
499
+ async #decide<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false, asked: Reads = {}): Promise<Outcome<T>> {
464
500
  const superseded = await this.#settled();
465
501
  if (superseded !== null) return { ok: false, refusal: refusal('wrong_kek', superseded) };
466
- return this.#decideNow(principals, decide, late);
502
+ return this.#decideNow(principals, decide, late, asked);
467
503
  }
468
504
 
469
505
  /** `#decide`, once the vault is settled. */
470
- async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false): Promise<Outcome<T>> {
506
+ async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false, asked: Reads = {}): Promise<Outcome<T>> {
471
507
  const reports: NewEntry[] = [];
472
508
  for (let attempt = 1; ; attempt += 1) {
473
509
  try {
474
- return await this.#decideOnce(principals, decide, reports, late);
510
+ return await this.#decideOnce(principals, decide, reports, late, asked);
475
511
  } catch (error) {
476
512
  // A member who had no row when this decision locked theirs, and has
477
513
  // one now: another decision admitted them meanwhile. Again, with it.
@@ -481,15 +517,15 @@ class VaultService implements Vault {
481
517
  }
482
518
  }
483
519
 
484
- async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], late: boolean): Promise<Outcome<T>> {
520
+ async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], late: boolean, asked: Reads): Promise<Outcome<T>> {
485
521
  try {
486
522
  const result = await this.#db.transaction(async (tx) => {
487
523
  await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
488
524
  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.
525
+ // Then the head, and everything the decision reads, in one statement (`snapshot` says why it holds).
526
+ const reads = await this.#snapshot(tx, principals, asked, !late);
491
527
  const d: Decision = {
492
- tx, members, at: await this.#now(tx), log: [], writes: [], touched: new Set(), grants: new Map(), reports, after: [], underLock: [],
528
+ tx, members, at: this.#at(reads), reads, log: [], writes: [], touched: new Set(), grants: new Map(), reports, after: [], underLock: [],
493
529
  };
494
530
  const result = await decide(d);
495
531
  // A read is logged once per place, whichever way it ends (`readsByPlace`).
@@ -498,7 +534,9 @@ class VaultService implements Vault {
498
534
  // Each member's newest access entry, which their row names (rows.ts).
499
535
  const accessSeq = new Map<string, bigint>();
500
536
  if (entries.length > 0) {
501
- const appended = await this.#append(tx, entries, d.underLock);
537
+ // Under the head the decision's reads took: a remote key's decision takes it late, and reads again with it.
538
+ if (d.reads.head === null) throw new Error('a decision appends only under the head it read with');
539
+ const appended = await this.#append(tx, entries, d.underLock, d.reads);
502
540
  at = appended.occurredAt;
503
541
  entries.forEach((entry, i) => {
504
542
  if (isAccessEntry(entry)) accessSeq.set(entry.subjectPrincipal!, appended.seqStart + BigInt(i));
@@ -557,12 +595,12 @@ class VaultService implements Vault {
557
595
  * MAC are passed over, and reported: otherwise whoever can insert a row
558
596
  * could lock any member out.
559
597
  */
560
- async #integrity(db: Queryable, principal: string, row: Member | undefined, grants: readonly GrantRow[], reports: NewEntry[]): Promise<Fault | null> {
598
+ async #integrity(db: Queryable, principal: string, row: Member | undefined, grants: readonly GrantRow[], reports: NewEntry[], reads?: Snapshot): Promise<Fault | null> {
561
599
  if (row !== undefined && !this.#sealed(row, grants)) {
562
600
  this.#report(reports, principal, 'mac', row.mac.toString('hex'));
563
601
  return 'mac';
564
602
  }
565
- const newest = await this.#newestAccess(db, principal, reports);
603
+ const newest = await this.#newestAccess(db, principal, reports, reads);
566
604
  // No row, and no entry of the vault's own: someone never admitted. No
567
605
  // row, but one: one deleted, unless it was admitted since the row was
568
606
  // read. Too many forged to see past: neither a row nor the lack of one holds.
@@ -574,8 +612,14 @@ class VaultService implements Vault {
574
612
  }
575
613
 
576
614
  /** 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));
615
+ #newestAccess(db: Queryable, principal: string, reports: NewEntry[], reads?: Snapshot): Promise<Newest> {
616
+ if (reads === undefined || !reads.grants.has(principal)) return newestAccess(db, principal, this.#check(reports, principal));
617
+ // The snapshot's newest first, read with the rest; the pages before it, as they are asked.
618
+ const newest = reads.newestAccess.get(principal);
619
+ return newestAuthentic(
620
+ (limit, before) => (before === undefined ? Promise.resolve(newest === undefined ? [] : [newest]) : store.accessEntriesAbout(db, principal, limit, before)),
621
+ this.#check(reports, principal),
622
+ );
579
623
  }
580
624
 
581
625
  /** Entries in the vault's name told apart by their MACs, each it did not write reported, about `principal`. */
@@ -687,7 +731,7 @@ class VaultService implements Vault {
687
731
 
688
732
  async #replaceEveryProject(d: Decision, principal: string): Promise<void> {
689
733
  const row = d.members.get(principal);
690
- const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
734
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
691
735
  if (row === undefined || subject.status !== 'active') return;
692
736
  const everyProject = subject.stored.filter((grant) => grant.projectId === null && grant.environmentId === null);
693
737
  if (everyProject.length === 0) return;
@@ -833,16 +877,29 @@ class VaultService implements Vault {
833
877
  * that an instance still running with the replaced KEK, as during a
834
878
  * deploy, cannot write after the rotation.
835
879
  */
836
- #append(tx: Transaction, entries: readonly NewEntry[], underLock: readonly ((locked: Queryable) => Promise<void>)[] = []): Promise<Appended> {
880
+ async #append(
881
+ tx: Transaction,
882
+ entries: readonly NewEntry[],
883
+ underLock: readonly ((locked: Queryable) => Promise<void>)[] = [],
884
+ /** A snapshot that holds the head: what it read under it is read again by nothing. */
885
+ held?: Snapshot,
886
+ ): Promise<Appended> {
837
887
  const prepared = this.#prepared;
838
- return appendEntries(tx, prepared.logKey, entries, async (locked) => {
839
- const rotation = await store.latestVaultEntry(locked, [KEY_ROTATE]);
888
+ const head = held?.head ?? undefined;
889
+ const appended = await appendEntries(tx, prepared.logKey, entries, async (locked) => {
890
+ const rotation = head === undefined ? await store.latestVaultEntry(locked, [KEY_ROTATE]) : head.rotation ?? undefined;
840
891
  if (rotation !== undefined && !prepared.logKeys.some((key) => key.keyId === rotation.keyId)) {
841
892
  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
893
  throw new Error(prepared.superseded);
843
894
  }
844
895
  for (const check of underLock) await check(locked);
845
- });
896
+ }, head === undefined ? undefined : { head, last: head.last, at: held!.at });
897
+ // The head still held, as this append left it, for the next append of the same transaction: a remote key's intent,
898
+ // then the decision's reports.
899
+ if (head !== undefined) {
900
+ held!.head = { ...head, nextSeq: appended.nextSeq, headHash: appended.headHash, last: { seq: appended.nextSeq - 1n, hash: appended.headHash } };
901
+ }
902
+ return appended;
846
903
  }
847
904
 
848
905
  // --- keys -------------------------------------------------------------------
@@ -906,10 +963,10 @@ class VaultService implements Vault {
906
963
  secrets: items.map((item) => item.secret),
907
964
  // Through a reference, the reader's grants on the holder's environment decide.
908
965
  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
- },
966
+ // Which items are read through a reference: a sensitive one is never copied, which would move it out of its project.
967
+ viaReference: sealed.map((seal) => seal !== null),
968
+ references: ends,
969
+ vet: ends.length === 0 ? undefined : async (_, reads) => sealed.map((seal) => (seal !== null && reads.ended.has(seal.seq) ? 'ended' : null)),
913
970
  remote,
914
971
  entry,
915
972
  input,
@@ -975,16 +1032,22 @@ class VaultService implements Vault {
975
1032
  ...keyEntry('key.rewrap', principal, source.secret, 'deny', 'bad_claim', input), secretVersionId: source.id,
976
1033
  })));
977
1034
  }
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))) {
1035
+ // A value is made looser only by writing it again: a sensitive value's key, or one of a secret whose newest version
1036
+ // is, is never wrapped again as anything but sensitive, so that a person who may not read it never makes it readable.
1037
+ // Nor a version whose value was made sensitive since, in place: the newest version of its very ciphertext. But for one:
1038
+ // a key's current sensitive value made a secret, in place, by an Owner of its project, who reads it anyway through a
1039
+ // service account they give read (R30). Older versions stay as they were held.
1040
+ const loosens = items.map((item, i) => item.secret.sensitive !== true && loaded.versions[i].secret.sensitive === true && loaded.versions[i].current);
1041
+ const strict = (i: number) => loaded.versions[i].secret.sensitive || loaded.versions[i].heldSensitive || loaded.versions[i].newestSensitive;
1042
+ if (items.some((item, i) => !loosens[i] && item.secret.sensitive !== true && strict(i))) {
981
1043
  return this.#refuseVersions(principal, 'invalid', loaded.versions.map((source) => ({
982
1044
  ...keyEntry('key.rewrap', principal, source.secret, 'deny', 'invalid', input), secretVersionId: source.id,
983
1045
  })), LOOSER);
984
1046
  }
985
1047
  const sources = new Map(items.map((item, i) => [item.secret, loaded.versions[i]]));
1048
+ const loosened = new Set(items.flatMap((item, i) => (loosens[i] ? [item.secret] : [])));
986
1049
  const entry = (secret: SecretRef, decision: 'allow' | 'deny', code: string | null): NewEntry => ({
987
- ...keyEntry('key.rewrap', principal, secret, decision, code, input, { from: sources.get(secret)!.secret.version }),
1050
+ ...keyEntry('key.rewrap', principal, secret, decision, code, input, { from: sources.get(secret)!.secret.version, ...(loosened.has(secret) ? { was: 'sensitive' } : {}) }),
988
1051
  secretVersionId: sources.get(secret)!.id,
989
1052
  });
990
1053
  const remote =
@@ -992,11 +1055,19 @@ class VaultService implements Vault {
992
1055
  return this.#keys(
993
1056
  {
994
1057
  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.
1058
+ // Again in the decision and under the log's head: a sensitive version written since it was loaded is as newest as any,
1059
+ // a value made sensitive in place since is so, and a value loosened is still its key's current one.
996
1060
  vet: async (db) => {
997
1061
  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));
1062
+ // Each source again: current as the decision had it, its key's pointer and its newest, and its value as held now.
1063
+ const again = new Map((await store.versions(db, items.map(({ secret }) => sources.get(secret)!.id))).map((version) => [version.id, version]));
1064
+ return items.map(({ secret }, i) => {
1065
+ const now = again.get(sources.get(secret)!.id);
1066
+ if (loosens[i]) return now?.current === true ? null : 'invalid';
1067
+ return secret.sensitive !== true && (newest.has(secret.secretId) || now?.heldSensitive === true) ? 'invalid' : null;
1068
+ });
999
1069
  },
1070
+ owns: loosens,
1000
1071
  // An old value brought back, not the current one, is made live by whoever could have read it: a secret by its
1001
1072
  // readers, a sensitive one, which nobody reads, by those who use it, a plain one by any writer, as it was shown
1002
1073
  // to them. Whatever reads the key next, a reference to it included, then reads what its restorer could. Typing a
@@ -1095,16 +1166,23 @@ class VaultService implements Vault {
1095
1166
  secrets: readonly SecretRef[];
1096
1167
  /** Where each secret's permission is checked, when not where it is: a reference's holder. */
1097
1168
  places?: readonly SecretPlace[];
1169
+ /** The `reference.create` entries `vet` asks about: read with the decision (`Snapshot`'s `ended`). */
1170
+ references?: readonly bigint[];
1098
1171
  /**
1099
1172
  * What else refuses each secret, checked in the decision and again
1100
- * once the log's head is locked, before its entries append.
1173
+ * once the log's head is locked, before its entries append: from the
1174
+ * decision's reads, or the database.
1101
1175
  */
1102
- vet?: (db: Queryable) => Promise<(RefusalCode | null)[]>;
1176
+ vet?: (db: Queryable, reads: Snapshot) => Promise<(RefusalCode | null)[]>;
1103
1177
  /**
1104
1178
  * Any one of which each secret takes besides `permission`, where it
1105
1179
  * is, read in the decision and again under the head; none for null.
1106
1180
  */
1107
1181
  needs?: (db: Queryable) => Promise<(readonly Permission[] | null)[]>;
1182
+ /** Each secret that takes besides its reader being an Owner of its project (`ownsProject`): a sensitive value made a secret. */
1183
+ owns?: readonly boolean[];
1184
+ /** Which secrets are read through a reference, by index: a sensitive one of these is never copied (it would leave its project). */
1185
+ viaReference?: readonly boolean[];
1108
1186
  remote: boolean;
1109
1187
  entry: (secret: SecretRef, decision: 'allow' | 'deny', code: string | null) => NewEntry;
1110
1188
  input: Correlation & Through & { purpose?: string; keepsSensitive?: boolean };
@@ -1128,48 +1206,25 @@ class VaultService implements Vault {
1128
1206
  // Each personal config's owner, hashed, by environment, as the decision read them: what its keys' contexts bind.
1129
1207
  const owners = new Map<string, string>();
1130
1208
  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);
1209
+ const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), call.input);
1132
1210
  // One read of the environments the decision is about, as the store has them, not as the app sent them:
1133
1211
  // each secret's and, for a read through a reference, its holder's. A grant on one slug in every project
1134
1212
  // matches the slug there, and a place deleted on either side opens for none.
1135
1213
  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);
1140
- const gone = (place: SecretPlace) => environments.get(place.environmentId)?.deleted ?? false;
1214
+ // Each a personal config is a config of too, whose read its owner needs (`places`).
1215
+ const environments = d.reads.environments;
1141
1216
  owners.clear();
1142
1217
  for (const [id, environment] of environments) if (environment.owner !== null) owners.set(id, ownerKey(environment.owner));
1143
1218
  const needs = call.needs === undefined ? [] : await call.needs(d.tx);
1144
- const codes: (RefusalCode | null)[] = secrets.map((secret, i) => {
1145
- const personal = personalRefusal(reader, principal, call.permission, secret, at[i]!, environments);
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`).
1156
- return refuses(reader, call.permission, placeIn(at[i]!, environments), gone(secret) || gone(at[i]!));
1219
+ let codes = keyRefusals(reader, { principal, permission: call.permission, secrets, at, environments, needs, owns: call.owns });
1220
+ if (call.vet !== undefined && !codes.some((code) => code !== null)) codes = await call.vet(d.tx, d.reads);
1221
+ // Where each value is held, the secret itself, a reference's source or a fork's parent: what a workload lists.
1222
+ codes = useOnlyRefusals(codes, reader, { action, purpose: call.input.purpose, secrets });
1223
+ codes = sensitiveRefusals(codes, reader, {
1224
+ action, principal, purpose: call.input.purpose, keepsSensitive: call.input.keepsSensitive, secrets, viaReference: call.viaReference,
1157
1225
  });
1158
- if (call.vet !== undefined && !codes.some((code) => code !== null)) {
1159
- (await call.vet(d.tx)).forEach((code, i) => (codes[i] = code));
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
- }
1171
1226
  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';
1227
+ if (first === null && action === 'secret.read' && this.#overBulkLimit(d)) first = 'bulk_limit';
1173
1228
  if (first !== null) {
1174
1229
  throw new Refused(
1175
1230
  refusal(first, MESSAGES[first]),
@@ -1178,6 +1233,12 @@ class VaultService implements Vault {
1178
1233
  }
1179
1234
  };
1180
1235
 
1236
+ // Every decision below reads the places it is about with the reader's standing; a read, the reader's releases too.
1237
+ const reads: Reads = {
1238
+ environments: [...secrets, ...secrets.map((secret, i) => call.places?.[i] ?? secret)].map((place) => place.environmentId),
1239
+ references: call.references ?? [],
1240
+ ...(action === 'secret.read' ? { released: { principal, keys: secrets.map((secret) => secret.secretId) } } : {}),
1241
+ };
1181
1242
  // The intent's own identity; `operationId` is the app's, for the whole action.
1182
1243
  const intentId = call.remote ? randomUUID() : null;
1183
1244
  let intentSeq: bigint | null = null;
@@ -1197,7 +1258,7 @@ class VaultService implements Vault {
1197
1258
  if (call.remote) {
1198
1259
  const intent = await this.#decide([principal], async (d) => {
1199
1260
  await check(d);
1200
- const at = await this.#now(d.tx);
1261
+ const at = d.at;
1201
1262
  const appended = await this.#append(d.tx, [{
1202
1263
  actor: principal,
1203
1264
  action: 'key.intent',
@@ -1213,9 +1274,9 @@ class VaultService implements Vault {
1213
1274
  ...traced(call.input),
1214
1275
  keys: secrets.map((secret, item) => ({ item, subject: secret.path, secretId: secret.secretId, version: secret.version })),
1215
1276
  }),
1216
- }]);
1277
+ }], [], d.reads);
1217
1278
  return { seq: appended.seqStart };
1218
- });
1279
+ }, false, reads);
1219
1280
  if (!intent.ok) return intent;
1220
1281
  intentSeq = intent.seq;
1221
1282
  }
@@ -1242,7 +1303,7 @@ class VaultService implements Vault {
1242
1303
  const { vet } = call;
1243
1304
  if (late && vet !== undefined) {
1244
1305
  d.underLock.push(async (locked) => {
1245
- const codes = await vet(locked);
1306
+ const codes = await vet(locked, d.reads);
1246
1307
  const first = codes.find((code) => code !== null);
1247
1308
  if (first === undefined || first === null) return;
1248
1309
  throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, 'deny', codes[item] ?? first)));
@@ -1272,13 +1333,13 @@ class VaultService implements Vault {
1272
1333
  // deletion renames its place under it, so one that committed since the
1273
1334
  // check above is seen here, and nothing is released after it.
1274
1335
  // A read through a reference: its holder's place too, which a deletion may have taken.
1275
- await lockLogHead(d.tx);
1276
1336
  // 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.
1337
+ // since the check above is seen here, and nothing it refuses now is released. All of it read again, holding the head.
1338
+ d.reads = await this.#snapshot(d.tx, [principal], reads, true);
1339
+ d.at = this.#at(d.reads);
1278
1340
  await decided(d);
1279
1341
  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)) {
1342
+ if (places.some((place) => d.reads.environments.get(place.environmentId)?.deleted ?? false)) {
1282
1343
  throw new Refused(refusal('deleted', MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, 'deny', 'deleted')));
1283
1344
  }
1284
1345
  }
@@ -1291,13 +1352,17 @@ class VaultService implements Vault {
1291
1352
  } finally {
1292
1353
  for (const value of done) value.wipe();
1293
1354
  }
1294
- }, late);
1355
+ }, late, reads);
1295
1356
  }
1296
1357
 
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;
1358
+ /**
1359
+ * Whether this read's keys would take the reader past the bulk limit: the
1360
+ * distinct keys released to them in the window, these among them. A key
1361
+ * read again exposes nothing new, so it counts once, whatever its version.
1362
+ * Counted under their row's lock, so exactly (`Reads`).
1363
+ */
1364
+ #overBulkLimit(d: Decision): boolean {
1365
+ return d.reads.released > this.#config.bulkLimit.count;
1301
1366
  }
1302
1367
 
1303
1368
  // --- references ---------------------------------------------------------------
@@ -1349,7 +1414,7 @@ class VaultService implements Vault {
1349
1414
  return this.#decide([principal], async (d) => {
1350
1415
  // Under the log's head, which a deletion and every change to the policy take: neither side deleted, nor protected,
1351
1416
  // 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);
1417
+ const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), input);
1353
1418
  // Each source's nature, its current version's, under the head: what its maker must hold there.
1354
1419
  const natures = await store.natures(d.tx, [...sources.values()].map((source) => source.secretId));
1355
1420
  // Both sides as the store has them: a grant on a slug matches there, and neither side may be deleted.
@@ -1357,19 +1422,11 @@ class VaultService implements Vault {
1357
1422
  const source = sources.get(item.source.secretId);
1358
1423
  return source === undefined ? [item.holder.environmentId] : [item.holder.environmentId, source.environmentId];
1359
1424
  }));
1360
- const gone = (place: SecretPlace) => environments.get(place.environmentId)?.deleted ?? false;
1361
1425
  const codes = items.map((item): RefusalCode | null => {
1362
1426
  const source = sources.get(item.source.secretId);
1363
1427
  if (source === undefined || projectOf.get(item.holder.environmentId) !== item.holder.projectId) return 'bad_claim';
1364
1428
  if (source.secretId === item.holder.secretId) return 'invalid';
1365
- // A personal config's values are bound to its owner: no reference holds one, nor reads one.
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);
1370
- return refuses(reader, 'secret.write', placeIn(item.holder, environments), gone(item.holder))
1371
- ?? refuses(reader, 'secret.read', at, gone(source))
1372
- ?? (natures.get(source.secretId) === 'sensitive' ? refuses(reader, 'secret.use', at, gone(source)) : null);
1429
+ return referenceRefusal(reader, item.holder, source, environments, natures.get(source.secretId) ?? 'secret');
1373
1430
  });
1374
1431
  const first = codes.find((code) => code !== null) ?? null;
1375
1432
  if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), items.map((item, i) => entry(item, 'deny', codes[i] ?? first)));
@@ -1436,7 +1493,7 @@ class VaultService implements Vault {
1436
1493
  };
1437
1494
  return this.#decide([principal], async (d) => {
1438
1495
  // 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);
1496
+ const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), input);
1440
1497
  // Ending is allowed in a deleted place: deleting one ends the references into and out of it.
1441
1498
  const environments = await store.environmentsById(d.tx, seals.map((seal) => seal.holder.environmentId));
1442
1499
  const codes: (RefusalCode | null)[] = seals.map((seal) => {
@@ -1552,7 +1609,7 @@ class VaultService implements Vault {
1552
1609
  */
1553
1610
  async #makeOwner(d: Decision, principal: string, reason: 'owners'): Promise<boolean> {
1554
1611
  const row = d.members.get(principal);
1555
- const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
1612
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
1556
1613
  if (subject.status === 'active' && ownsInstance(subject.live)) return false;
1557
1614
  const tampered = subject.status === 'tampered';
1558
1615
  if (tampered && (await this.#logged(d.tx, principal)) === undefined) return false;
@@ -1584,17 +1641,17 @@ class VaultService implements Vault {
1584
1641
  * What `principal` holds, their row checked first (`#integrity`): a row
1585
1642
  * that fails holds nothing, and is `tampered`.
1586
1643
  */
1587
- async #standing(db: Queryable, principal: string, row: Member | undefined, at: number, reports: NewEntry[]): Promise<Standing> {
1644
+ async #standing(db: Queryable, principal: string, row: Member | undefined, at: number, reports: NewEntry[], reads?: Snapshot): Promise<Standing> {
1588
1645
  const none = NOTHING;
1589
- const grants = row === undefined ? [] : await store.grants(db, principal);
1590
- const fault = await this.#integrity(db, principal, row, grants, reports);
1646
+ const grants = row === undefined ? [] : reads?.grants.get(principal) ?? (await store.grants(db, principal));
1647
+ const fault = await this.#integrity(db, principal, row, grants, reports, reads);
1591
1648
  if (fault !== null) return { principal, status: 'tampered', live: none, all: none, fault, stored: grants };
1592
1649
  if (!PRINCIPAL.test(principal)) return { principal, status: 'unknown', live: none, all: none, fault, stored: grants };
1593
1650
  if (row?.status !== 'active') return { principal, status: row?.status ?? 'unknown', live: none, all: none, fault, stored: grants };
1594
1651
  const held = grants.map((grant) => ({ ...grant, role: grant.role as Role }));
1595
1652
  const { role, scope } = roleOf(principal, row);
1596
1653
  // 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);
1654
+ const { policy } = await this.#policyAt(db, reports, at, reads);
1598
1655
  const service = !principal.startsWith('user:');
1599
1656
  return {
1600
1657
  principal,
@@ -1642,9 +1699,10 @@ class VaultService implements Vault {
1642
1699
  /** `access`, as the rows are now. */
1643
1700
  async #read(principal: string, superseded: string | null): Promise<Access> {
1644
1701
  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);
1702
+ const reads = await snapshot(db, { principals: [principal], members: true });
1703
+ const [row, held, at] = [reads.members.get(principal), reads.grants.get(principal)!, this.#at(reads)];
1704
+ const fault = await this.#integrity(db, principal, row, held, reports, reads);
1705
+ const { policy } = await this.#policyAt(db, reports, at, reads);
1648
1706
  return this.#access(principal, row, held, at, fault !== null, policy);
1649
1707
  };
1650
1708
  const found: NewEntry[] = [];
@@ -1724,7 +1782,7 @@ class VaultService implements Vault {
1724
1782
  // renames the place, so the place is as it commits.
1725
1783
  // The roles as the policy defines them now, under the head every change to it takes: a role an Owner made is
1726
1784
  // 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);
1785
+ const { policy } = await this.#policyAt(d.tx, d.reports, d.at, d.reads);
1728
1786
  for (const change of changes) {
1729
1787
  if (change.role !== null && !isRole(change.role, policy)) throw refused('invalid', `no such role: ${change.role}`);
1730
1788
  if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role, policy)) {
@@ -1748,7 +1806,7 @@ class VaultService implements Vault {
1748
1806
  // An environment by its slug as the store has it, which scopes match.
1749
1807
  return environmentId === null ? { projectId: projectId! } : { projectId: projectId!, environmentId, environmentSlug: environment!.slug };
1750
1808
  });
1751
- const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
1809
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
1752
1810
  if (acting.status === 'tampered') throw refused('tampered');
1753
1811
  // Where they manage access, anything; otherwise only a person to a service account (`givesService`).
1754
1812
  const manages = where.every((place) => mayManageAccess(acting.live, place));
@@ -1759,7 +1817,7 @@ class VaultService implements Vault {
1759
1817
  }
1760
1818
 
1761
1819
  const row = d.members.get(principal);
1762
- const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
1820
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
1763
1821
  if (subject.status === 'tampered') throw refused('tampered', TAMPERED_SUBJECT);
1764
1822
  // A service account admitted with its first grants, as `admit` would: all of it, or none.
1765
1823
  const admitted = input.admit === true && row?.status !== 'active' ? await this.#admitWith(d, acting, principal, row, input) : undefined;
@@ -1859,7 +1917,7 @@ class VaultService implements Vault {
1859
1917
  ]);
1860
1918
  return this.#decide([actor, principal], async (d) => {
1861
1919
  validateCorrelation(input);
1862
- const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
1920
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
1863
1921
  if (acting.status === 'tampered') throw refused('tampered');
1864
1922
  // A service account, with no grant yet, any person who could give it one sets up (`setsUpServices`).
1865
1923
  const service = SERVICE.test(principal) && (input.role ?? 'member') === 'member' && input.scope === undefined;
@@ -1872,7 +1930,7 @@ class VaultService implements Vault {
1872
1930
  throw refused('invalid', 'service accounts hold project grants only, never an instance role');
1873
1931
  }
1874
1932
  const row = d.members.get(principal);
1875
- const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
1933
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
1876
1934
  if (subject.status === 'tampered') throw refused('tampered', TAMPERED_SUBJECT);
1877
1935
  d.touched.add(principal);
1878
1936
  d.grants.set(principal, [...subject.stored]);
@@ -1964,10 +2022,10 @@ class VaultService implements Vault {
1964
2022
  validateCorrelation(input);
1965
2023
  if (!PRINCIPAL.test(principal)) throw refused('invalid', `not a member: ${shownMember(principal)}`);
1966
2024
  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);
2025
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
1968
2026
  if (acting.status === 'tampered') throw refused('tampered');
1969
2027
  const holder = acting.live;
1970
- const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
2028
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
1971
2029
  const held = subject.stored;
1972
2030
  if (this.#listed(principal)) throw refused('not_allowed', declaredOwner(principal));
1973
2031
  if (subject.status === 'tampered') {
@@ -2079,14 +2137,19 @@ class VaultService implements Vault {
2079
2137
  * no role holds anything but Owner, otherwise whoever can insert rows
2080
2138
  * could unprotect them. Its edits are then unknown, not none.
2081
2139
  */
2082
- async #policyAt(db: Queryable, reports: NewEntry[], at: number): Promise<{ policy: Policy; edits: PolicyEdits; readable: boolean }> {
2140
+ async #policyAt(db: Queryable, reports: NewEntry[], at: number, reads?: Snapshot): Promise<{ policy: Policy; edits: PolicyEdits; readable: boolean }> {
2141
+ // The snapshot's newest first, with the rows it protects; the pages before it, as they are asked.
2083
2142
  const entry = await newestAuthentic(
2084
- (limit, before) => store.newestVaultEntries(db, POLICY, limit, before),
2143
+ (limit, before) => (reads !== undefined && before === undefined
2144
+ ? Promise.resolve(reads.newestPolicy === null ? [] : [reads.newestPolicy])
2145
+ : store.newestVaultEntries(db, POLICY, limit, before)),
2085
2146
  this.#check(reports, null),
2086
2147
  );
2087
2148
  if (entry === 'none') return { policy: DEFAULT_POLICY, edits: NO_EDITS, readable: true };
2088
2149
  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 };
2150
+ if (edits === null) return { policy: CLOSED_POLICY, edits: NO_EDITS, readable: false };
2151
+ const rows = entry === reads?.newestPolicy ? reads.environments : await store.environmentsById(db, edits.protected.map((place) => place.environmentId));
2152
+ return { policy: livePolicy(rows, edits, at), edits, readable: true };
2090
2153
  }
2091
2154
 
2092
2155
  changePolicy(input: ChangePolicyInput): Promise<Outcome<{ policy: Policy; seq: number | null }>> {
@@ -2109,14 +2172,15 @@ class VaultService implements Vault {
2109
2172
  new Refused(refusal(code, message), [entry('deny', { change: describeChange(change) }, code)]);
2110
2173
  return this.#decide([actor], async (d) => {
2111
2174
  validateCorrelation(input);
2112
- const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
2175
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
2113
2176
  if (acting.status === 'tampered') throw refused('tampered');
2114
2177
  if (acting.status !== 'active') throw refused(acting.status === 'removed' ? 'removed' : 'not_a_member');
2115
2178
  // Under the log's head, as the actor's standing above: two changes at once each start from the one before. One the
2116
2179
  // caller weighed from a revision of the policy, its newest change and its protected environments' rows, starts
2117
2180
  // 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);
2181
+ // The newest change in the vault's name, read with the decision, as the app reads it: by its number, unchecked.
2182
+ const newest = d.reads.newestPolicy?.seq ?? null;
2183
+ const { policy: before, edits, readable } = await this.#policyAt(d.tx, d.reports, d.at, d.reads);
2120
2184
  if (revision !== undefined && policyRevision(newest, before) !== revision) throw refused('policy_changed');
2121
2185
  const owner = acting.live.credentialScope == null && ownsInstance(acting.live);
2122
2186
  // Unreadable, its edits are unknown: a change made from none would erase every protection and role edit the forged
@@ -2180,15 +2244,16 @@ class VaultService implements Vault {
2180
2244
  next.protected = [...next.protected, ...change.environmentIds.filter((id) => !known.has(id)).map((environmentId) => ({ projectId: change.projectId, environmentId, at: d.at }))]
2181
2245
  .sort((a, b) => (a.environmentId < b.environmentId ? -1 : 1));
2182
2246
  } 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));
2247
+ // Unprotecting widens what people hold there: an Owner of the project's to do, or of the whole instance's (R30).
2248
+ // It takes off only what the project protects: a fork's id, with no row yet, is another project's to unprotect.
2249
+ if (!ownsProject(acting.live, change.projectId)) throw refused('not_allowed', 'only an Owner of its project, or of the whole instance, unprotects an environment');
2250
+ next.protected = next.protected.filter((place) => place.projectId !== change.projectId || !change.environmentIds.includes(place.environmentId));
2186
2251
  }
2187
2252
  } else {
2188
2253
  throw refused('invalid', 'a change is to a role, an instance role, or protection');
2189
2254
  }
2190
2255
  // 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) };
2256
+ 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
2257
  if (!readable || JSON.stringify(next) !== JSON.stringify(edits)) {
2193
2258
  const made = entry('allow', { policy: next, change: describeChange(change), before: describeBefore(change, before) });
2194
2259
  d.log.push(made);
@@ -2213,7 +2278,7 @@ class VaultService implements Vault {
2213
2278
  new Refused(refusal(code, message), [entry('deny', { serviceAccounts: input.settings?.serviceAccounts ?? null }, code)]);
2214
2279
  return this.#decide([actor], async (d) => {
2215
2280
  validateCorrelation(input);
2216
- const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
2281
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
2217
2282
  if (acting.status === 'tampered') throw refused('tampered');
2218
2283
  if (!runsInstance(acting.live)) throw refused('not_allowed', "only admins and owners of the whole instance change its settings");
2219
2284
  if (!isScope(input.settings?.serviceAccounts)) throw refused('invalid', 'who sets up service accounts is a scope: { projects, environments }, each all, only or except');
@@ -3051,64 +3116,6 @@ function viaDetail(via: Via, seal: Sealed | null): Record<string, unknown> {
3051
3116
  };
3052
3117
  }
3053
3118
 
3054
- /** An environment a secret is in, or that holds a reference to one. */
3055
- type SecretPlace = { projectId: string; environmentId: string };
3056
-
3057
- /**
3058
- * Where a decision checks a permission: an environment, with its slug as
3059
- * the store has it when it was read and is in the project claimed;
3060
- * otherwise none, which no grant on a slug matches.
3061
- */
3062
- function placeIn(place: SecretPlace, environments: ReadonlyMap<string, { projectId: string; slug: string }>): Place {
3063
- const environment = environments.get(place.environmentId);
3064
- const slug = environment?.projectId === place.projectId ? environment.slug : null;
3065
- return { projectId: place.projectId, environmentId: place.environmentId, environmentSlug: slug };
3066
- }
3067
-
3068
- /**
3069
- * Why `reader` may not do `permission` at `where`, or null if they may. A
3070
- * deleted place is refused before any grant is asked, so no kind of grant,
3071
- * on the project, the environment or every project, can reach into one.
3072
- */
3073
- /**
3074
- * A key of a personal config is its owner's alone (docs/design/forking.md,
3075
- * section 4): no grant or role opens or wraps it, an Owner's included,
3076
- * only its owner, while they read the environment it is a config of,
3077
- * whatever the call is. Nor does any reference reach into or out of one.
3078
- * Undefined for a key of any other environment, decided as ever.
3079
- */
3080
- function personalRefusal(
3081
- reader: Standing,
3082
- principal: string,
3083
- permission: Permission,
3084
- secret: SecretPlace,
3085
- at: SecretPlace,
3086
- environments: ReadonlyMap<string, store.EnvironmentRow>,
3087
- ): RefusalCode | null | undefined {
3088
- const own = environments.get(secret.environmentId);
3089
- const held = at.environmentId === secret.environmentId ? own : environments.get(at.environmentId);
3090
- if (own?.owner == null && held?.owner == null) return undefined;
3091
- if (own !== held || principal !== own?.owner || own.parentId === null) return 'personal';
3092
- const parent = placeIn({ projectId: secret.projectId, environmentId: own.parentId }, environments);
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);
3096
- if (refused !== null) return refused;
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';
3100
- }
3101
-
3102
- function refuses(reader: Standing, permission: Permission, where: Place, deleted: boolean): RefusalCode | null {
3103
- if (reader.status === 'tampered') return 'tampered';
3104
- if (reader.status === 'removed') return 'removed';
3105
- if (reader.status === 'unknown') return 'not_a_member';
3106
- if (deleted) return 'deleted';
3107
- if (allows(reader.live, permission, where)) return null;
3108
- // Would a grant that has lapsed have covered it?
3109
- return allows(reader.all, permission, where) ? 'expired' : 'no_grant';
3110
- }
3111
-
3112
3119
  /**
3113
3120
  * An entry about a key. A new secret's row is not committed when its key is
3114
3121
  * wrapped, so a wrap names the secret in its payload; the others name it.
@@ -3320,8 +3327,7 @@ function readEdits(stored: { policy?: unknown }): PolicyEdits | null {
3320
3327
  * keeps its slug, which a scoped instance role's filters read. The edits
3321
3328
  * keep them all: no write is needed.
3322
3329
  */
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));
3330
+ function livePolicy(rows: ReadonlyMap<string, store.EnvironmentRow>, edits: PolicyEdits, at: number): Policy {
3325
3331
  const live = edits.protected.filter((place) => {
3326
3332
  const row = rows.get(place.environmentId);
3327
3333
  return row === undefined ? at - place.at < UNWRITTEN_FORK_MS : !row.deleted;