@coffre/vault 0.9.0 → 0.10.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,25 +3,39 @@ import { randomUUID, timingSafeEqual } from 'node:crypto';
3
3
  import {
4
4
  allows,
5
5
  assignableToEnvironment,
6
+ DEFAULT_POLICY,
7
+ definitionProblem,
8
+ EVERY_ENVIRONMENT,
6
9
  EVERYWHERE,
10
+ FIXED_ROLES,
7
11
  givesService,
12
+ guarded,
8
13
  grantKind,
14
+ INSTANCE_ROLES,
15
+ isBuiltinRole,
9
16
  isCredentialScope,
10
17
  isInstanceRole,
11
18
  isRole,
12
19
  isScope,
13
20
  managesService,
14
21
  mayManageAccess,
22
+ normalDefinition,
15
23
  normalScope,
16
24
  NOTHING,
17
25
  ownsInstance,
26
+ PROJECT_ONLY_PERMISSIONS,
27
+ ROLE_ID,
28
+ ROLES,
18
29
  runsInstance,
19
30
  setsUpServices,
31
+ UNWRITTEN_FORK_MS,
20
32
  type Holdings,
21
33
  type InstanceRole,
22
34
  type Permission,
23
35
  type Place,
36
+ type Policy,
24
37
  type Role,
38
+ type RoleDefinition,
25
39
  type Scope,
26
40
  type ServiceAccount,
27
41
  withinCredential,
@@ -46,11 +60,14 @@ import {
46
60
  checkpointMessage,
47
61
  checkpointVerifier,
48
62
  describeAccessFault,
49
- HANDED_BACK,
63
+ handsBack,
64
+ POLICY_REVISION,
65
+ policyRevision,
50
66
  type Access,
51
67
  type AccessChange,
52
68
  type AccessFault,
53
69
  type AdmitInput,
70
+ type ChangePolicyInput,
54
71
  type Admitted,
55
72
  type Checkpoint,
56
73
  type CheckpointKey,
@@ -61,6 +78,7 @@ import {
61
78
  type LogHead,
62
79
  type LogVerification,
63
80
  type Outcome,
81
+ type PolicyChange,
64
82
  type Purpose,
65
83
  type ReferenceInput,
66
84
  type Refusal,
@@ -73,6 +91,7 @@ import {
73
91
  type Settings,
74
92
  type Through,
75
93
  type UnwrapInput,
94
+ type Wrapped,
76
95
  type Vault,
77
96
  type Via,
78
97
  type VerifyLogInput,
@@ -82,7 +101,7 @@ import {
82
101
  import type { Database, Queryable, Transaction } from '@coffre/db';
83
102
  import { isUniqueViolation, SNAPSHOT } from '@coffre/db/dialect';
84
103
  import { everyProjectConversion, newestAccess, reportStands } from '@coffre/db/grants';
85
- import { appendEntries, LOOK_BACK, lockLogHead, newestAuthentic, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
104
+ import { appendEntries, LOOK_BACK, lockLogHead, newestAuthentic, newestVaultSeq, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
86
105
 
87
106
  import { KeyAccounting } from './accounting.ts';
88
107
  import { signer, type Signer } from './checkpoint.ts';
@@ -217,6 +236,42 @@ const SETTINGS = 'settings.change';
217
236
  /** The settings before anyone changed them: people set up service accounts anywhere they hold access. */
218
237
  const DEFAULT_SETTINGS: Settings = { serviceAccounts: EVERYWHERE };
219
238
 
239
+ /** The action of the vault's entry that holds the instance's access policy, and changes it (`policy`). */
240
+ const POLICY = 'policy.change';
241
+
242
+ /**
243
+ * What a `policy.change` entry keeps: the roles an Owner edited or made,
244
+ * each whole, and the protected environments. A role as it ships is kept by
245
+ * none, so a release that changes a default changes it where nobody edited it.
246
+ */
247
+ type PolicyEdits = { roles: Record<string, RoleDefinition>; instanceRoles: Partial<Record<InstanceRole, RoleDefinition>>; protected: ProtectedPlace[] };
248
+
249
+ /**
250
+ * A protected environment, with its project's id, what a grant on the whole
251
+ * project reaches of it, and when it was protected, by the vault's clock:
252
+ * an id with no row ten minutes later is a fork that was never written.
253
+ */
254
+ type ProtectedPlace = { projectId: string; environmentId: string; at: number };
255
+
256
+
257
+ const NO_EDITS: PolicyEdits = { roles: {}, instanceRoles: {}, protected: [] };
258
+
259
+ /** A role holding nothing, which every role but Owner is under the closed policy. */
260
+ const NOTHING_HELD = (definition: RoleDefinition): RoleDefinition => ({ ...definition, permissions: [], protected: [] });
261
+
262
+ /**
263
+ * The policy when the log has some, but none the vault wrote within reach:
264
+ * whoever inserted rows may have hidden an Owner's narrowing, so no role
265
+ * holds anything, a service account's neither, but Owner, which holds
266
+ * everything always and so may write the policy again.
267
+ */
268
+ const CLOSED_POLICY: Policy = {
269
+ roles: Object.fromEntries(Object.entries(ROLES).map(([id, definition]) => [id, id === 'owner' ? definition : NOTHING_HELD(definition)])),
270
+ instanceRoles: Object.fromEntries(Object.entries(INSTANCE_ROLES).map(([id, definition]) => [id, id === 'owner' ? definition : NOTHING_HELD(definition)])) as Policy['instanceRoles'],
271
+ protected: [EVERY_ENVIRONMENT],
272
+ protectedIn: {},
273
+ };
274
+
220
275
  /** The settings when the log has some, but none the vault wrote within reach: people set up no service accounts. */
221
276
  const CLOSED_SETTINGS: Settings = { serviceAccounts: { projects: { only: [] }, environments: 'all' } };
222
277
 
@@ -269,6 +324,9 @@ class Outage {
269
324
  }
270
325
  }
271
326
 
327
+ /** A rewrap that would make a value less sensitive than it is. */
328
+ 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';
329
+
272
330
  function refusal(code: RefusalCode, message: string): Refusal {
273
331
  return { code, message };
274
332
  }
@@ -283,10 +341,11 @@ const MESSAGES: Record<RefusalCode, string> = {
283
341
  deleted: 'this project or environment was deleted',
284
342
  ended: 'the reference read through was ended',
285
343
  personal: "a personal config's values are its owner's alone",
286
- stand_ins: "people use this instance's values through stand-ins, never as values: `coffre run` gives your app them; a service account reads values",
344
+ sensitive: "a sensitive value is never shown to anyone, Owners included: `coffre run` gives your app a placeholder for it through the credential proxy, and a service account reads it",
287
345
  not_allowed: 'not allowed to change this',
288
346
  last_owner: 'the last Owner of the whole instance stays one: make someone else an Owner first',
289
347
  invalid: 'not something the rules allow',
348
+ policy_changed: "the instance's roles or protected environments changed while this was asked: try again",
290
349
  log_broken: 'the vault log does not hold from the last checkpoint',
291
350
  wrong_kek: "this vault's key does not open the data it holds",
292
351
  tampered: "this member's record failed the vault's integrity check",
@@ -398,23 +457,29 @@ class VaultService implements Vault {
398
457
  }
399
458
 
400
459
  /**
401
- * Decide in one transaction, with `principals`' rows locked first. A
402
- * `Refused` or an `Outage` rolls back all but its own entries, which
403
- * commit on their own: then the refusal is the answer, and the outage
404
- * fails the call.
460
+ * Decide in one transaction, with `principals`' rows locked first, then
461
+ * the log's head. A `Refused` or an `Outage` rolls back all but its own
462
+ * entries, which commit on their own: then the refusal is the answer, and
463
+ * the outage fails the call.
464
+ *
465
+ * Every change to the policy, the settings, a member or a place appends
466
+ * under the head, so whatever the decision reads of them stands until it
467
+ * commits: no decision acts on a policy read before it. A key operation
468
+ * alone takes the head later (`late`), once its key service has answered,
469
+ * which it must not wait on holding it, and decides again then (`#keys`).
405
470
  */
406
- async #decide<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>): Promise<Outcome<T>> {
471
+ async #decide<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false): Promise<Outcome<T>> {
407
472
  const superseded = await this.#settled();
408
473
  if (superseded !== null) return { ok: false, refusal: refusal('wrong_kek', superseded) };
409
- return this.#decideNow(principals, decide);
474
+ return this.#decideNow(principals, decide, late);
410
475
  }
411
476
 
412
477
  /** `#decide`, once the vault is settled. */
413
- async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>): Promise<Outcome<T>> {
478
+ async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false): Promise<Outcome<T>> {
414
479
  const reports: NewEntry[] = [];
415
480
  for (let attempt = 1; ; attempt += 1) {
416
481
  try {
417
- return await this.#decideOnce(principals, decide, reports);
482
+ return await this.#decideOnce(principals, decide, reports, late);
418
483
  } catch (error) {
419
484
  // A member who had no row when this decision locked theirs, and has
420
485
  // one now: another decision admitted them meanwhile. Again, with it.
@@ -424,11 +489,12 @@ class VaultService implements Vault {
424
489
  }
425
490
  }
426
491
 
427
- async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[]): Promise<Outcome<T>> {
492
+ async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], late: boolean): Promise<Outcome<T>> {
428
493
  try {
429
494
  const result = await this.#db.transaction(async (tx) => {
430
495
  await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
431
496
  const members = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals);
497
+ if (!late) await lockLogHead(tx);
432
498
  const d: Decision = {
433
499
  tx, members, at: await this.#now(tx), log: [], writes: [], touched: new Set(), grants: new Map(), reports, after: [], underLock: [],
434
500
  };
@@ -630,7 +696,6 @@ class VaultService implements Vault {
630
696
  if (everyProject.length === 0) return;
631
697
  if (await reportStands(d.tx, principal, this.#check(d.reports, principal))) return;
632
698
  // The projects there are now, read under the log's head, which a deletion holds.
633
- await lockLogHead(d.tx);
634
699
  const before = roleOf(principal, row);
635
700
  const conversion = everyProjectConversion(principal, before.role, subject.stored, d.at, await store.liveProjects(d.tx));
636
701
  const after = { role: conversion.role, scope: conversion.scope };
@@ -790,6 +855,7 @@ class VaultService implements Vault {
790
855
  async unwrap(input: UnwrapInput): Promise<Outcome<{ keys: string[] }>> {
791
856
  const { principal } = input;
792
857
  validateText(input.purpose);
858
+ if (input.keepsSensitive !== undefined && typeof input.keepsSensitive !== 'boolean') throw new Error('keepsSensitive is true, false or absent');
793
859
  const vias = input.items.map((item) => validateVia(item.via));
794
860
  const throughs = input.items.map((item) => validateThrough(item.through));
795
861
  const loaded = await this.#versions(input, 'secret.read', { purpose: input.purpose });
@@ -838,7 +904,8 @@ class VaultService implements Vault {
838
904
  {
839
905
  action: 'secret.read',
840
906
  principal,
841
- permission: 'secret.read',
907
+ // The proxy's use opens a value for coffre to use for its reader, never to hand back: `secret.use`. Anything else reads.
908
+ permission: input.purpose === 'use' ? 'secret.use' : 'secret.read',
842
909
  secrets: items.map((item) => item.secret),
843
910
  // Through a reference, the reader's grants on the holder's environment decide.
844
911
  places: sealed.map((seal, i) => seal?.holder ?? items[i]!.secret),
@@ -867,7 +934,7 @@ class VaultService implements Vault {
867
934
  );
868
935
  }
869
936
 
870
- async wrap(input: WrapInput): Promise<Outcome<{ wrapped: WrappedKey[]; seqs: number[] }>> {
937
+ async wrap(input: WrapInput): Promise<Outcome<Wrapped>> {
871
938
  const { principal, items } = input;
872
939
  validateItems(items, 'key');
873
940
  validateText(principal);
@@ -893,11 +960,11 @@ class VaultService implements Vault {
893
960
  dek.fill(0);
894
961
  }
895
962
  }),
896
- (done) => ({ wrapped: done.map(({ wrapped }) => wrapped), seqs: [] as number[] }),
963
+ (done) => ({ wrapped: done.map(({ wrapped }) => wrapped), seqs: [] as number[], sensitive: items.map(({ secret }) => secret.sensitive === true) }),
897
964
  );
898
965
  }
899
966
 
900
- async rewrap(input: RewrapInput): Promise<Outcome<{ wrapped: WrappedKey[]; seqs: number[] }>> {
967
+ async rewrap(input: RewrapInput): Promise<Outcome<Wrapped>> {
901
968
  const { principal } = input;
902
969
  const loaded = await this.#versions(input, 'key.rewrap');
903
970
  if (!loaded.ok) return loaded;
@@ -911,6 +978,13 @@ class VaultService implements Vault {
911
978
  ...keyEntry('key.rewrap', principal, source.secret, 'deny', 'bad_claim', input), secretVersionId: source.id,
912
979
  })));
913
980
  }
981
+ // A value is made looser only by writing it again: a sensitive version's key, or one of a secret whose newest is,
982
+ // is never wrapped again as anything but sensitive, so that a person who may not read it never makes it readable.
983
+ if (items.some((item, i) => item.secret.sensitive !== true && (loaded.versions[i].secret.sensitive || loaded.versions[i].newestSensitive))) {
984
+ return this.#refuseVersions(principal, 'invalid', loaded.versions.map((source) => ({
985
+ ...keyEntry('key.rewrap', principal, source.secret, 'deny', 'invalid', input), secretVersionId: source.id,
986
+ })), LOOSER);
987
+ }
914
988
  const sources = new Map(items.map((item, i) => [item.secret, loaded.versions[i]]));
915
989
  const entry = (secret: SecretRef, decision: 'allow' | 'deny', code: string | null): NewEntry => ({
916
990
  ...keyEntry('key.rewrap', principal, secret, decision, code, input, { from: sources.get(secret)!.secret.version }),
@@ -919,10 +993,26 @@ class VaultService implements Vault {
919
993
  const remote =
920
994
  this.#remote(this.#config.keks.primary) || items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
921
995
  return this.#keys(
922
- { action: 'key.rewrap', principal, permission: 'secret.write', secrets: items.map((item) => item.secret), remote, entry, input },
996
+ {
997
+ action: 'key.rewrap', principal, permission: 'secret.write', secrets: items.map((item) => item.secret), remote, entry, input,
998
+ // Again in the decision and under the log's head: a sensitive version written since it was loaded is as newest as any.
999
+ vet: async (db) => {
1000
+ const newest = await store.newestSensitive(db, items.map(({ secret }) => secret.secretId));
1001
+ return items.map(({ secret }) => (secret.sensitive !== true && newest.has(secret.secretId) ? 'invalid' : null));
1002
+ },
1003
+ // An old value brought back, not the current one, is made live by whoever could have read it: a secret by its
1004
+ // readers, a sensitive one, which nobody reads, by those who use it, a plain one by any writer, as it was shown
1005
+ // to them. Whatever reads the key next, a reference to it included, then reads what its restorer could. Typing a
1006
+ // value, or tightening the current one, takes no more than writing.
1007
+ needs: async (db) => {
1008
+ const restored = await store.restoredNatures(db, items.map(({ secret }) => ({ secretId: secret.secretId, from: sources.get(secret)!.id })));
1009
+ return restored.map((nature) => (nature === 'secret' ? ['secret.read'] as const : nature === 'sensitive' ? ['secret.use'] as const : null));
1010
+ },
1011
+ },
923
1012
  (contextOf) =>
924
1013
  items.map(({ secret, wrapped }) => async (operation: KeyOperation) => {
925
- const key = await this.#open(wrapped, contextOf(secret), operation);
1014
+ // Opened as the source was written, sensitive or not, and wrapped as the new version is.
1015
+ const key = await this.#open(wrapped, contextOf(sources.get(secret)!.secret), operation);
926
1016
  if (key === null) return null;
927
1017
  try {
928
1018
  return { wrapped: serialisable(await this.#config.keks.wrap(key, contextOf(secret), operation)), wipe: () => {} };
@@ -930,7 +1020,7 @@ class VaultService implements Vault {
930
1020
  key.fill(0);
931
1021
  }
932
1022
  }),
933
- (done) => ({ wrapped: done.map(({ wrapped }) => wrapped), seqs: [] as number[] }),
1023
+ (done) => ({ wrapped: done.map(({ wrapped }) => wrapped), seqs: [] as number[], sensitive: items.map(({ secret }) => secret.sensitive === true) }),
934
1024
  );
935
1025
  }
936
1026
 
@@ -968,9 +1058,9 @@ class VaultService implements Vault {
968
1058
  return { ok: true, versions: ids.map((id) => found.get(id)!) };
969
1059
  }
970
1060
 
971
- async #refuseVersions(principal: string, code: 'bad_claim' | 'deleted', entries: NewEntry[]): Promise<Outcome<never>> {
1061
+ async #refuseVersions(principal: string, code: 'bad_claim' | 'deleted' | 'invalid', entries: NewEntry[], message = MESSAGES[code]): Promise<Outcome<never>> {
972
1062
  return this.#decide([principal], async () => {
973
- throw new Refused(refusal(code, MESSAGES[code]), entries);
1063
+ throw new Refused(refusal(code, message), entries);
974
1064
  });
975
1065
  }
976
1066
 
@@ -1013,9 +1103,14 @@ class VaultService implements Vault {
1013
1103
  * once the log's head is locked, before its entries append.
1014
1104
  */
1015
1105
  vet?: (db: Queryable) => Promise<(RefusalCode | null)[]>;
1106
+ /**
1107
+ * Any one of which each secret takes besides `permission`, where it
1108
+ * is, read in the decision and again under the head; none for null.
1109
+ */
1110
+ needs?: (db: Queryable) => Promise<(readonly Permission[] | null)[]>;
1016
1111
  remote: boolean;
1017
1112
  entry: (secret: SecretRef, decision: 'allow' | 'deny', code: string | null) => NewEntry;
1018
- input: Correlation & Through & { purpose?: string };
1113
+ input: Correlation & Through & { purpose?: string; keepsSensitive?: boolean };
1019
1114
  },
1020
1115
  /**
1021
1116
  * One per secret; null for a bad claim, an error for an outage or an
@@ -1048,17 +1143,35 @@ class VaultService implements Vault {
1048
1143
  const gone = (place: SecretPlace) => environments.get(place.environmentId)?.deleted ?? false;
1049
1144
  owners.clear();
1050
1145
  for (const [id, environment] of environments) if (environment.owner !== null) owners.set(id, ownerKey(environment.owner));
1146
+ const needs = call.needs === undefined ? [] : await call.needs(d.tx);
1051
1147
  const codes: (RefusalCode | null)[] = secrets.map((secret, i) => {
1052
1148
  const personal = personalRefusal(reader, principal, call.permission, secret, at[i]!, environments);
1053
1149
  if (personal !== undefined) return personal;
1150
+ const also = needs[i];
1151
+ if (also != null) {
1152
+ const place = placeIn(at[i]!, environments);
1153
+ const refused = also.map((permission) => refuses(reader, permission, place, gone(secret) || gone(at[i]!)));
1154
+ if (refused.every((code) => code !== null)) return refused[0]!;
1155
+ }
1156
+ // Through a reference, the holder's grants decide, its source's never: a reference takes its value and nature
1157
+ // (a sensitive one stays use-only) from its source, and its permissions from where it lives. Its maker held what
1158
+ // the source's nature asks when it was made (`reference`).
1054
1159
  return refuses(reader, call.permission, placeIn(at[i]!, environments), gone(secret) || gone(at[i]!));
1055
1160
  });
1056
1161
  if (call.vet !== undefined && !codes.some((code) => code !== null)) {
1057
1162
  (await call.vet(d.tx)).forEach((code, i) => (codes[i] = code));
1058
1163
  }
1164
+ // A sensitive value is only ever used for a person, never handed back, by whatever credential of theirs asks. And
1165
+ // a fork copies one, for anyone, a service account too, only where its app says it keeps it sensitive: an app of
1166
+ // 0.9, mid-deploy, would write the copy as a secret its people read.
1167
+ const copying = action === 'secret.read' && call.input.purpose === 'copy';
1168
+ const handedBack = action === 'secret.read' && principal.startsWith('user:') && handsBack({ purpose: call.input.purpose ?? '', keepsSensitive: call.input.keepsSensitive === true });
1169
+ if (handedBack || (copying && call.input.keepsSensitive !== true)) {
1170
+ secrets.forEach((secret, i) => {
1171
+ if (codes[i] === null && secret.sensitive === true) codes[i] = 'sensitive';
1172
+ });
1173
+ }
1059
1174
  let first = codes.find((code) => code !== null) ?? null;
1060
- // A person's values are only ever used for them, where people get stand-ins: by whatever credential of theirs asks.
1061
- if (first === null && action === 'secret.read' && this.#config.people === 'stand-ins' && principal.startsWith('user:') && HANDED_BACK.includes(call.input.purpose as Purpose)) first = 'stand_ins';
1062
1175
  if (first === null && action === 'secret.read' && (await this.#overBulkLimit(d, principal, secrets.length))) first = 'bulk_limit';
1063
1176
  if (first !== null) {
1064
1177
  throw new Refused(
@@ -1087,7 +1200,6 @@ class VaultService implements Vault {
1087
1200
  if (call.remote) {
1088
1201
  const intent = await this.#decide([principal], async (d) => {
1089
1202
  await check(d);
1090
- await lockLogHead(d.tx);
1091
1203
  const at = await this.#now(d.tx);
1092
1204
  const appended = await this.#append(d.tx, [{
1093
1205
  actor: principal,
@@ -1155,6 +1267,16 @@ class VaultService implements Vault {
1155
1267
  // check above is seen here, and nothing is released after it.
1156
1268
  // A read through a reference: its holder's place too, which a deletion may have taken.
1157
1269
  await lockLogHead(d.tx);
1270
+ // And as the policy is now, under the head every change to it takes: an environment protected, or a role narrowed,
1271
+ // since the check above is seen here, and nothing it refuses now is released.
1272
+ try {
1273
+ await check(d);
1274
+ } catch (error) {
1275
+ if (error instanceof Refused) {
1276
+ throw new Refused(error.refusal, secrets.map((secret, item) => outcomeEntry(secret, item, 'deny', error.entries[item].code ?? error.refusal.code)));
1277
+ }
1278
+ throw error;
1279
+ }
1158
1280
  const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
1159
1281
  const now = await store.environmentsById(d.tx, places.map((place) => place.environmentId));
1160
1282
  if (places.some((place) => now.get(place.environmentId)?.deleted ?? false)) {
@@ -1169,7 +1291,7 @@ class VaultService implements Vault {
1169
1291
  } finally {
1170
1292
  for (const value of done) value.wipe();
1171
1293
  }
1172
- });
1294
+ }, true);
1173
1295
  }
1174
1296
 
1175
1297
  /** Whether `n` more keys would take `principal` past the bulk limit; counted under their row's lock, so exactly. */
@@ -1225,7 +1347,11 @@ class VaultService implements Vault {
1225
1347
  };
1226
1348
  };
1227
1349
  return this.#decide([principal], async (d) => {
1350
+ // Under the log's head, which a deletion and every change to the policy take: neither side deleted, nor protected,
1351
+ // nor a role narrowed, before this commits.
1228
1352
  const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
1353
+ // Each source's nature, its current version's, under the head: what its maker must hold there.
1354
+ const natures = await store.natures(d.tx, [...sources.values()].map((source) => source.secretId));
1229
1355
  // Both sides as the store has them: a grant on a slug matches there, and neither side may be deleted.
1230
1356
  const environments = await store.environmentsById(d.tx, items.flatMap((item) => {
1231
1357
  const source = sources.get(item.source.secretId);
@@ -1238,17 +1364,15 @@ class VaultService implements Vault {
1238
1364
  if (source.secretId === item.holder.secretId) return 'invalid';
1239
1365
  // A personal config's values are bound to its owner: no reference holds one, nor reads one.
1240
1366
  if (environments.get(item.holder.environmentId)?.owner != null || environments.get(source.environmentId)?.owner != null) return 'personal';
1367
+ // Making one hands its source's value to whoever reads the holder, whatever its nature becomes: its maker reads
1368
+ // it at the source, as any direct access there, and, for a sensitive one, which nobody reads, uses it there too.
1369
+ const at = placeIn(source, environments);
1241
1370
  return refuses(reader, 'secret.write', placeIn(item.holder, environments), gone(item.holder))
1242
- ?? refuses(reader, 'secret.read', placeIn(source, environments), gone(source));
1371
+ ?? refuses(reader, 'secret.read', at, gone(source))
1372
+ ?? (natures.get(source.secretId) === 'sensitive' ? refuses(reader, 'secret.use', at, gone(source)) : null);
1243
1373
  });
1244
1374
  const first = codes.find((code) => code !== null) ?? null;
1245
1375
  if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), items.map((item, i) => entry(item, 'deny', codes[i] ?? first)));
1246
- // Again under the log's head, as a read is: neither side was deleted since the check above.
1247
- d.underLock.push(async (locked) => {
1248
- const now = await store.environmentsById(locked, [...environments.keys()]);
1249
- if (![...now.values()].some((environment) => environment.deleted)) return;
1250
- throw new Refused(refusal('deleted', MESSAGES.deleted), items.map((item) => entry(item, 'deny', 'deleted')));
1251
- });
1252
1376
  const entries = items.map((item) => entry(item, 'allow', null));
1253
1377
  d.log.push(...entries);
1254
1378
  const answer = { seqs: [] as number[] };
@@ -1311,6 +1435,7 @@ class VaultService implements Vault {
1311
1435
  return seals.map((seal) => (done.has(seal.seq) ? ('ended' as const) : null));
1312
1436
  };
1313
1437
  return this.#decide([principal], async (d) => {
1438
+ // Under the log's head: two ends of one reference at once, the second finds the first; and the actor's standing stands.
1314
1439
  const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
1315
1440
  // Ending is allowed in a deleted place: deleting one ends the references into and out of it.
1316
1441
  const environments = await store.environmentsById(d.tx, seals.map((seal) => seal.holder.environmentId));
@@ -1326,11 +1451,6 @@ class VaultService implements Vault {
1326
1451
  if (!codes.some((code) => code !== null)) (await ended(d.tx)).forEach((code, i) => (codes[i] = code));
1327
1452
  const first = codes.find((code) => code !== null) ?? null;
1328
1453
  if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), vias.map((via, i) => entry(via, seals[i]!, 'deny', codes[i] ?? first)));
1329
- // Two ends of one reference at once: the second finds the first under the head's lock.
1330
- d.underLock.push(async (locked) => {
1331
- if ((await ended(locked)).every((code) => code === null)) return;
1332
- throw new Refused(refusal('ended', MESSAGES.ended), vias.map((via, i) => entry(via, seals[i]!, 'deny', 'ended')));
1333
- });
1334
1454
  const entries = vias.map((via, i) => entry(via, seals[i]!, 'allow', null));
1335
1455
  d.log.push(...entries);
1336
1456
  const answer = { seqs: [] as number[] };
@@ -1412,11 +1532,11 @@ class VaultService implements Vault {
1412
1532
  /**
1413
1533
  * Refuse a change that would leave `principal`, an Owner of the whole
1414
1534
  * instance, no longer one, unless another is. Counted under the log's
1415
- * head: two changes at once, each demoting or removing a different
1416
- * Owner, lock different rows, and only the head puts one after the other.
1535
+ * head, which every decision holds: two changes at once, each demoting or
1536
+ * removing a different Owner, lock different rows, and only the head puts
1537
+ * one after the other.
1417
1538
  */
1418
1539
  async #keepAnOwner(d: Decision, actor: string, principal: string, refused: (code: RefusalCode, message?: string) => Refused): Promise<void> {
1419
- await lockLogHead(d.tx);
1420
1540
  if ((await this.#anOwner(d.tx, d.reports, principal)) !== null) return;
1421
1541
  throw refused('last_owner', actor === principal
1422
1542
  ? "you're the last Owner of the whole instance: make someone else an Owner first"
@@ -1473,17 +1593,20 @@ class VaultService implements Vault {
1473
1593
  if (row?.status !== 'active') return { principal, status: row?.status ?? 'unknown', live: none, all: none, fault, stored: grants };
1474
1594
  const held = grants.map((grant) => ({ ...grant, role: grant.role as Role }));
1475
1595
  const { role, scope } = roleOf(principal, row);
1596
+ // Every decision about them reads the policy: their roles as an Owner edited them, and which environments are protected.
1597
+ const { policy } = await this.#policyAt(db, reports, at);
1598
+ const service = !principal.startsWith('user:');
1476
1599
  return {
1477
1600
  principal,
1478
1601
  status: 'active',
1479
- live: { role, scope, grants: held.filter((grant) => live(grant, at)) },
1480
- all: { role, scope, grants: held },
1602
+ live: { role, scope, grants: held.filter((grant) => live(grant, at)), policy, service },
1603
+ all: { role, scope, grants: held, policy, service },
1481
1604
  fault,
1482
1605
  stored: grants,
1483
1606
  };
1484
1607
  }
1485
1608
 
1486
- #access(principal: string, row: Member | undefined, held: readonly GrantRow[], at: number, tampered: boolean): Access {
1609
+ #access(principal: string, row: Member | undefined, held: readonly GrantRow[], at: number, tampered: boolean, policy: Policy): Access {
1487
1610
  const active = PRINCIPAL.test(principal) && !tampered && row?.status === 'active';
1488
1611
  return {
1489
1612
  principal,
@@ -1493,6 +1616,7 @@ class VaultService implements Vault {
1493
1616
  grants: active ? held.filter((grant) => live(grant, at) && grantKind(grant) !== null).map(view) : [],
1494
1617
  since: row ? iso(row.statusChangedAt) : null,
1495
1618
  by: row?.statusChangedBy ?? null,
1619
+ policy,
1496
1620
  };
1497
1621
  }
1498
1622
 
@@ -1520,7 +1644,8 @@ class VaultService implements Vault {
1520
1644
  const read = async (db: Queryable, reports: NewEntry[]) => {
1521
1645
  const [row, held, at] = await Promise.all([store.member(db, principal), store.grants(db, principal), this.#now(db)]);
1522
1646
  const fault = await this.#integrity(db, principal, row, held, reports);
1523
- return this.#access(principal, row, held, at, fault !== null);
1647
+ const { policy } = await this.#policyAt(db, reports, at);
1648
+ return this.#access(principal, row, held, at, fault !== null, policy);
1524
1649
  };
1525
1650
  const found: NewEntry[] = [];
1526
1651
  const quick = await read(this.#db, found).catch((error: unknown) => {
@@ -1588,10 +1713,7 @@ class VaultService implements Vault {
1588
1713
  const key = placeKey(change);
1589
1714
  if (places.has(key)) throw refused('invalid', 'each place may be changed once per call');
1590
1715
  places.add(key);
1591
- if (change.role !== null && !isRole(change.role)) throw refused('invalid', `no such role: ${change.role}`);
1592
- if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role)) {
1593
- throw refused('invalid', `${change.role} can only be granted on a project`);
1594
- }
1716
+ if (change.role !== null && typeof change.role !== 'string') throw refused('invalid', `no such role: ${String(change.role)}`);
1595
1717
  const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
1596
1718
  if (Number.isNaN(expiresAt) || (expiresAt !== null && expiresAt <= d.at)) {
1597
1719
  throw refused('invalid', 'an end date must be in the future');
@@ -1600,7 +1722,15 @@ class VaultService implements Vault {
1600
1722
  // A project or an environment must be there, and to be granted, not
1601
1723
  // deleted. Read under the log's head, which a deletion holds while it
1602
1724
  // renames the place, so the place is as it commits.
1603
- await lockLogHead(d.tx);
1725
+ // The roles as the policy defines them now, under the head every change to it takes: a role an Owner made is
1726
+ // granted as one that ships, and one deleted meanwhile is granted by nobody.
1727
+ const { policy } = await this.#policyAt(d.tx, d.reports, d.at);
1728
+ for (const change of changes) {
1729
+ if (change.role !== null && !isRole(change.role, policy)) throw refused('invalid', `no such role: ${change.role}`);
1730
+ if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role, policy)) {
1731
+ throw refused('invalid', `${change.role} can only be granted on a project`);
1732
+ }
1733
+ }
1604
1734
  const known = await store.places(
1605
1735
  d.tx,
1606
1736
  changes.map((change) => change.projectId!),
@@ -1931,6 +2061,143 @@ class VaultService implements Vault {
1931
2061
  return isScope(serviceAccounts) ? { serviceAccounts } : CLOSED_SETTINGS;
1932
2062
  }
1933
2063
 
2064
+ // --- the instance's access policy -------------------------------------------
2065
+
2066
+ async policy(): Promise<Policy> {
2067
+ await this.#settled();
2068
+ const reports: NewEntry[] = [];
2069
+ const { policy } = await this.#policyAt(this.#db, reports, await this.#now(this.#db));
2070
+ await this.#record(reports);
2071
+ return policy;
2072
+ }
2073
+
2074
+ /**
2075
+ * The policy the vault's newest `policy.change` says, by its MAC, and its
2076
+ * edits, as of `at`: one in its name it did not write is passed over, and
2077
+ * reported. With such entries and none of its own within reach, the
2078
+ * policy is unreadable, and closed: every environment is protected, and
2079
+ * no role holds anything but Owner, otherwise whoever can insert rows
2080
+ * could unprotect them. Its edits are then unknown, not none.
2081
+ */
2082
+ async #policyAt(db: Queryable, reports: NewEntry[], at: number): Promise<{ policy: Policy; edits: PolicyEdits; readable: boolean }> {
2083
+ const entry = await newestAuthentic(
2084
+ (before) => store.newestVaultEntries(db, POLICY, LOOK_BACK.page, before),
2085
+ this.#check(reports, null),
2086
+ );
2087
+ if (entry === 'none') return { policy: DEFAULT_POLICY, edits: NO_EDITS, readable: true };
2088
+ const edits = typeof entry === 'string' ? null : readEdits(JSON.parse(entry.metadata) as { policy?: unknown });
2089
+ return edits === null ? { policy: CLOSED_POLICY, edits: NO_EDITS, readable: false } : { policy: await livePolicy(db, edits, at), edits, readable: true };
2090
+ }
2091
+
2092
+ changePolicy(input: ChangePolicyInput): Promise<Outcome<{ policy: Policy; seq: number | null }>> {
2093
+ const { actor, change, revision } = input;
2094
+ if (revision !== undefined && (typeof revision !== 'string' || !POLICY_REVISION.test(revision))) throw new Error('revision names a policy as policyRevision writes it');
2095
+ // Where a protection is, once its rows are found: its project, and its one environment if that has a row yet. A fork's,
2096
+ // protected before its row is written, is named by the change alone, as the log's columns name only rows.
2097
+ const at: { projectId?: string; environmentId?: string } = {};
2098
+ const entry = (decision: 'allow' | 'deny', detail: Record<string, unknown>, code: RefusalCode | null = null): NewEntry => ({
2099
+ actor,
2100
+ action: POLICY,
2101
+ decision,
2102
+ code,
2103
+ operationId: input.operationId ?? null,
2104
+ requestId: input.requestId ?? null,
2105
+ ...at,
2106
+ metadata: JSON.stringify({ ...detail, ...traced(input) }),
2107
+ });
2108
+ const refused = (code: RefusalCode, message = MESSAGES[code]) =>
2109
+ new Refused(refusal(code, message), [entry('deny', { change: describeChange(change) }, code)]);
2110
+ return this.#decide([actor], async (d) => {
2111
+ validateCorrelation(input);
2112
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
2113
+ if (acting.status === 'tampered') throw refused('tampered');
2114
+ if (acting.status !== 'active') throw refused(acting.status === 'removed' ? 'removed' : 'not_a_member');
2115
+ // Under the log's head, as the actor's standing above: two changes at once each start from the one before. One the
2116
+ // caller weighed from a revision of the policy, its newest change and its protected environments' rows, starts
2117
+ // from that one, or is refused, to be asked again: a protected prod renamed since is a policy changed too.
2118
+ const newest = await newestVaultSeq(d.tx, POLICY);
2119
+ const { policy: before, edits, readable } = await this.#policyAt(d.tx, d.reports, d.at);
2120
+ if (revision !== undefined && policyRevision(newest, before) !== revision) throw refused('policy_changed');
2121
+ const owner = acting.live.credentialScope == null && ownsInstance(acting.live);
2122
+ // Unreadable, its edits are unknown: a change made from none would erase every protection and role edit the forged
2123
+ // rows hide. An Owner of the whole instance alone writes it again, from the roles as they ship and nothing protected.
2124
+ if (!readable && !owner) throw refused('not_allowed', "the vault cannot read its own policy: only an Owner of the whole instance writes it again, from the roles as they ship");
2125
+ // Those the policy no longer holds stay out: a deleted environment's id, and an unwritten fork's past its window,
2126
+ // neither of which comes back, the window being one a fork writes its row well within.
2127
+ const next: PolicyEdits = {
2128
+ roles: { ...edits.roles },
2129
+ instanceRoles: { ...edits.instanceRoles },
2130
+ protected: edits.protected.filter((place) => before.protected.includes(place.environmentId)).map((place) => ({ ...place })),
2131
+ };
2132
+ if (change?.kind === 'role' || change?.kind === 'instance-role') {
2133
+ // Roles are the whole instance's: an Owner of all of it alone changes them (R1, R15), never through a scoped credential.
2134
+ if (!owner) throw refused('not_allowed', 'only an Owner of the whole instance edits roles');
2135
+ const instance = change.kind === 'instance-role';
2136
+ const fixed = instance ? FIXED_ROLES.instanceRoles.includes(change.role as InstanceRole) : FIXED_ROLES.roles.includes(change.role);
2137
+ if (typeof change.role !== 'string' || fixed) throw refused('invalid', `${change.role} is not edited: it holds ${change.role === 'member' ? 'nothing' : 'everything'}, always`);
2138
+ if (instance && !isInstanceRole(change.role)) throw refused('invalid', `no instance role ${change.role}: an Owner makes project roles`);
2139
+ if (!instance && !isBuiltinRole(change.role) && !ROLE_ID.test(change.role)) throw refused('invalid', 'a role is named by lowercase letters, digits and dashes, 2 to 32, starting with a letter');
2140
+ const table = instance ? next.instanceRoles as Record<string, RoleDefinition> : next.roles;
2141
+ if (change.definition === null) {
2142
+ // Put back as it ships, or, one an Owner made, gone: while no grant names it.
2143
+ if (!instance && !isBuiltinRole(change.role)) {
2144
+ if (table[change.role] === undefined) throw refused('invalid', `no role ${change.role}`);
2145
+ const held = await store.grantsNaming(d.tx, change.role);
2146
+ if (held > 0) throw refused('invalid', `${held === 1 ? '1 grant names' : `${held} grants name`} ${change.role}: revoke ${held === 1 ? 'it' : 'them'} first`);
2147
+ }
2148
+ // What it ships as is held to the same rule as any edit: a project-wide permission back on a role granted on environments.
2149
+ const shipped = instance ? undefined : isBuiltinRole(change.role) ? ROLES[change.role] : undefined;
2150
+ if (shipped !== undefined && shipped.permissions.some((permission) => PROJECT_ONLY_PERMISSIONS.includes(permission)) && (await store.grantsNaming(d.tx, change.role, { onEnvironments: true })) > 0) {
2151
+ throw refused('invalid', `${change.role} is granted on environments: as it ships, it holds a project-wide permission, which would make those grants incoherent`);
2152
+ }
2153
+ delete table[change.role];
2154
+ } else {
2155
+ const problem = definitionProblem(change.definition);
2156
+ if (problem !== null) throw refused('invalid', problem);
2157
+ const definition = normalDefinition(change.definition);
2158
+ // A project role granted on an environment keeps no project-wide permission while any grant there names it.
2159
+ if (!instance && definition.permissions.some((permission) => PROJECT_ONLY_PERMISSIONS.includes(permission)) && (await store.grantsNaming(d.tx, change.role, { onEnvironments: true })) > 0) {
2160
+ throw refused('invalid', `${change.role} is granted on environments: a project-wide permission would make those grants incoherent`);
2161
+ }
2162
+ const shipped = instance ? INSTANCE_ROLES[change.role as InstanceRole] : isBuiltinRole(change.role) ? ROLES[change.role] : undefined;
2163
+ if (shipped !== undefined && JSON.stringify(normalDefinition(shipped)) === JSON.stringify(definition)) delete table[change.role];
2164
+ else table[change.role] = definition;
2165
+ }
2166
+ } else if (change?.kind === 'protect') {
2167
+ if (typeof change.projectId !== 'string' || !UUID.test(change.projectId) || !Array.isArray(change.environmentIds) || change.environmentIds.length === 0
2168
+ || change.environmentIds.some((id) => typeof id !== 'string' || !UUID.test(id)) || typeof change.protected !== 'boolean') {
2169
+ throw refused('invalid', 'protection names a project and some of its environments, by id, and whether they are protected');
2170
+ }
2171
+ if (!(await store.projectLive(d.tx, change.projectId))) throw refused('invalid', 'no such project');
2172
+ at.projectId = change.projectId;
2173
+ // Each a live environment of the project, or none yet: a fork's id, protected before its row is written.
2174
+ const found = await store.environmentsById(d.tx, change.environmentIds);
2175
+ if ([...found.values()].some((environment) => environment.projectId !== change.projectId)) throw refused('invalid', 'each environment is one of the project\'s');
2176
+ if (change.environmentIds.length === 1 && found.has(change.environmentIds[0]!)) at.environmentId = change.environmentIds[0];
2177
+ if (change.protected) {
2178
+ if (!allows(acting.live, 'environment.manage', { projectId: change.projectId })) throw refused('not_allowed', 'protecting an environment takes managing its project\'s environments');
2179
+ const known = new Set(next.protected.map((place) => place.environmentId));
2180
+ next.protected = [...next.protected, ...change.environmentIds.filter((id) => !known.has(id)).map((environmentId) => ({ projectId: change.projectId, environmentId, at: d.at }))]
2181
+ .sort((a, b) => (a.environmentId < b.environmentId ? -1 : 1));
2182
+ } else {
2183
+ // Unprotecting widens what people hold there: an Owner of the whole instance's to do (R15).
2184
+ if (!owner) throw refused('not_allowed', 'only an Owner of the whole instance unprotects an environment');
2185
+ next.protected = next.protected.filter((place) => !change.environmentIds.includes(place.environmentId));
2186
+ }
2187
+ } else {
2188
+ throw refused('invalid', 'a change is to a role, an instance role, or protection');
2189
+ }
2190
+ // The policy now, and the newest change, which it is as of: this one's, when it changed anything.
2191
+ const answer = { policy: await livePolicy(d.tx, next, d.at), seq: newest === null ? null : Number(newest) };
2192
+ if (!readable || JSON.stringify(next) !== JSON.stringify(edits)) {
2193
+ const made = entry('allow', { policy: next, change: describeChange(change), before: describeBefore(change, before) });
2194
+ d.log.push(made);
2195
+ d.after.push((seqOf) => (answer.seq = seqOf(made)));
2196
+ }
2197
+ return answer;
2198
+ });
2199
+ }
2200
+
1934
2201
  setSettings(input: SetSettingsInput): Promise<Outcome<{ settings: Settings }>> {
1935
2202
  const { actor } = input;
1936
2203
  const entry = (decision: 'allow' | 'deny', detail: Record<string, unknown>, code: RefusalCode | null = null): NewEntry => ({
@@ -1951,7 +2218,6 @@ class VaultService implements Vault {
1951
2218
  if (!runsInstance(acting.live)) throw refused('not_allowed', "only admins and owners of the whole instance change its settings");
1952
2219
  if (!isScope(input.settings?.serviceAccounts)) throw refused('invalid', 'who sets up service accounts is a scope: { projects, environments }, each all, only or except');
1953
2220
  // Under the log's head, so two changes at once each name the one before.
1954
- await lockLogHead(d.tx);
1955
2221
  const previous = await this.#settingsAt(d.tx, d.reports);
1956
2222
  const settings: Settings = { serviceAccounts: normalScope(input.settings.serviceAccounts) };
1957
2223
  if (JSON.stringify(settings) !== JSON.stringify(previous)) d.log.push(entry('allow', { ...settings, previous }));
@@ -2442,6 +2708,7 @@ function validateItems(items: readonly { secret: SecretRef; key?: string; wrappe
2442
2708
  if (field === 'key' && typeof key !== 'string') throw new Error('DEK must be base64');
2443
2709
  if (field === 'wrapped' && (wrapped === undefined || wrapped === null)) throw new Error('wrapped key is required');
2444
2710
  checkContext(context(secret));
2711
+ if (secret.sensitive !== undefined && typeof secret.sensitive !== 'boolean') throw new Error('a secret is sensitive or not: true, false or absent');
2445
2712
  if (!Number.isSafeInteger(secret.version) || secret.version < 1) throw new Error('secret version must be a positive integer');
2446
2713
  if (typeof secret.path !== 'string' || /[\uD800-\uDFFF]/u.test(secret.path)) throw new Error('secret path must be a well-formed string');
2447
2714
  if (key !== undefined) {
@@ -2578,10 +2845,13 @@ function personalRefusal(
2578
2845
  if (own?.owner == null && held?.owner == null) return undefined;
2579
2846
  if (own !== held || principal !== own?.owner || own.parentId === null) return 'personal';
2580
2847
  const parent = placeIn({ projectId: secret.projectId, environmentId: own.parentId }, environments);
2581
- const refused = refuses(reader, 'secret.read', parent, own.deleted || (environments.get(own.parentId)?.deleted ?? true));
2848
+ // Its owner reads what they wrote while they read or use the environment it configures (R8).
2849
+ const gone = own.deleted || (environments.get(own.parentId)?.deleted ?? true);
2850
+ const refused = refuses(reader, 'secret.use', parent, gone) === null ? null : refuses(reader, 'secret.read', parent, gone);
2582
2851
  if (refused !== null) return refused;
2583
- // The credential's scope caps the owner still: their viewer token on the parent reads, and wraps no key.
2584
- return withinCredential(reader.live, permission, parent) ? null : 'no_grant';
2852
+ // The credential's scope caps the owner still: their viewer token on the parent reads, and wraps no key. Reading their
2853
+ // own values takes use there: a Contributor's handle reads what its owner wrote, and nothing of the parent's.
2854
+ return withinCredential(reader.live, permission === 'secret.read' ? 'secret.use' : permission, parent) ? null : 'no_grant';
2585
2855
  }
2586
2856
 
2587
2857
  function refuses(reader: Standing, permission: Permission, where: Place, deleted: boolean): RefusalCode | null {
@@ -2781,9 +3051,71 @@ function iso(ms: number): string {
2781
3051
  return new Date(ms).toISOString();
2782
3052
  }
2783
3053
 
2784
- /** A secret's binding context: its ids, and a personal config's owner, hashed, as the decision read it. */
3054
+ /** A policy entry's edits, checked as the vault wrote them, or null for any that does not hold. */
3055
+ function readEdits(stored: { policy?: unknown }): PolicyEdits | null {
3056
+ const edits = stored.policy as Partial<PolicyEdits> | undefined;
3057
+ if (typeof edits !== 'object' || edits === null) return null;
3058
+ const { roles, instanceRoles, protected: protectedIds } = edits;
3059
+ if (typeof roles !== 'object' || roles === null || typeof instanceRoles !== 'object' || instanceRoles === null || !Array.isArray(protectedIds)) return null;
3060
+ const defined = (table: object) => Object.values(table).every((definition) => definitionProblem(definition as RoleDefinition) === null);
3061
+ if (!defined(roles) || !defined(instanceRoles) || Object.keys(instanceRoles).some((role) => !isInstanceRole(role))) return null;
3062
+ if (protectedIds.some((place) => typeof place !== 'object' || place === null || !UUID.test((place as ProtectedPlace).projectId) || !UUID.test((place as ProtectedPlace).environmentId)
3063
+ || !Number.isSafeInteger((place as ProtectedPlace).at))) return null;
3064
+ return { roles: roles as Record<string, RoleDefinition>, instanceRoles: instanceRoles as PolicyEdits['instanceRoles'], protected: protectedIds as ProtectedPlace[] };
3065
+ }
3066
+
3067
+ /**
3068
+ * The policy `edits` say as of `at`: the roles as they ship, each edited
3069
+ * one replaced and each made one added; and the protected environments, but
3070
+ * for those deleted since, the project around one's included: a deleted
3071
+ * prod protects nothing, and holds nobody back from giving a service
3072
+ * account a role on all of its project (`givesService`). A fork's id with
3073
+ * no row yet stays, protected before it is written, ten minutes: one with
3074
+ * none after that is a fork refused or abandoned, never written. Each
3075
+ * keeps its slug, which a scoped instance role's filters read. The edits
3076
+ * keep them all: no write is needed.
3077
+ */
3078
+ async function livePolicy(db: Queryable, edits: PolicyEdits, at: number): Promise<Policy> {
3079
+ const rows = await store.environmentsById(db, edits.protected.map((place) => place.environmentId));
3080
+ const live = edits.protected.filter((place) => {
3081
+ const row = rows.get(place.environmentId);
3082
+ return row === undefined ? at - place.at < UNWRITTEN_FORK_MS : !row.deleted;
3083
+ });
3084
+ const slugOf = (place: ProtectedPlace) => rows.get(place.environmentId)?.slug ?? null;
3085
+ return {
3086
+ roles: { ...ROLES, ...edits.roles },
3087
+ instanceRoles: { ...INSTANCE_ROLES, ...edits.instanceRoles },
3088
+ protected: live.map((place) => place.environmentId),
3089
+ protectedIn: live.reduce<Record<string, { environmentId: string; slug: string | null }[]>>(
3090
+ (byProject, place) => ({ ...byProject, [place.projectId]: [...(byProject[place.projectId] ?? []), { environmentId: place.environmentId, slug: slugOf(place) }] }),
3091
+ {},
3092
+ ),
3093
+ };
3094
+ }
3095
+
3096
+ /** A change, as its entry names it. */
3097
+ function describeChange(change: PolicyChange | undefined): Record<string, unknown> | null {
3098
+ if (change === undefined || change === null || typeof change !== 'object') return null;
3099
+ if (change.kind === 'protect') return { kind: 'protect', projectId: change.projectId, environmentIds: change.environmentIds, protected: change.protected };
3100
+ return { kind: change.kind, role: change.role, definition: change.definition };
3101
+ }
3102
+
3103
+ /** What a change changed, as it was: the role's definition, or which environments were protected. */
3104
+ function describeBefore(change: PolicyChange, before: Policy): Record<string, unknown> {
3105
+ if (change.kind === 'protect') return { protected: change.environmentIds.filter((id) => before.protected.includes(id)) };
3106
+ const table = change.kind === 'instance-role' ? before.instanceRoles as Record<string, RoleDefinition> : before.roles;
3107
+ return { definition: Object.hasOwn(table, change.role) ? table[change.role] : null };
3108
+ }
3109
+
3110
+ /** A secret's binding context: its ids, a personal config's owner, hashed, as the decision read it, and whether it is sensitive. */
2785
3111
  function context(secret: SecretRef, owner?: string): SecretContext {
2786
- return { projectId: secret.projectId, environmentId: secret.environmentId, secretId: secret.secretId, ...(owner === undefined ? {} : { owner }) };
3112
+ return {
3113
+ projectId: secret.projectId,
3114
+ environmentId: secret.environmentId,
3115
+ secretId: secret.secretId,
3116
+ ...(owner === undefined ? {} : { owner }),
3117
+ ...(secret.sensitive === true ? { sensitive: true as const } : {}),
3118
+ };
2787
3119
  }
2788
3120
 
2789
3121
  function unwrappable(wrapped: WrappedKey) {