@coffre/vault 0.3.0 → 0.4.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
@@ -3,14 +3,19 @@ import { randomUUID, timingSafeEqual } from 'node:crypto';
3
3
  import {
4
4
  allows,
5
5
  assignableToEnvironment,
6
+ everyProjectPath,
7
+ grantKind,
6
8
  isRole,
7
9
  mayManageAccess,
10
+ type GrantPlace,
8
11
  type Holdings,
9
12
  type Permission,
13
+ type Place,
10
14
  type Role,
11
15
  } from '@coffre/core/access';
12
16
  import { GENESIS_HASH, verifyEntries, type LogKey, type StoredEntry } from '@coffre/core/audit';
13
17
  import { checkContext, type SecretContext } from '@coffre/core/envelope';
18
+ import { shownMember } from '@coffre/core/schemas';
14
19
  import {
15
20
  DEK_BYTES,
16
21
  KEY_CHECK,
@@ -34,12 +39,14 @@ import {
34
39
  type AdmitInput,
35
40
  type Checkpoint,
36
41
  type CheckpointKey,
42
+ type EndReferencesInput,
37
43
  type Grant,
38
44
  type GrantChange,
39
45
  type KeyChecks,
40
46
  type LogHead,
41
47
  type LogVerification,
42
48
  type Outcome,
49
+ type ReferenceInput,
43
50
  type Refusal,
44
51
  type RefusalCode,
45
52
  type RemoveInput,
@@ -48,6 +55,7 @@ import {
48
55
  type SetAccessInput,
49
56
  type UnwrapInput,
50
57
  type Vault,
58
+ type Via,
51
59
  type VerifyLogInput,
52
60
  type WrapInput,
53
61
  type WrappedKey,
@@ -148,9 +156,8 @@ const KEY_BUDGET_MS = 5_000;
148
156
  /** How long a decision waits for a lock: above the key budget, so a removal outwaits a read in flight. */
149
157
  const LOCK_TIMEOUT_MS = 15_000;
150
158
 
151
- // Historical principals remain valid audit subjects and sealed rows.
152
- const PRINCIPAL = /^(user|token|sync):[^\s:][^\s]*$/;
153
- const LIVE_PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
159
+ /** A member: `user:<email>` or `token:<name>`. */
160
+ const PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
154
161
 
155
162
  /** Who acts for the vault itself, as when it gives a root admin a member row. */
156
163
  const VAULT_ACTOR = 'system:vault';
@@ -225,6 +232,8 @@ const MESSAGES: Record<RefusalCode, string> = {
225
232
  expired: 'the grant that covered this has expired',
226
233
  bulk_limit: 'too many secrets read in too short a time',
227
234
  bad_claim: 'the key does not belong to this secret',
235
+ deleted: 'this project or environment was deleted',
236
+ ended: 'the reference read through was ended',
228
237
  not_allowed: 'not allowed to change this',
229
238
  root_admin: 'root admins are set in the vault configuration',
230
239
  invalid: 'not something the rules allow',
@@ -259,6 +268,13 @@ type Decision = {
259
268
  reports: NewEntry[];
260
269
  /** Run once the entries are appended, with the seq each was given. */
261
270
  after: ((seqOf: (entry: NewEntry) => number) => void)[];
271
+ /**
272
+ * Checks made again once the log's head is locked, just before this
273
+ * decision's entries append: what another decision could have changed
274
+ * since it checked, such as a reference ended while a read through it
275
+ * was deciding. A `Refused` there refuses the decision.
276
+ */
277
+ underLock: ((locked: Queryable) => Promise<void>)[];
262
278
  };
263
279
 
264
280
  /** The actions of the entries the vault writes about keys. */
@@ -348,7 +364,7 @@ class VaultService implements Vault {
348
364
  await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
349
365
  const members = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals);
350
366
  const d: Decision = {
351
- tx, members, at: await this.#now(tx), log: [], writes: [], touched: new Set(), grants: new Map(), reports, after: [],
367
+ tx, members, at: await this.#now(tx), log: [], writes: [], touched: new Set(), grants: new Map(), reports, after: [], underLock: [],
352
368
  };
353
369
  const result = await decide(d);
354
370
  const entries = [...reports, ...d.log];
@@ -356,7 +372,7 @@ class VaultService implements Vault {
356
372
  // Each member's newest access entry, which their row names (rows.ts).
357
373
  const accessSeq = new Map<string, bigint>();
358
374
  if (entries.length > 0) {
359
- const appended = await this.#append(tx, entries);
375
+ const appended = await this.#append(tx, entries, d.underLock);
360
376
  at = appended.occurredAt;
361
377
  entries.forEach((entry, i) => {
362
378
  if (isAccessEntry(entry)) accessSeq.set(entry.subjectPrincipal!, appended.seqStart + BigInt(i));
@@ -585,13 +601,15 @@ class VaultService implements Vault {
585
601
  * that an instance still running with the replaced KEK, as during a
586
602
  * deploy, cannot write after the rotation.
587
603
  */
588
- #append(tx: Transaction, entries: readonly NewEntry[]): Promise<Appended> {
604
+ #append(tx: Transaction, entries: readonly NewEntry[], underLock: readonly ((locked: Queryable) => Promise<void>)[] = []): Promise<Appended> {
589
605
  const prepared = this.#prepared;
590
606
  return appendEntries(tx, prepared.logKey, entries, async (locked) => {
591
607
  const rotation = await store.latestVaultEntry(locked, [KEY_ROTATE]);
592
- if (rotation === undefined || prepared.logKeys.some((key) => key.keyId === rotation.keyId)) return;
593
- 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`;
594
- throw new Error(prepared.superseded);
608
+ if (rotation !== undefined && !prepared.logKeys.some((key) => key.keyId === rotation.keyId)) {
609
+ 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`;
610
+ throw new Error(prepared.superseded);
611
+ }
612
+ for (const check of underLock) await check(locked);
595
613
  });
596
614
  }
597
615
 
@@ -602,18 +620,44 @@ class VaultService implements Vault {
602
620
  async unwrap(input: UnwrapInput): Promise<Outcome<{ keys: string[] }>> {
603
621
  const { principal } = input;
604
622
  validateText(input.purpose);
623
+ const vias = input.items.map((item) => validateVia(item.via));
605
624
  const loaded = await this.#versions(input, 'secret.read', { purpose: input.purpose });
606
625
  if (!loaded.ok) return loaded;
607
- const items = loaded.versions;
626
+ // RPC can preserve shared objects, and two holders can read one source: each item its own secret.
627
+ const items = loaded.versions.map((version) => ({ ...version, secret: { ...version.secret } }));
608
628
  validateItems(items, 'wrapped');
629
+ const sealed = await this.#referencesAt(vias);
609
630
  const versionIds = new Map(items.map((item) => [item.secret, item.id]));
610
- const entry = (secret: SecretRef, decision: 'allow' | 'deny', code: string | null): NewEntry => ({
611
- ...keyEntry('secret.read', principal, secret, decision, code, input, { purpose: input.purpose }),
612
- secretVersionId: versionIds.get(secret),
613
- });
631
+ const viaOf = new Map(items.map((item, i) => [item.secret, { via: vias[i], sealed: sealed[i] }]));
632
+ const entry = (secret: SecretRef, decision: 'allow' | 'deny', code: string | null): NewEntry => {
633
+ const { via, sealed: seal } = viaOf.get(secret)!;
634
+ return {
635
+ ...keyEntry('secret.read', principal, secret, decision, code, input, { purpose: input.purpose, ...(via === null ? {} : viaDetail(via, seal)) }),
636
+ secretVersionId: versionIds.get(secret),
637
+ };
638
+ };
639
+ // A reference reads only the current version of the source its entry names.
640
+ if (items.some((item, i) => vias[i] !== null && (sealed[i] === null || sealed[i]!.source.secretId !== item.secret.secretId || !item.current))) {
641
+ return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')));
642
+ }
643
+ const ends = sealed.flatMap((seal) => (seal === null ? [] : [seal.seq]));
614
644
  const remote = items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
615
645
  return this.#keys(
616
- { action: 'secret.read', principal, permission: 'secret.read', secrets: items.map((item) => item.secret), remote, entry, input },
646
+ {
647
+ action: 'secret.read',
648
+ principal,
649
+ permission: 'secret.read',
650
+ secrets: items.map((item) => item.secret),
651
+ // Through a reference, the reader's grants on the holder's environment decide.
652
+ places: sealed.map((seal, i) => seal?.holder ?? items[i]!.secret),
653
+ vet: ends.length === 0 ? undefined : async (db) => {
654
+ const ended = await store.endedReferences(db, ends);
655
+ return sealed.map((seal) => (seal !== null && ended.has(seal.seq) ? 'ended' : null));
656
+ },
657
+ remote,
658
+ entry,
659
+ input,
660
+ },
617
661
  () =>
618
662
  items.map(({ secret, wrapped }) => async (operation: KeyOperation) => {
619
663
  const key = await this.#open(wrapped, secret, operation);
@@ -671,7 +715,7 @@ class VaultService implements Vault {
671
715
  const sameSecret = (secret: SecretRef, source: SecretRef) =>
672
716
  secret.projectId === source.projectId && secret.environmentId === source.environmentId && secret.secretId === source.secretId;
673
717
  if (items.some((item, i) => !sameSecret(item.secret, loaded.versions[i].secret))) {
674
- return this.#badVersions(principal, loaded.versions.map((source) => ({
718
+ return this.#refuseVersions(principal, 'bad_claim', loaded.versions.map((source) => ({
675
719
  ...keyEntry('key.rewrap', principal, source.secret, 'deny', 'bad_claim', input), secretVersionId: source.id,
676
720
  })));
677
721
  }
@@ -698,7 +742,7 @@ class VaultService implements Vault {
698
742
  );
699
743
  }
700
744
 
701
- /** Immutable versions need no lock; their ids determine the whole batch before any key call. */
745
+ /** Versions need no lock: they change only by being erased, once their place is archived, and their ids determine the whole batch before any key call. */
702
746
  async #versions(
703
747
  input: Correlation & { principal: string; items: { secretVersionId: string }[] },
704
748
  action: KeyAction,
@@ -713,8 +757,13 @@ class VaultService implements Vault {
713
757
  }
714
758
  }
715
759
  const found = new Map((await store.versions(this.#db, ids)).map((version) => [version.id, version]));
760
+ if ([...found.values()].some((version) => version.deleted)) {
761
+ return this.#refuseVersions(input.principal, 'deleted', [...found.values()].map((version) => ({
762
+ ...keyEntry(action, input.principal, version.secret, 'deny', 'deleted', input, detail), secretVersionId: version.id,
763
+ })));
764
+ }
716
765
  if (ids.some((id) => !found.has(id))) {
717
- return this.#badVersions(input.principal, ids.map((id) => {
766
+ return this.#refuseVersions(input.principal, 'bad_claim', ids.map((id) => {
718
767
  const version = found.get(id);
719
768
  if (version === undefined) return {
720
769
  actor: input.principal, action, decision: 'deny', code: 'bad_claim',
@@ -727,10 +776,10 @@ class VaultService implements Vault {
727
776
  return { ok: true, versions: ids.map((id) => found.get(id)!) };
728
777
  }
729
778
 
730
- async #badVersions(principal: string, entries: NewEntry[]): Promise<Outcome<never>> {
779
+ async #refuseVersions(principal: string, code: 'bad_claim' | 'deleted', entries: NewEntry[]): Promise<Outcome<never>> {
731
780
  if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
732
781
  return this.#decide([principal], async () => {
733
- throw new Refused(refusal('bad_claim', MESSAGES.bad_claim), entries);
782
+ throw new Refused(refusal(code, MESSAGES[code]), entries);
734
783
  });
735
784
  }
736
785
 
@@ -766,6 +815,13 @@ class VaultService implements Vault {
766
815
  principal: string;
767
816
  permission: Permission;
768
817
  secrets: readonly SecretRef[];
818
+ /** Where each secret's permission is checked, when not where it is: a reference's holder. */
819
+ places?: readonly SecretPlace[];
820
+ /**
821
+ * What else refuses each secret, checked in the decision and again
822
+ * once the log's head is locked, before its entries append.
823
+ */
824
+ vet?: (db: Queryable) => Promise<(RefusalCode | null)[]>;
769
825
  remote: boolean;
770
826
  entry: (secret: SecretRef, decision: 'allow' | 'deny', code: string | null) => NewEntry;
771
827
  input: Correlation & { purpose?: string };
@@ -785,7 +841,17 @@ class VaultService implements Vault {
785
841
  if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
786
842
  const check = async (d: Decision) => {
787
843
  const reader = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports);
788
- const codes = secrets.map((secret) => refuses(reader, call.permission, secret));
844
+ // One read of the environments the decision is about, as the store has them, not as the app sent them:
845
+ // each secret's and, for a read through a reference, its holder's. A grant on one slug in every project
846
+ // matches the slug there, and a place deleted on either side opens for none.
847
+ const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
848
+ const environments = await store.environmentsById(d.tx, [...secrets, ...at].map((place) => place.environmentId));
849
+ const gone = (place: SecretPlace) => environments.get(place.environmentId)?.deleted ?? false;
850
+ const codes: (RefusalCode | null)[] = secrets.map((secret, i) =>
851
+ refuses(reader, call.permission, placeIn(at[i]!, environments), gone(secret) || gone(at[i]!)));
852
+ if (call.vet !== undefined && !codes.some((code) => code !== null)) {
853
+ (await call.vet(d.tx)).forEach((code, i) => (codes[i] = code));
854
+ }
789
855
  let first = codes.find((code) => code !== null) ?? null;
790
856
  if (first === null && action === 'secret.read' && (await this.#overBulkLimit(d, principal, secrets.length))) first = 'bulk_limit';
791
857
  if (first !== null) {
@@ -849,6 +915,16 @@ class VaultService implements Vault {
849
915
  }
850
916
  throw error;
851
917
  }
918
+ // A reference ended while this decided refuses it, rather than a read logged after its end.
919
+ const { vet } = call;
920
+ if (vet !== undefined) {
921
+ d.underLock.push(async (locked) => {
922
+ const codes = await vet(locked);
923
+ const first = codes.find((code) => code !== null);
924
+ if (first === undefined || first === null) return;
925
+ throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, 'deny', codes[item] ?? first)));
926
+ });
927
+ }
852
928
  const { outcomes, expired } = await settle(operations(), this.#prepared.options.keyBudgetMs);
853
929
  const done = outcomes.flatMap((outcome) => (outcome.ok ? [outcome.value] : []));
854
930
  try {
@@ -868,6 +944,16 @@ class VaultService implements Vault {
868
944
  if (done.length < outcomes.length) {
869
945
  throw new Refused(refusal('bad_claim', MESSAGES.bad_claim), entries());
870
946
  }
947
+ // Again under the log's head, the append's, taken a statement early: a
948
+ // deletion renames its place under it, so one that committed since the
949
+ // check above is seen here, and nothing is released after it.
950
+ // A read through a reference: its holder's place too, which a deletion may have taken.
951
+ await lockLogHead(d.tx);
952
+ const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
953
+ const now = await store.environmentsById(d.tx, places.map((place) => place.environmentId));
954
+ if (places.some((place) => now.get(place.environmentId)?.deleted ?? false)) {
955
+ throw new Refused(refusal('deleted', MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, 'deny', 'deleted')));
956
+ }
871
957
  const released = secrets.map((secret, i) => outcomeEntry(secret, i, 'allow', null));
872
958
  d.log.push(...released);
873
959
  const answer = result(done);
@@ -886,6 +972,184 @@ class VaultService implements Vault {
886
972
  return (await store.releasesSince(d.tx, principal, d.at - windowMs)) + n > count;
887
973
  }
888
974
 
975
+ // --- references ---------------------------------------------------------------
976
+
977
+ /**
978
+ * Make each holder a reference to its source: the actor writes the holder
979
+ * and reads the source, by their own grants, and the vault's
980
+ * `reference.create` entry is the reference. The app's row only points at
981
+ * it; a read through one is checked against the entry (`#referencesAt`).
982
+ */
983
+ async reference(input: ReferenceInput): Promise<Outcome<{ seqs: number[] }>> {
984
+ const { principal, items } = input;
985
+ validateText(principal);
986
+ validateCorrelation(input);
987
+ for (const item of items) {
988
+ if (typeof item.id !== 'string' || !UUID.test(item.id)) throw new Error('a reference id must be a lowercase UUID');
989
+ if (typeof item.source?.secretId !== 'string' || !UUID.test(item.source.secretId)) throw new Error('a source must be named by its secret id');
990
+ checkContext(item.holder);
991
+ validateText(item.holder.path);
992
+ }
993
+ if (new Set(items.map((item) => item.id)).size !== items.length) throw new Error('each reference needs its own id');
994
+ if (items.length === 0) return { ok: true, seqs: [] };
995
+ const [sources, projectOf] = await Promise.all([
996
+ store.secretsByIds(this.#db, items.map((item) => item.source.secretId)),
997
+ store.projectsOfEnvironments(this.#db, items.map((item) => item.holder.environmentId)),
998
+ ]);
999
+ const entry = (item: ReferenceInput['items'][number], decision: 'allow' | 'deny', code: string | null): NewEntry => {
1000
+ const source = sources.get(item.source.secretId);
1001
+ return {
1002
+ actor: principal,
1003
+ action: 'reference.create',
1004
+ decision,
1005
+ code,
1006
+ projectId: projectOf.has(item.holder.environmentId) ? item.holder.projectId : null,
1007
+ environmentId: projectOf.has(item.holder.environmentId) ? item.holder.environmentId : null,
1008
+ operationId: input.operationId ?? null,
1009
+ requestId: input.requestId ?? null,
1010
+ // A new holder's row is not committed yet: it is named here, as a wrap names its secret.
1011
+ metadata: JSON.stringify({
1012
+ reference: item.id,
1013
+ subject: item.holder.path,
1014
+ secretId: item.holder.secretId,
1015
+ source: source === undefined ? { secretId: item.source.secretId } : { path: source.path },
1016
+ ...(source === undefined ? {} : { also: { projectId: source.projectId, environmentId: source.environmentId, secretId: source.secretId } }),
1017
+ ...traced(input),
1018
+ }),
1019
+ };
1020
+ };
1021
+ if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
1022
+ return this.#decide([principal], async (d) => {
1023
+ const reader = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports);
1024
+ // Both sides as the store has them: a grant on a slug matches there, and neither side may be deleted.
1025
+ const environments = await store.environmentsById(d.tx, items.flatMap((item) => {
1026
+ const source = sources.get(item.source.secretId);
1027
+ return source === undefined ? [item.holder.environmentId] : [item.holder.environmentId, source.environmentId];
1028
+ }));
1029
+ const gone = (place: SecretPlace) => environments.get(place.environmentId)?.deleted ?? false;
1030
+ const codes = items.map((item): RefusalCode | null => {
1031
+ const source = sources.get(item.source.secretId);
1032
+ if (source === undefined || projectOf.get(item.holder.environmentId) !== item.holder.projectId) return 'bad_claim';
1033
+ if (source.secretId === item.holder.secretId) return 'invalid';
1034
+ return refuses(reader, 'secret.write', placeIn(item.holder, environments), gone(item.holder))
1035
+ ?? refuses(reader, 'secret.read', placeIn(source, environments), gone(source));
1036
+ });
1037
+ const first = codes.find((code) => code !== null) ?? null;
1038
+ if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), items.map((item, i) => entry(item, 'deny', codes[i] ?? first)));
1039
+ // Again under the log's head, as a read is: neither side was deleted since the check above.
1040
+ d.underLock.push(async (locked) => {
1041
+ const now = await store.environmentsById(locked, [...environments.keys()]);
1042
+ if (![...now.values()].some((environment) => environment.deleted)) return;
1043
+ throw new Refused(refusal('deleted', MESSAGES.deleted), items.map((item) => entry(item, 'deny', 'deleted')));
1044
+ });
1045
+ const entries = items.map((item) => entry(item, 'allow', null));
1046
+ d.log.push(...entries);
1047
+ const answer = { seqs: [] as number[] };
1048
+ d.after.push((seqOf) => (answer.seqs = entries.map(seqOf)));
1049
+ return answer;
1050
+ });
1051
+ }
1052
+
1053
+ /**
1054
+ * End references, each with a `reference.end` naming its creation by
1055
+ * `related_seq`, where the next read through it looks. `replaced` takes
1056
+ * write on the holder; `broken`, write on the holder or managing the
1057
+ * source's project's access. Ending one already ended is refused.
1058
+ */
1059
+ async endReferences(input: EndReferencesInput): Promise<Outcome<{ seqs: number[] }>> {
1060
+ const { principal, reason } = input;
1061
+ validateText(principal);
1062
+ validateCorrelation(input);
1063
+ if (reason !== 'replaced' && reason !== 'broken' && reason !== 'abandoned') throw new Error('a reference ends replaced, broken or abandoned');
1064
+ const vias = input.items.map((item) => validateVia(item)!);
1065
+ if (vias.some((via) => via === null) || new Set(vias.map((via) => via.seq)).size !== vias.length) throw new Error('each reference to end, once');
1066
+ if (vias.length === 0) return { ok: true, seqs: [] };
1067
+ const sealed = await this.#referencesAt(vias);
1068
+ // A holder's row is committed after its reference is sealed, if the write went through: an end names it only then.
1069
+ const holders = await store.secretsByIds(this.#db, sealed.flatMap((seal) => (seal === null ? [] : [seal.holder.secretId])));
1070
+ const entry = (via: Via, seal: Sealed | null, decision: 'allow' | 'deny', code: string | null): NewEntry => ({
1071
+ actor: principal,
1072
+ action: 'reference.end',
1073
+ decision,
1074
+ code,
1075
+ projectId: seal?.holder.projectId ?? null,
1076
+ environmentId: seal?.holder.environmentId ?? null,
1077
+ // So that the holder key's own log shows the reference ending.
1078
+ secretId: seal !== null && holders.has(seal.holder.secretId) ? seal.holder.secretId : null,
1079
+ relatedSeq: seal === null ? null : seal.seq,
1080
+ operationId: input.operationId ?? null,
1081
+ requestId: input.requestId ?? null,
1082
+ metadata: JSON.stringify({
1083
+ reference: via.reference,
1084
+ reason,
1085
+ ...(seal === null
1086
+ ? { seq: via.seq }
1087
+ : {
1088
+ subject: seal.holder.path,
1089
+ secretId: seal.holder.secretId,
1090
+ source: { path: seal.source.path },
1091
+ also: { projectId: seal.source.projectId, environmentId: seal.source.environmentId, secretId: seal.source.secretId },
1092
+ }),
1093
+ ...traced(input),
1094
+ }),
1095
+ });
1096
+ if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
1097
+ if (sealed.some((seal) => seal === null)) {
1098
+ return this.#decide([principal], async () => {
1099
+ throw new Refused(refusal('bad_claim', MESSAGES.bad_claim), vias.map((via, i) => entry(via, sealed[i]!, 'deny', 'bad_claim')));
1100
+ });
1101
+ }
1102
+ const seals = sealed as Sealed[];
1103
+ const ended = async (db: Queryable) => {
1104
+ const done = await store.endedReferences(db, seals.map((seal) => seal.seq));
1105
+ return seals.map((seal) => (done.has(seal.seq) ? ('ended' as const) : null));
1106
+ };
1107
+ return this.#decide([principal], async (d) => {
1108
+ const actor = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports);
1109
+ // Ending is allowed in a deleted place: deleting one ends the references into and out of it.
1110
+ const environments = await store.environmentsById(d.tx, seals.map((seal) => seal.holder.environmentId));
1111
+ const codes: (RefusalCode | null)[] = seals.map((seal) => {
1112
+ const holder = refuses(actor, 'secret.write', placeIn(seal.holder, environments), false);
1113
+ // Abandoning is its maker's alone, for a write of theirs that stored nothing: the vault
1114
+ // never reads the app's rows, so whose seal it is decides, and no one else's end says it.
1115
+ if (reason === 'abandoned' && seal.createdBy !== principal) return 'not_allowed';
1116
+ // Replacing or abandoning is the holder's side: write where it is held.
1117
+ if (holder === null || reason !== 'broken') return holder;
1118
+ return refuses(actor, 'grant.manage', { projectId: seal.source.projectId }, false) === null ? null : holder;
1119
+ });
1120
+ if (!codes.some((code) => code !== null)) (await ended(d.tx)).forEach((code, i) => (codes[i] = code));
1121
+ const first = codes.find((code) => code !== null) ?? null;
1122
+ if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), vias.map((via, i) => entry(via, seals[i]!, 'deny', codes[i] ?? first)));
1123
+ // Two ends of one reference at once: the second finds the first under the head's lock.
1124
+ d.underLock.push(async (locked) => {
1125
+ if ((await ended(locked)).every((code) => code === null)) return;
1126
+ throw new Refused(refusal('ended', MESSAGES.ended), vias.map((via, i) => entry(via, seals[i]!, 'deny', 'ended')));
1127
+ });
1128
+ const entries = vias.map((via, i) => entry(via, seals[i]!, 'allow', null));
1129
+ d.log.push(...entries);
1130
+ const answer = { seqs: [] as number[] };
1131
+ d.after.push((seqOf) => (answer.seqs = entries.map(seqOf)));
1132
+ return answer;
1133
+ });
1134
+ }
1135
+
1136
+ /**
1137
+ * The reference each read names, as the vault's own `reference.create`
1138
+ * entry at its seq says it is: an entry of the vault's, allowed, under its
1139
+ * MAC, about that reference. Null for an item that names none, and for
1140
+ * one that names something else: a row the vault never sealed.
1141
+ */
1142
+ async #referencesAt(vias: readonly (Via | null)[]): Promise<(Sealed | null)[]> {
1143
+ const found = await store.entriesAt(this.#db, vias.flatMap((via) => (via === null ? [] : [BigInt(via.seq)])));
1144
+ return vias.map((via) => {
1145
+ if (via === null) return null;
1146
+ const entry = found.get(BigInt(via.seq));
1147
+ if (entry === undefined || entry.author !== 'vault' || entry.action !== 'reference.create' || entry.decision !== 'allow') return null;
1148
+ if (!this.#authentic(entry)) return null;
1149
+ return sealedReference(entry, via.reference);
1150
+ });
1151
+ }
1152
+
889
1153
  // --- who holds what -----------------------------------------------------------
890
1154
 
891
1155
  #isRootAdmin(principal: string): boolean {
@@ -951,7 +1215,7 @@ class VaultService implements Vault {
951
1215
  const grants = row === undefined ? [] : await store.grants(db, principal);
952
1216
  const fault = await this.#integrity(db, principal, row, grants, reports);
953
1217
  if (fault !== null) return { principal, status: 'tampered', live: none, all: none, fault, stored: grants };
954
- if (!LIVE_PRINCIPAL.test(principal)) return { principal, status: 'unknown', live: none, all: none, fault, stored: grants };
1218
+ if (!PRINCIPAL.test(principal)) return { principal, status: 'unknown', live: none, all: none, fault, stored: grants };
955
1219
  if (row?.status !== 'active') return { principal, status: row?.status ?? 'unknown', live: none, all: none, fault, stored: grants };
956
1220
  const held = grants.map((grant) => ({ ...grant, role: grant.role as Role }));
957
1221
  const isOwner = row.owner && principal.startsWith('user:');
@@ -969,10 +1233,10 @@ class VaultService implements Vault {
969
1233
  if (this.#isRootAdmin(principal)) {
970
1234
  return { principal, status: 'active', generation: row?.generation ?? 0, isRootAdmin: true, isOwner: true, grants: [], since: null, by: null };
971
1235
  }
972
- const active = LIVE_PRINCIPAL.test(principal) && !tampered && row?.status === 'active';
1236
+ const active = PRINCIPAL.test(principal) && !tampered && row?.status === 'active';
973
1237
  return {
974
1238
  principal,
975
- status: tampered ? 'tampered' : !LIVE_PRINCIPAL.test(principal) ? 'unknown' : (row?.status ?? 'unknown'),
1239
+ status: tampered ? 'tampered' : !PRINCIPAL.test(principal) ? 'unknown' : (row?.status ?? 'unknown'),
976
1240
  generation: row?.generation ?? 0,
977
1241
  isRootAdmin: false,
978
1242
  isOwner: active && row.owner && principal.startsWith('user:'),
@@ -1038,45 +1302,59 @@ class VaultService implements Vault {
1038
1302
 
1039
1303
  setAccess(input: SetAccessInput): Promise<Outcome<{ changes: AccessChange[] }>> {
1040
1304
  const { actor, principal } = input;
1041
- const action = input.changes.every((change) => change.role === null) ? 'access.revoke' : 'access.grant';
1305
+ const changes = input.changes.map((change) => ({ ...change, environmentSlug: change.environmentSlug ?? null }));
1306
+ const action = changes.every((change) => change.role === null) ? 'access.revoke' : 'access.grant';
1042
1307
  // One place refused is shown where it is, to whoever reads that place's log; an invalid one may be no place at all.
1043
- const [only] = input.changes.length === 1 ? input.changes : [];
1044
- const refused = (code: RefusalCode, message = MESSAGES[code]) =>
1045
- new Refused(refusal(code, message), [{
1046
- ...accessEntry(actor, action, principal, 'deny', input, { changes: input.changes }, code),
1047
- ...(only === undefined || code === 'invalid' ? {} : { projectId: only.projectId, environmentId: only.environmentId }),
1308
+ const [only] = changes.length === 1 ? changes : [];
1309
+ const refused = (code: RefusalCode, message = MESSAGES[code]) => {
1310
+ const where = only === undefined || code === 'invalid' ? { ids: {}, detail: {} } : located(only);
1311
+ return new Refused(refusal(code, message), [{
1312
+ ...accessEntry(actor, action, principal, 'deny', input, { changes: input.changes, ...where.detail }, code),
1313
+ ...where.ids,
1048
1314
  }]);
1315
+ };
1316
+ const onProjects = changes.flatMap(({ projectId, environmentId, role }) => (projectId === null ? [] : [{ projectId, environmentId, role }]));
1049
1317
  return this.#decide([actor, principal], async (d) => {
1050
1318
  validateCorrelation(input);
1051
- if (!LIVE_PRINCIPAL.test(principal)) throw refused('invalid', `not a principal: ${principal}`);
1319
+ if (!PRINCIPAL.test(principal)) throw refused('invalid', `not a principal: ${principal}`);
1052
1320
  if (this.#isRootAdmin(principal)) throw refused('root_admin');
1053
1321
  const places = new Set<string>();
1054
- for (const change of input.changes) {
1055
- const key = `${change.projectId}/${change.environmentId ?? ''}`;
1322
+ for (const change of changes) {
1323
+ const shape = placeShape(change);
1324
+ if (shape !== null) throw refused('invalid', shape);
1325
+ const key = placeKey(change);
1056
1326
  if (places.has(key)) throw refused('invalid', 'each place may be changed once per call');
1057
1327
  places.add(key);
1058
1328
  if (change.role !== null && !isRole(change.role)) throw refused('invalid', `no such role: ${change.role}`);
1059
- if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role)) {
1060
- throw refused('invalid', `${change.role} can only be granted on a project`);
1329
+ if (change.role !== null && (change.environmentId !== null || change.environmentSlug !== null) && !assignableToEnvironment(change.role)) {
1330
+ throw refused('invalid', `${change.role} can only be granted on a project, or on every project`);
1061
1331
  }
1062
1332
  const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
1063
1333
  if (Number.isNaN(expiresAt) || (expiresAt !== null && expiresAt <= d.at)) {
1064
1334
  throw refused('invalid', 'an end date must be in the future');
1065
1335
  }
1066
1336
  }
1337
+ // Every project is always there; a project or an environment must be,
1338
+ // and to be granted, not deleted. Read under the log's head, which a
1339
+ // deletion holds while it renames the place, so the place is as it commits.
1340
+ if (onProjects.length > 0) await lockLogHead(d.tx);
1067
1341
  const known = await store.places(
1068
1342
  d.tx,
1069
- input.changes.map((change) => change.projectId),
1070
- input.changes.flatMap((change) => (change.environmentId === null ? [] : [change.environmentId])),
1343
+ onProjects.map((change) => change.projectId),
1344
+ onProjects.flatMap((change) => (change.environmentId === null ? [] : [change.environmentId])),
1071
1345
  );
1072
- for (const { projectId, environmentId } of input.changes) {
1346
+ for (const { projectId, environmentId, role } of onProjects) {
1073
1347
  if (!known.projects.has(projectId) || (environmentId !== null && known.environments.get(environmentId) !== projectId)) {
1074
1348
  throw refused('invalid', `no such place: ${environmentId === null ? projectId : `${projectId}/${environmentId}`}`);
1075
1349
  }
1350
+ // A grant there is refused; a revocation, as a deletion makes, is not.
1351
+ if (role !== null && (known.deleted.has(projectId) || (environmentId !== null && known.deleted.has(environmentId)))) {
1352
+ throw refused('deleted');
1353
+ }
1076
1354
  }
1077
1355
  const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
1078
1356
  if (acting.status === 'tampered') throw refused('tampered');
1079
- if (!input.changes.every((change) => mayManageAccess(acting.live, change))) throw refused('not_allowed');
1357
+ if (!changes.every((change) => mayManageAccess(acting.live, change))) throw refused('not_allowed');
1080
1358
 
1081
1359
  const row = d.members.get(principal);
1082
1360
  const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
@@ -1086,9 +1364,9 @@ class VaultService implements Vault {
1086
1364
  // The grants the check verified, not a second read: what the decision changes, and seals.
1087
1365
  const held = subject.stored;
1088
1366
  d.grants.set(principal, [...held]);
1089
- const changes = input.changes.map((change) => this.#apply(d, actor, principal, held, change, input));
1367
+ const outcomes = changes.map((change) => this.#apply(d, actor, principal, held, change, input));
1090
1368
  if (d.writes.length > 0) d.touched.add(principal);
1091
- return { changes };
1369
+ return { changes: outcomes };
1092
1370
  });
1093
1371
  }
1094
1372
 
@@ -1098,21 +1376,23 @@ class VaultService implements Vault {
1098
1376
  actor: string,
1099
1377
  principal: string,
1100
1378
  held: readonly GrantRow[],
1101
- change: GrantChange,
1379
+ change: GrantChange & GrantPlace,
1102
1380
  correlation: Correlation,
1103
1381
  ): AccessChange {
1104
- const existing = held.find((grant) => grant.projectId === change.projectId && grant.environmentId === change.environmentId);
1382
+ const existing = held.find((grant) => placeKey(grant) === placeKey(change));
1105
1383
  const current = existing !== undefined && live(existing, d.at) ? existing : undefined;
1106
1384
  const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
1107
- const place = { projectId: change.projectId, environmentId: change.environmentId };
1385
+ const place = { projectId: change.projectId, environmentId: change.environmentId, environmentSlug: change.environmentSlug };
1386
+ const where = located(place);
1108
1387
  const entry = (action: string, role: string | null) =>
1109
1388
  d.log.push({
1110
1389
  ...accessEntry(actor, action, principal, 'allow', correlation, {
1111
1390
  role,
1112
1391
  expiresAt: expiresAt === null ? null : iso(expiresAt),
1113
1392
  previousRole: current?.role ?? null,
1393
+ ...where.detail,
1114
1394
  }),
1115
- ...place,
1395
+ ...where.ids,
1116
1396
  });
1117
1397
  // A lapsed grant is cleared with no entry: it changes nothing anyone holds.
1118
1398
  const clear = () => {
@@ -1152,7 +1432,7 @@ class VaultService implements Vault {
1152
1432
  const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
1153
1433
  if (acting.status === 'tampered') throw refused('tampered');
1154
1434
  if (!acting.live.isOwner) throw refused('not_allowed', 'only owners may add or restore members');
1155
- if (!LIVE_PRINCIPAL.test(principal)) throw refused('invalid', `not a member: ${principal}`);
1435
+ if (!PRINCIPAL.test(principal)) throw refused('invalid', `not a member: ${shownMember(principal)}`);
1156
1436
  if (this.#isRootAdmin(principal)) throw refused('root_admin');
1157
1437
  if (input.owner === true && !principal.startsWith('user:')) {
1158
1438
  throw refused('invalid', 'service accounts cannot be owners');
@@ -1204,7 +1484,7 @@ class VaultService implements Vault {
1204
1484
  new Refused(refusal(code, message), [accessEntry(actor, 'member.remove', principal, 'deny', input, {}, code)]);
1205
1485
  return this.#decide([actor, principal], async (d) => {
1206
1486
  validateCorrelation(input);
1207
- if (!LIVE_PRINCIPAL.test(principal)) throw refused('invalid', `not a member: ${principal}`);
1487
+ if (!PRINCIPAL.test(principal)) throw refused('invalid', `not a member: ${shownMember(principal)}`);
1208
1488
  if (this.#isRootAdmin(principal)) throw refused('root_admin');
1209
1489
  const row = d.members.get(principal);
1210
1490
  const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
@@ -1221,14 +1501,15 @@ class VaultService implements Vault {
1221
1501
 
1222
1502
  const revoked = held.filter((grant) => live(grant, d.at));
1223
1503
  for (const grant of revoked) {
1504
+ const where = located(grant);
1224
1505
  d.log.push({
1225
1506
  ...accessEntry(actor, 'access.revoke', principal, 'allow', input, {
1226
1507
  role: null,
1227
1508
  expiresAt: null,
1228
1509
  previousRole: grant.role,
1510
+ ...where.detail,
1229
1511
  }),
1230
- projectId: grant.projectId,
1231
- environmentId: grant.environmentId,
1512
+ ...where.ids,
1232
1513
  });
1233
1514
  }
1234
1515
  const generation = row.generation + 1;
@@ -1750,12 +2031,83 @@ function validateItems(items: readonly { secret: SecretRef; key?: string; wrappe
1750
2031
  }
1751
2032
  }
1752
2033
 
1753
- /** Why `reader` may not do `permission` on `secret`, or null if they may. */
1754
- function refuses(reader: Standing, permission: Permission, secret: SecretRef): RefusalCode | null {
2034
+ /** A reference as its `reference.create` entry seals it. */
2035
+ type Sealed = {
2036
+ reference: string;
2037
+ seq: bigint;
2038
+ holder: { projectId: string; environmentId: string; secretId: string; path: string };
2039
+ source: { projectId: string; environmentId: string; secretId: string; path: string };
2040
+ createdBy: string;
2041
+ createdAt: number;
2042
+ };
2043
+
2044
+ /** What a genuine `reference.create` entry seals, if it is about `reference`; null if not, or malformed. */
2045
+ function sealedReference(entry: StoredEntry, reference: string): Sealed | null {
2046
+ let detail: { reference?: unknown; subject?: unknown; secretId?: unknown; source?: { path?: unknown }; also?: Record<string, unknown> };
2047
+ try {
2048
+ detail = JSON.parse(entry.metadata) as typeof detail;
2049
+ } catch {
2050
+ return null;
2051
+ }
2052
+ const text = (value: unknown): value is string => typeof value === 'string' && value !== '';
2053
+ const also = detail.also ?? {};
2054
+ if (detail.reference !== reference || entry.projectId === null || entry.environmentId === null) return null;
2055
+ if (!text(detail.subject) || !text(detail.secretId) || !text(detail.source?.path)) return null;
2056
+ if (!text(also.projectId) || !text(also.environmentId) || !text(also.secretId)) return null;
2057
+ return {
2058
+ reference,
2059
+ seq: entry.seq,
2060
+ holder: { projectId: entry.projectId, environmentId: entry.environmentId, secretId: detail.secretId, path: detail.subject },
2061
+ source: { projectId: also.projectId, environmentId: also.environmentId, secretId: also.secretId, path: detail.source!.path as string },
2062
+ createdBy: entry.actor,
2063
+ createdAt: entry.occurredAt,
2064
+ };
2065
+ }
2066
+
2067
+ /** A read's reference, checked for shape: null for none. */
2068
+ function validateVia(via: Via | null | undefined): Via | null {
2069
+ if (via === undefined || via === null) return null;
2070
+ if (typeof via.reference !== 'string' || !UUID.test(via.reference)) throw new Error('a reference id must be a lowercase UUID');
2071
+ if (!Number.isSafeInteger(via.seq) || via.seq < 0) throw new Error("a reference's seq must be a whole number");
2072
+ return { reference: via.reference, seq: via.seq };
2073
+ }
2074
+
2075
+ /**
2076
+ * How a read through a reference is logged: the way in, and `also`, the
2077
+ * holder's place, which puts the read in the holder's project's log too.
2078
+ */
2079
+ function viaDetail(via: Via, seal: Sealed | null): Record<string, unknown> {
2080
+ if (seal === null) return { via: { reference: via.reference, seq: via.seq } };
2081
+ return {
2082
+ via: { reference: via.reference, seq: via.seq, path: seal.holder.path, createdBy: seal.createdBy, createdAt: iso(seal.createdAt) },
2083
+ also: { projectId: seal.holder.projectId, environmentId: seal.holder.environmentId, secretId: seal.holder.secretId },
2084
+ };
2085
+ }
2086
+
2087
+ /** An environment a secret is in, or that holds a reference to one. */
2088
+ type SecretPlace = { projectId: string; environmentId: string };
2089
+
2090
+ /**
2091
+ * Where a decision checks a permission: an environment, with its slug as
2092
+ * the store has it when it was read and is in the project claimed;
2093
+ * otherwise none, which no grant on a slug matches.
2094
+ */
2095
+ function placeIn(place: SecretPlace, environments: ReadonlyMap<string, { projectId: string; slug: string }>): Place {
2096
+ const environment = environments.get(place.environmentId);
2097
+ const slug = environment?.projectId === place.projectId ? environment.slug : null;
2098
+ return { projectId: place.projectId, environmentId: place.environmentId, environmentSlug: slug };
2099
+ }
2100
+
2101
+ /**
2102
+ * Why `reader` may not do `permission` at `where`, or null if they may. A
2103
+ * deleted place is refused before any grant is asked, so no kind of grant,
2104
+ * on the project, the environment or every project, can reach into one.
2105
+ */
2106
+ function refuses(reader: Standing, permission: Permission, where: Place, deleted: boolean): RefusalCode | null {
1755
2107
  if (reader.status === 'tampered') return 'tampered';
1756
2108
  if (reader.status === 'removed') return 'removed';
1757
2109
  if (reader.status === 'unknown') return 'not_a_member';
1758
- const where = { projectId: secret.projectId, environmentId: secret.environmentId };
2110
+ if (deleted) return 'deleted';
1759
2111
  if (allows(reader.live, permission, where)) return null;
1760
2112
  // Would a grant that has lapsed have covered it?
1761
2113
  return allows(reader.all, permission, where) ? 'expired' : 'no_grant';
@@ -1831,10 +2183,41 @@ function live(grant: GrantRow, at: number): boolean {
1831
2183
  return grant.expiresAt === null || grant.expiresAt > at;
1832
2184
  }
1833
2185
 
2186
+ /**
2187
+ * Where an entry about a grant says it is: its project and environment; or,
2188
+ * on every project, neither, and the place as a path in its payload. Only a
2189
+ * grant that names no place at all is on every project (`grantKind`).
2190
+ */
2191
+ function located(place: GrantPlace): { ids: Pick<NewEntry, 'projectId' | 'environmentId'>; detail: { place?: string } } {
2192
+ return grantKind(place) === 'every-project'
2193
+ ? { ids: {}, detail: { place: everyProjectPath(place.environmentSlug) } }
2194
+ : { ids: { projectId: place.projectId, environmentId: place.environmentId }, detail: {} };
2195
+ }
2196
+
2197
+ /** One key per place a member can hold a grant at, by what the grant names first: its environment, its project, or neither. */
2198
+ function placeKey(place: GrantPlace): string {
2199
+ if (place.environmentId !== null) return `environment:${place.environmentId}`;
2200
+ return place.projectId !== null ? `project:${place.projectId}` : everyProjectPath(place.environmentSlug);
2201
+ }
2202
+
2203
+ /** Why a change's place is not one a grant can be at, or null when it is. */
2204
+ function placeShape(place: GrantPlace): string | null {
2205
+ const kind = grantKind(place);
2206
+ if (kind === null) return place.projectId === null ? 'an environment is named in its project' : 'a slug names environments on every project, not in one';
2207
+ if (kind === 'every-project' && place.environmentSlug !== null && !SLUG.test(place.environmentSlug)) {
2208
+ return `not an environment slug: ${place.environmentSlug}`;
2209
+ }
2210
+ return null;
2211
+ }
2212
+
2213
+ /** An environment's slug, as the schema has it. */
2214
+ const SLUG = /^[a-z0-9][a-z0-9-]{0,62}$/;
2215
+
1834
2216
  function view(grant: GrantRow): Grant {
1835
2217
  return {
1836
2218
  projectId: grant.projectId,
1837
2219
  environmentId: grant.environmentId,
2220
+ environmentSlug: grant.environmentSlug,
1838
2221
  role: grant.role as Role,
1839
2222
  expiresAt: grant.expiresAt === null ? null : iso(grant.expiresAt),
1840
2223
  grantedAt: iso(grant.grantedAt),