@coffre/vault 0.2.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.
@@ -2,12 +2,14 @@ import { ACCESS_ACTIONS, checkpointMessage, checkpointVerifier, describeAccessFa
2
2
  import { DEK_BYTES, KEY_CHECK, KEY_CHECK_CONTEXT, KEY_CHECK_VALUE, KekBadClaimError, KekCancelledError, KekUnavailableError, LocalKekProvider } from "@coffre/core/kek";
3
3
  import { createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
4
4
  import { tablesOf } from "@coffre/db";
5
- import { allows, assignableToEnvironment, isRole, mayManageAccess } from "@coffre/core/access";
5
+ import { allows, assignableToEnvironment, everyProjectPath, grantKind, isRole, mayManageAccess } from "@coffre/core/access";
6
6
  import { GENESIS_HASH, deriveLogKey, verifyEntries } from "@coffre/core/audit";
7
7
  import { checkContext } from "@coffre/core/envelope";
8
- import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUniqueViolation } from "@coffre/db/dialect";
8
+ import { shownMember } from "@coffre/core/schemas";
9
+ import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUniqueViolation, tombstone, truth } from "@coffre/db/dialect";
9
10
  import { appendEntries, lockLogHead } from "@coffre/db/log";
10
- import { and, asc, count, desc, eq, gt, gte, inArray, isNull, lt, lte, sql } from "drizzle-orm";
11
+ import { insertGrant, readGrants } from "@coffre/db/grants";
12
+ import { and, asc, count, desc, eq, gt, gte, inArray, isNull, lt, lte, or, sql } from "drizzle-orm";
11
13
  //#region src/accounting.ts
12
14
  const KEY_ACTIONS = /* @__PURE__ */ new Set([
13
15
  "secret.read",
@@ -228,29 +230,35 @@ async function updateMember(tx, principal, change) {
228
230
  await tx.update(vaultMembers).set(change).where(eq(vaultMembers.principal, principal));
229
231
  }
230
232
  /** Grants, lapsed ones too, each with its project, which an environment's grant finds through `environments`. */
231
- async function grants(db, principal) {
232
- const { vaultGrants, environments } = tablesOf(db);
233
- return db.select({
234
- principal: vaultGrants.principal,
235
- projectId: sql`coalesce(${vaultGrants.projectId}, ${environments.projectId})`,
236
- environmentId: vaultGrants.environmentId,
237
- role: vaultGrants.role,
238
- expiresAt: vaultGrants.expiresAt,
239
- grantedAt: vaultGrants.grantedAt,
240
- grantedBy: vaultGrants.grantedBy
241
- }).from(vaultGrants).leftJoin(environments, eq(environments.id, vaultGrants.environmentId)).where(principal === void 0 ? void 0 : eq(vaultGrants.principal, principal));
242
- }
243
- /** A grant names its environment, or its project when it has none: exactly one. */
233
+ function grants(db, principal) {
234
+ return readGrants(db, principal === void 0 ? {} : { principal });
235
+ }
236
+ /**
237
+ * A grant's row names its environment, or its project when it has none, or
238
+ * neither on every project, with the slug it covers there, if any.
239
+ */
244
240
  function at(db, place) {
245
241
  const { vaultGrants } = tablesOf(db);
246
- return place.environmentId === null ? and(eq(vaultGrants.projectId, place.projectId), isNull(vaultGrants.environmentId)) : eq(vaultGrants.environmentId, place.environmentId);
242
+ if (place.environmentId !== null) return eq(vaultGrants.environmentId, place.environmentId);
243
+ if (place.projectId !== null) return and(eq(vaultGrants.projectId, place.projectId), isNull(vaultGrants.environmentId));
244
+ return and(isNull(vaultGrants.projectId), isNull(vaultGrants.environmentId), place.environmentSlug === null ? isNull(vaultGrants.environmentSlug) : eq(vaultGrants.environmentSlug, place.environmentSlug));
247
245
  }
248
- async function insertGrant(tx, grant) {
249
- const { vaultGrants } = tablesOf(tx);
250
- await tx.insert(vaultGrants).values({
251
- ...grant,
252
- projectId: grant.environmentId === null ? grant.projectId : null
253
- });
246
+ /**
247
+ * Each of these environments that exists, by id, with its project, its
248
+ * slug, what grants on one slug in every project match, and whether it was
249
+ * deleted, alone or with its project: then no key under it opens, or is
250
+ * wrapped, whatever grant would cover it.
251
+ */
252
+ async function environmentsById(db, ids) {
253
+ if (ids.length === 0) return /* @__PURE__ */ new Map();
254
+ const { environments, projects } = tablesOf(db);
255
+ const rows = await db.select({
256
+ id: environments.id,
257
+ projectId: environments.projectId,
258
+ slug: environments.slug,
259
+ deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug)))
260
+ }).from(environments).innerJoin(projects, eq(projects.id, environments.projectId)).where(inArray(environments.id, [...new Set(ids)]));
261
+ return new Map(rows.map(({ id, ...environment }) => [id, environment]));
254
262
  }
255
263
  async function deleteGrant(tx, principal, place) {
256
264
  const { vaultGrants } = tablesOf(tx);
@@ -276,7 +284,7 @@ async function wrappedUnder(db, provider, keyId, limit) {
276
284
  kekId: secretVersions.kekId,
277
285
  kekVersion: secretVersions.kekVersion,
278
286
  bytes: secretVersions.wrappedDek
279
- }).from(secretVersions).innerJoin(secrets, eq(secrets.id, secretVersions.secretId)).where(and(eq(secretVersions.kekProvider, provider), eq(secretVersions.kekId, keyId))).orderBy(desc(secretVersions.createdAt)).limit(limit)).map((row) => ({
287
+ }).from(secretVersions).innerJoin(secrets, eq(secrets.id, secretVersions.secretId)).where(and(eq(secretVersions.kekProvider, provider), eq(secretVersions.kekId, keyId), sealed$1(secretVersions.wrappedDek))).orderBy(desc(secretVersions.createdAt)).limit(limit)).map((row) => ({
280
288
  ...row,
281
289
  bytes: Buffer.from(row.bytes)
282
290
  }));
@@ -284,13 +292,18 @@ async function wrappedUnder(db, provider, keyId, limit) {
284
292
  /** Of these projects and environments, the ones that exist, each environment with its project. */
285
293
  async function places(db, projectIds, environmentIds) {
286
294
  const { projects, environments } = tablesOf(db);
287
- const [foundProjects, foundEnvironments] = await Promise.all([projectIds.length === 0 ? [] : db.select({ id: projects.id }).from(projects).where(inArray(projects.id, [...projectIds])), environmentIds.length === 0 ? [] : db.select({
295
+ const [foundProjects, foundEnvironments] = await Promise.all([projectIds.length === 0 ? [] : db.select({
296
+ id: projects.id,
297
+ deleted: truth(tombstone(projects.slug))
298
+ }).from(projects).where(inArray(projects.id, [...projectIds])), environmentIds.length === 0 ? [] : db.select({
288
299
  id: environments.id,
289
- projectId: environments.projectId
290
- }).from(environments).where(inArray(environments.id, [...environmentIds]))]);
300
+ projectId: environments.projectId,
301
+ deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug)))
302
+ }).from(environments).innerJoin(projects, eq(projects.id, environments.projectId)).where(inArray(environments.id, [...environmentIds]))]);
291
303
  return {
292
304
  projects: new Set(foundProjects.map((row) => row.id)),
293
- environments: new Map(foundEnvironments.map((row) => [row.id, row.projectId]))
305
+ environments: new Map(foundEnvironments.map((row) => [row.id, row.projectId])),
306
+ deleted: new Set([...foundProjects, ...foundEnvironments].filter((row) => row.deleted).map((row) => row.id))
294
307
  };
295
308
  }
296
309
  /** The action of an entry that releases a key, which the bulk limit counts. */
@@ -387,6 +400,52 @@ async function firstVaultEntryUnder(db, keyId) {
387
400
  const [row] = await db.select({ seq: auditLog.seq }).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.keyId, keyId))).orderBy(asc(auditLog.seq)).limit(1);
388
401
  return row?.seq;
389
402
  }
403
+ /** The entries at these seqs, any author, by seq: what a reference names as its seal. */
404
+ async function entriesAt(db, seqs) {
405
+ if (seqs.length === 0) return /* @__PURE__ */ new Map();
406
+ const { auditLog } = tablesOf(db);
407
+ const rows = stored(await db.select(entryColumns(db)).from(auditLog).where(inArray(auditLog.seq, [...new Set(seqs)])));
408
+ return new Map(rows.map((row) => [row.seq, row]));
409
+ }
410
+ /**
411
+ * Which of these `reference.create` entries a `reference.end` of the
412
+ * vault's names, allowed: one read through `audit_log_reference_end_idx`.
413
+ * An end that fails its MAC still ends: putting a reference back takes
414
+ * more than a forged end can undo, and refusing is the safe side.
415
+ */
416
+ async function endedReferences(db, seqs) {
417
+ if (seqs.length === 0) return /* @__PURE__ */ new Set();
418
+ const { auditLog } = tablesOf(db);
419
+ const rows = await db.select({ relatedSeq: auditLog.relatedSeq }).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.action, "reference.end"), eq(auditLog.decision, "allow"), inArray(auditLog.relatedSeq, [...new Set(seqs)])));
420
+ return new Set(rows.map((row) => row.relatedSeq));
421
+ }
422
+ /** The project each environment is in: a holder's ids, checked against each other. */
423
+ async function projectsOfEnvironments(db, ids) {
424
+ if (ids.length === 0) return /* @__PURE__ */ new Map();
425
+ const { environments } = tablesOf(db);
426
+ const rows = await db.select({
427
+ id: environments.id,
428
+ projectId: environments.projectId
429
+ }).from(environments).where(inArray(environments.id, [...new Set(ids)]));
430
+ return new Map(rows.map((row) => [row.id, row.projectId]));
431
+ }
432
+ async function secretsByIds(db, ids) {
433
+ if (ids.length === 0) return /* @__PURE__ */ new Map();
434
+ const { secrets, environments, projects } = tablesOf(db);
435
+ const rows = await db.select({
436
+ secretId: secrets.id,
437
+ projectId: secrets.projectId,
438
+ environmentId: secrets.environmentId,
439
+ project: projects.slug,
440
+ environment: environments.slug,
441
+ key: secrets.key,
442
+ currentVersionId: secrets.currentVersionId
443
+ }).from(secrets).innerJoin(environments, eq(environments.id, secrets.environmentId)).innerJoin(projects, eq(projects.id, secrets.projectId)).where(inArray(secrets.id, [...new Set(ids)]));
444
+ return new Map(rows.map(({ project, environment, key, ...row }) => [row.secretId, {
445
+ ...row,
446
+ path: `${project}/${environment}/${key}`
447
+ }]));
448
+ }
390
449
  /** The vault's newest entry of any of these actions, allowed, or undefined. */
391
450
  async function latestVaultEntry(db, actions) {
392
451
  const { auditLog } = tablesOf(db);
@@ -410,6 +469,14 @@ async function vaultEntriesOf(db, actions, afterSeq, limit) {
410
469
  const { auditLog } = tablesOf(db);
411
470
  return stored(await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), inArray(auditLog.action, [...actions]), eq(auditLog.decision, "allow"), gt(auditLog.seq, afterSeq))).orderBy(asc(auditLog.seq)).limit(limit));
412
471
  }
472
+ /**
473
+ * A version that still holds its wrapped key. A deleted place's versions are
474
+ * erased, their keys emptied: there is nothing left to open, or to try a
475
+ * vault key on.
476
+ */
477
+ function sealed$1(wrappedDek) {
478
+ return sql`length(${wrappedDek}) > 0`;
479
+ }
413
480
  async function versions(db, ids) {
414
481
  if (ids.length === 0) return [];
415
482
  const { secretVersions, secrets, environments, projects } = tablesOf(db);
@@ -425,9 +492,13 @@ async function versions(db, ids) {
425
492
  kekProvider: secretVersions.kekProvider,
426
493
  kekId: secretVersions.kekId,
427
494
  kekVersion: secretVersions.kekVersion,
428
- wrappedDek: secretVersions.wrappedDek
429
- }).from(secretVersions).innerJoin(secrets, eq(secrets.id, secretVersions.secretId)).innerJoin(environments, eq(environments.id, secrets.environmentId)).innerJoin(projects, eq(projects.id, secrets.projectId)).where(inArray(secretVersions.id, [...new Set(ids)]))).map((row) => ({
495
+ wrappedDek: secretVersions.wrappedDek,
496
+ deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug))),
497
+ currentVersionId: secrets.currentVersionId,
498
+ newest: sql`(SELECT max(newest.version) FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId})`
499
+ }).from(secretVersions).innerJoin(secrets, eq(secrets.id, secretVersions.secretId)).innerJoin(environments, eq(environments.id, secrets.environmentId)).innerJoin(projects, eq(projects.id, secrets.projectId)).where(and(inArray(secretVersions.id, [...new Set(ids)]), or(sealed$1(secretVersions.wrappedDek), tombstone(environments.slug), tombstone(projects.slug))))).map((row) => ({
430
500
  id: row.id,
501
+ deleted: row.deleted,
431
502
  secret: {
432
503
  projectId: row.projectId,
433
504
  environmentId: row.environmentId,
@@ -440,7 +511,8 @@ async function versions(db, ids) {
440
511
  kekId: row.kekId,
441
512
  kekVersion: row.kekVersion,
442
513
  bytes: row.wrappedDek.toString("base64")
443
- }
514
+ },
515
+ current: row.currentVersionId === row.id && Number(row.newest) === row.version
444
516
  }));
445
517
  }
446
518
  //#endregion
@@ -635,10 +707,7 @@ function replayFault(state, rows, storedGrants, at) {
635
707
  function apply(state, row) {
636
708
  const principal = row.subjectPrincipal;
637
709
  const detail = JSON.parse(row.metadata);
638
- const place = {
639
- projectId: row.projectId,
640
- environmentId: row.environmentId
641
- };
710
+ const place = placeOf(row, detail);
642
711
  const grantsOf = state.held.get(principal) ?? /* @__PURE__ */ new Map();
643
712
  state.held.set(principal, grantsOf);
644
713
  const before = state.members.get(principal);
@@ -691,7 +760,7 @@ function apply(state, row) {
691
760
  return null;
692
761
  }
693
762
  case "access.grant":
694
- grantsOf.set(placeKey(place), {
763
+ grantsOf.set(placeKey$1(place), {
695
764
  principal,
696
765
  ...place,
697
766
  role: detail.role,
@@ -705,7 +774,7 @@ function apply(state, row) {
705
774
  });
706
775
  return null;
707
776
  case "access.revoke":
708
- grantsOf.delete(placeKey(place));
777
+ grantsOf.delete(placeKey$1(place));
709
778
  if (before !== void 0) state.members.set(principal, {
710
779
  ...before,
711
780
  accessSeq: row.seq
@@ -725,15 +794,32 @@ const MEMBER_FIELDS = [
725
794
  ["statusChangedBy", "status_changed_by"],
726
795
  ["accessSeq", "access_seq"]
727
796
  ];
728
- /** A grant's place: its environment, or its project when it has none. */
729
- function placeKey(place) {
730
- return place.environmentId ?? place.projectId;
797
+ /**
798
+ * The place an access entry is about: its project and environment, or, with
799
+ * neither, every project, which it names as a path (`*`, or `*` and a slug).
800
+ */
801
+ function placeOf(row, detail) {
802
+ if (row.projectId !== null || row.environmentId !== null) return {
803
+ projectId: row.projectId,
804
+ environmentId: row.environmentId,
805
+ environmentSlug: null
806
+ };
807
+ return {
808
+ projectId: null,
809
+ environmentId: null,
810
+ environmentSlug: (typeof detail.place === "string" ? detail.place.split("/")[1] : void 0) ?? null
811
+ };
812
+ }
813
+ /** A grant's place: its environment, or its project when it has none, or every project's path. */
814
+ function placeKey$1(place) {
815
+ return place.environmentId ?? place.projectId ?? everyProjectPath(place.environmentSlug);
731
816
  }
732
817
  function grantKey(grant) {
733
818
  return JSON.stringify([
734
819
  grant.principal,
735
820
  grant.projectId,
736
821
  grant.environmentId,
822
+ grant.environmentSlug,
737
823
  grant.role,
738
824
  grant.expiresAt,
739
825
  grant.grantedAt,
@@ -741,11 +827,12 @@ function grantKey(grant) {
741
827
  ]);
742
828
  }
743
829
  function faultGrant(key) {
744
- const [principal, projectId, environmentId, role] = JSON.parse(key);
830
+ const [principal, projectId, environmentId, environmentSlug, role] = JSON.parse(key);
745
831
  return {
746
832
  principal,
747
833
  projectId,
748
834
  environmentId,
835
+ environmentSlug,
749
836
  role
750
837
  };
751
838
  }
@@ -768,11 +855,17 @@ function faultGrant(key) {
768
855
  function rowKey(signingKey) {
769
856
  return Buffer.from(hkdfSync("sha256", signingKey, /* @__PURE__ */ new Uint8Array(0), "coffre.vault.rows.v1", 32));
770
857
  }
771
- /** A member's grants as the MAC covers them: each one's place, role, end and grant, as a sorted set. */
858
+ /**
859
+ * A member's grants as the MAC covers them: each one's place, role, end and
860
+ * grant, as a sorted set. A grant is kept by what its row names first: its
861
+ * environment, its project, or neither, which is a grant on every project,
862
+ * with the slug it covers, or null. A member who holds none of those has the
863
+ * set, and so the MAC, they had before there were any.
864
+ */
772
865
  function grantSet(grants) {
773
866
  return grants.map((grant) => [
774
- grant.environmentId === null ? "project" : "environment",
775
- grant.environmentId ?? grant.projectId,
867
+ grant.environmentId !== null ? "environment" : grant.projectId !== null ? "project" : "every-project",
868
+ grant.environmentId ?? grant.projectId ?? grant.environmentSlug,
776
869
  grant.role,
777
870
  grant.expiresAt,
778
871
  grant.grantedAt,
@@ -847,8 +940,8 @@ function openVault(db, prepared) {
847
940
  const KEY_BUDGET_MS = 5e3;
848
941
  /** How long a decision waits for a lock: above the key budget, so a removal outwaits a read in flight. */
849
942
  const LOCK_TIMEOUT_MS = 15e3;
850
- const PRINCIPAL = /^(user|token|sync):[^\s:][^\s]*$/;
851
- const LIVE_PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
943
+ /** A member: `user:<email>` or `token:<name>`. */
944
+ const PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
852
945
  /** Who acts for the vault itself, as when it gives a root admin a member row. */
853
946
  const VAULT_ACTOR = "system:vault";
854
947
  /** How many stored keys a KEK with no check yet is tried on: one that opens proves it. */
@@ -907,6 +1000,8 @@ const MESSAGES = {
907
1000
  expired: "the grant that covered this has expired",
908
1001
  bulk_limit: "too many secrets read in too short a time",
909
1002
  bad_claim: "the key does not belong to this secret",
1003
+ deleted: "this project or environment was deleted",
1004
+ ended: "the reference read through was ended",
910
1005
  not_allowed: "not allowed to change this",
911
1006
  root_admin: "root admins are set in the vault configuration",
912
1007
  invalid: "not something the rules allow",
@@ -979,14 +1074,15 @@ var VaultService = class {
979
1074
  touched: /* @__PURE__ */ new Set(),
980
1075
  grants: /* @__PURE__ */ new Map(),
981
1076
  reports,
982
- after: []
1077
+ after: [],
1078
+ underLock: []
983
1079
  };
984
1080
  const result = await decide(d);
985
1081
  const entries = [...reports, ...d.log];
986
1082
  let at = d.at;
987
1083
  const accessSeq = /* @__PURE__ */ new Map();
988
1084
  if (entries.length > 0) {
989
- const appended = await this.#append(tx, entries);
1085
+ const appended = await this.#append(tx, entries, d.underLock);
990
1086
  at = appended.occurredAt;
991
1087
  entries.forEach((entry, i) => {
992
1088
  if (isAccessEntry(entry)) accessSeq.set(entry.subjectPrincipal, appended.seqStart + BigInt(i));
@@ -1204,33 +1300,57 @@ var VaultService = class {
1204
1300
  * that an instance still running with the replaced KEK, as during a
1205
1301
  * deploy, cannot write after the rotation.
1206
1302
  */
1207
- #append(tx, entries) {
1303
+ #append(tx, entries, underLock = []) {
1208
1304
  const prepared = this.#prepared;
1209
1305
  return appendEntries(tx, prepared.logKey, entries, async (locked) => {
1210
1306
  const rotation = await latestVaultEntry(locked, [KEY_ROTATE]);
1211
- if (rotation === void 0 || prepared.logKeys.some((key) => key.keyId === rotation.keyId)) return;
1212
- 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`;
1213
- throw new Error(prepared.superseded);
1307
+ if (rotation !== void 0 && !prepared.logKeys.some((key) => key.keyId === rotation.keyId)) {
1308
+ 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`;
1309
+ throw new Error(prepared.superseded);
1310
+ }
1311
+ for (const check of underLock) await check(locked);
1214
1312
  });
1215
1313
  }
1216
1314
  async unwrap(input) {
1217
1315
  const { principal } = input;
1218
1316
  validateText(input.purpose);
1317
+ const vias = input.items.map((item) => validateVia(item.via));
1219
1318
  const loaded = await this.#versions(input, "secret.read", { purpose: input.purpose });
1220
1319
  if (!loaded.ok) return loaded;
1221
- const items = loaded.versions;
1320
+ const items = loaded.versions.map((version) => ({
1321
+ ...version,
1322
+ secret: { ...version.secret }
1323
+ }));
1222
1324
  validateItems(items, "wrapped");
1325
+ const sealed = await this.#referencesAt(vias);
1223
1326
  const versionIds = new Map(items.map((item) => [item.secret, item.id]));
1224
- const entry = (secret, decision, code) => ({
1225
- ...keyEntry("secret.read", principal, secret, decision, code, input, { purpose: input.purpose }),
1226
- secretVersionId: versionIds.get(secret)
1227
- });
1327
+ const viaOf = new Map(items.map((item, i) => [item.secret, {
1328
+ via: vias[i],
1329
+ sealed: sealed[i]
1330
+ }]));
1331
+ const entry = (secret, decision, code) => {
1332
+ const { via, sealed: seal } = viaOf.get(secret);
1333
+ return {
1334
+ ...keyEntry("secret.read", principal, secret, decision, code, input, {
1335
+ purpose: input.purpose,
1336
+ ...via === null ? {} : viaDetail(via, seal)
1337
+ }),
1338
+ secretVersionId: versionIds.get(secret)
1339
+ };
1340
+ };
1341
+ if (items.some((item, i) => vias[i] !== null && (sealed[i] === null || sealed[i].source.secretId !== item.secret.secretId || !item.current))) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")));
1342
+ const ends = sealed.flatMap((seal) => seal === null ? [] : [seal.seq]);
1228
1343
  const remote = items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
1229
1344
  return this.#keys({
1230
1345
  action: "secret.read",
1231
1346
  principal,
1232
1347
  permission: "secret.read",
1233
1348
  secrets: items.map((item) => item.secret),
1349
+ places: sealed.map((seal, i) => seal?.holder ?? items[i].secret),
1350
+ vet: ends.length === 0 ? void 0 : async (db) => {
1351
+ const ended = await endedReferences(db, ends);
1352
+ return sealed.map((seal) => seal !== null && ended.has(seal.seq) ? "ended" : null);
1353
+ },
1234
1354
  remote,
1235
1355
  entry,
1236
1356
  input
@@ -1287,7 +1407,7 @@ var VaultService = class {
1287
1407
  }));
1288
1408
  validateItems(items, "wrapped");
1289
1409
  const sameSecret = (secret, source) => secret.projectId === source.projectId && secret.environmentId === source.environmentId && secret.secretId === source.secretId;
1290
- if (items.some((item, i) => !sameSecret(item.secret, loaded.versions[i].secret))) return this.#badVersions(principal, loaded.versions.map((source) => ({
1410
+ if (items.some((item, i) => !sameSecret(item.secret, loaded.versions[i].secret))) return this.#refuseVersions(principal, "bad_claim", loaded.versions.map((source) => ({
1291
1411
  ...keyEntry("key.rewrap", principal, source.secret, "deny", "bad_claim", input),
1292
1412
  secretVersionId: source.id
1293
1413
  })));
@@ -1321,14 +1441,18 @@ var VaultService = class {
1321
1441
  seqs: []
1322
1442
  }));
1323
1443
  }
1324
- /** Immutable versions need no lock; their ids determine the whole batch before any key call. */
1444
+ /** 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. */
1325
1445
  async #versions(input, action, detail = {}) {
1326
1446
  validateText(input.principal);
1327
1447
  validateCorrelation(input);
1328
1448
  const ids = input.items.map((item) => item.secretVersionId);
1329
1449
  for (const id of ids) if (typeof id !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id)) throw new Error("secret version id must be a lowercase UUID");
1330
1450
  const found = new Map((await versions(this.#db, ids)).map((version) => [version.id, version]));
1331
- if (ids.some((id) => !found.has(id))) return this.#badVersions(input.principal, ids.map((id) => {
1451
+ if ([...found.values()].some((version) => version.deleted)) return this.#refuseVersions(input.principal, "deleted", [...found.values()].map((version) => ({
1452
+ ...keyEntry(action, input.principal, version.secret, "deny", "deleted", input, detail),
1453
+ secretVersionId: version.id
1454
+ })));
1455
+ if (ids.some((id) => !found.has(id))) return this.#refuseVersions(input.principal, "bad_claim", ids.map((id) => {
1332
1456
  const version = found.get(id);
1333
1457
  if (version === void 0) return {
1334
1458
  actor: input.principal,
@@ -1353,10 +1477,10 @@ var VaultService = class {
1353
1477
  versions: ids.map((id) => found.get(id))
1354
1478
  };
1355
1479
  }
1356
- async #badVersions(principal, entries) {
1480
+ async #refuseVersions(principal, code, entries) {
1357
1481
  if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
1358
1482
  return this.#decide([principal], async () => {
1359
- throw new Refused(refusal("bad_claim", MESSAGES.bad_claim), entries);
1483
+ throw new Refused(refusal(code, MESSAGES[code]), entries);
1360
1484
  });
1361
1485
  }
1362
1486
  /**
@@ -1393,7 +1517,11 @@ var VaultService = class {
1393
1517
  if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
1394
1518
  const check = async (d) => {
1395
1519
  const reader = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports);
1396
- const codes = secrets.map((secret) => refuses(reader, call.permission, secret));
1520
+ const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
1521
+ const environments = await environmentsById(d.tx, [...secrets, ...at].map((place) => place.environmentId));
1522
+ const gone = (place) => environments.get(place.environmentId)?.deleted ?? false;
1523
+ const codes = secrets.map((secret, i) => refuses(reader, call.permission, placeIn(at[i], environments), gone(secret) || gone(at[i])));
1524
+ if (call.vet !== void 0 && !codes.some((code) => code !== null)) (await call.vet(d.tx)).forEach((code, i) => codes[i] = code);
1397
1525
  let first = codes.find((code) => code !== null) ?? null;
1398
1526
  if (first === null && action === "secret.read" && await this.#overBulkLimit(d, principal, secrets.length)) first = "bulk_limit";
1399
1527
  if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, i) => entry(secret, "deny", codes[i] ?? first)));
@@ -1449,6 +1577,13 @@ var VaultService = class {
1449
1577
  if (error instanceof Refused) throw new Refused(error.refusal, secrets.map((secret, item) => outcomeEntry(secret, item, "deny", error.entries[item].code ?? error.refusal.code)));
1450
1578
  throw error;
1451
1579
  }
1580
+ const { vet } = call;
1581
+ if (vet !== void 0) d.underLock.push(async (locked) => {
1582
+ const codes = await vet(locked);
1583
+ const first = codes.find((code) => code !== null);
1584
+ if (first === void 0 || first === null) return;
1585
+ throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, "deny", codes[item] ?? first)));
1586
+ });
1452
1587
  const { outcomes, expired } = await settle(operations(), this.#prepared.options.keyBudgetMs);
1453
1588
  const done = outcomes.flatMap((outcome) => outcome.ok ? [outcome.value] : []);
1454
1589
  try {
@@ -1459,6 +1594,10 @@ var VaultService = class {
1459
1594
  if (unanswered > 0) throw new Outage(new KekUnavailableError(`the key service did not answer for ${unanswered} of ${secrets.length} keys`, outcomes.some((outcome) => !outcome.ok && outcome.code === "kms_uncertain")), entries());
1460
1595
  if (expired) throw new Outage(new KekUnavailableError("key operation exceeded its deadline"), entries());
1461
1596
  if (done.length < outcomes.length) throw new Refused(refusal("bad_claim", MESSAGES.bad_claim), entries());
1597
+ await lockLogHead(d.tx);
1598
+ const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
1599
+ const now = await environmentsById(d.tx, places.map((place) => place.environmentId));
1600
+ if (places.some((place) => now.get(place.environmentId)?.deleted ?? false)) throw new Refused(refusal("deleted", MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, "deny", "deleted")));
1462
1601
  const released = secrets.map((secret, i) => outcomeEntry(secret, i, "allow", null));
1463
1602
  d.log.push(...released);
1464
1603
  const answer = result(done);
@@ -1474,6 +1613,174 @@ var VaultService = class {
1474
1613
  const { count, windowMs } = this.#config.bulkLimit;
1475
1614
  return await releasesSince(d.tx, principal, d.at - windowMs) + n > count;
1476
1615
  }
1616
+ /**
1617
+ * Make each holder a reference to its source: the actor writes the holder
1618
+ * and reads the source, by their own grants, and the vault's
1619
+ * `reference.create` entry is the reference. The app's row only points at
1620
+ * it; a read through one is checked against the entry (`#referencesAt`).
1621
+ */
1622
+ async reference(input) {
1623
+ const { principal, items } = input;
1624
+ validateText(principal);
1625
+ validateCorrelation(input);
1626
+ for (const item of items) {
1627
+ if (typeof item.id !== "string" || !UUID.test(item.id)) throw new Error("a reference id must be a lowercase UUID");
1628
+ if (typeof item.source?.secretId !== "string" || !UUID.test(item.source.secretId)) throw new Error("a source must be named by its secret id");
1629
+ checkContext(item.holder);
1630
+ validateText(item.holder.path);
1631
+ }
1632
+ if (new Set(items.map((item) => item.id)).size !== items.length) throw new Error("each reference needs its own id");
1633
+ if (items.length === 0) return {
1634
+ ok: true,
1635
+ seqs: []
1636
+ };
1637
+ const [sources, projectOf] = await Promise.all([secretsByIds(this.#db, items.map((item) => item.source.secretId)), projectsOfEnvironments(this.#db, items.map((item) => item.holder.environmentId))]);
1638
+ const entry = (item, decision, code) => {
1639
+ const source = sources.get(item.source.secretId);
1640
+ return {
1641
+ actor: principal,
1642
+ action: "reference.create",
1643
+ decision,
1644
+ code,
1645
+ projectId: projectOf.has(item.holder.environmentId) ? item.holder.projectId : null,
1646
+ environmentId: projectOf.has(item.holder.environmentId) ? item.holder.environmentId : null,
1647
+ operationId: input.operationId ?? null,
1648
+ requestId: input.requestId ?? null,
1649
+ metadata: JSON.stringify({
1650
+ reference: item.id,
1651
+ subject: item.holder.path,
1652
+ secretId: item.holder.secretId,
1653
+ source: source === void 0 ? { secretId: item.source.secretId } : { path: source.path },
1654
+ ...source === void 0 ? {} : { also: {
1655
+ projectId: source.projectId,
1656
+ environmentId: source.environmentId,
1657
+ secretId: source.secretId
1658
+ } },
1659
+ ...traced(input)
1660
+ })
1661
+ };
1662
+ };
1663
+ if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
1664
+ return this.#decide([principal], async (d) => {
1665
+ const reader = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports);
1666
+ const environments = await environmentsById(d.tx, items.flatMap((item) => {
1667
+ const source = sources.get(item.source.secretId);
1668
+ return source === void 0 ? [item.holder.environmentId] : [item.holder.environmentId, source.environmentId];
1669
+ }));
1670
+ const gone = (place) => environments.get(place.environmentId)?.deleted ?? false;
1671
+ const codes = items.map((item) => {
1672
+ const source = sources.get(item.source.secretId);
1673
+ if (source === void 0 || projectOf.get(item.holder.environmentId) !== item.holder.projectId) return "bad_claim";
1674
+ if (source.secretId === item.holder.secretId) return "invalid";
1675
+ return refuses(reader, "secret.write", placeIn(item.holder, environments), gone(item.holder)) ?? refuses(reader, "secret.read", placeIn(source, environments), gone(source));
1676
+ });
1677
+ const first = codes.find((code) => code !== null) ?? null;
1678
+ if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), items.map((item, i) => entry(item, "deny", codes[i] ?? first)));
1679
+ d.underLock.push(async (locked) => {
1680
+ if (![...(await environmentsById(locked, [...environments.keys()])).values()].some((environment) => environment.deleted)) return;
1681
+ throw new Refused(refusal("deleted", MESSAGES.deleted), items.map((item) => entry(item, "deny", "deleted")));
1682
+ });
1683
+ const entries = items.map((item) => entry(item, "allow", null));
1684
+ d.log.push(...entries);
1685
+ const answer = { seqs: [] };
1686
+ d.after.push((seqOf) => answer.seqs = entries.map(seqOf));
1687
+ return answer;
1688
+ });
1689
+ }
1690
+ /**
1691
+ * End references, each with a `reference.end` naming its creation by
1692
+ * `related_seq`, where the next read through it looks. `replaced` takes
1693
+ * write on the holder; `broken`, write on the holder or managing the
1694
+ * source's project's access. Ending one already ended is refused.
1695
+ */
1696
+ async endReferences(input) {
1697
+ const { principal, reason } = input;
1698
+ validateText(principal);
1699
+ validateCorrelation(input);
1700
+ if (reason !== "replaced" && reason !== "broken" && reason !== "abandoned") throw new Error("a reference ends replaced, broken or abandoned");
1701
+ const vias = input.items.map((item) => validateVia(item));
1702
+ if (vias.some((via) => via === null) || new Set(vias.map((via) => via.seq)).size !== vias.length) throw new Error("each reference to end, once");
1703
+ if (vias.length === 0) return {
1704
+ ok: true,
1705
+ seqs: []
1706
+ };
1707
+ const sealed = await this.#referencesAt(vias);
1708
+ const holders = await secretsByIds(this.#db, sealed.flatMap((seal) => seal === null ? [] : [seal.holder.secretId]));
1709
+ const entry = (via, seal, decision, code) => ({
1710
+ actor: principal,
1711
+ action: "reference.end",
1712
+ decision,
1713
+ code,
1714
+ projectId: seal?.holder.projectId ?? null,
1715
+ environmentId: seal?.holder.environmentId ?? null,
1716
+ secretId: seal !== null && holders.has(seal.holder.secretId) ? seal.holder.secretId : null,
1717
+ relatedSeq: seal === null ? null : seal.seq,
1718
+ operationId: input.operationId ?? null,
1719
+ requestId: input.requestId ?? null,
1720
+ metadata: JSON.stringify({
1721
+ reference: via.reference,
1722
+ reason,
1723
+ ...seal === null ? { seq: via.seq } : {
1724
+ subject: seal.holder.path,
1725
+ secretId: seal.holder.secretId,
1726
+ source: { path: seal.source.path },
1727
+ also: {
1728
+ projectId: seal.source.projectId,
1729
+ environmentId: seal.source.environmentId,
1730
+ secretId: seal.source.secretId
1731
+ }
1732
+ },
1733
+ ...traced(input)
1734
+ })
1735
+ });
1736
+ if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
1737
+ if (sealed.some((seal) => seal === null)) return this.#decide([principal], async () => {
1738
+ throw new Refused(refusal("bad_claim", MESSAGES.bad_claim), vias.map((via, i) => entry(via, sealed[i], "deny", "bad_claim")));
1739
+ });
1740
+ const seals = sealed;
1741
+ const ended = async (db) => {
1742
+ const done = await endedReferences(db, seals.map((seal) => seal.seq));
1743
+ return seals.map((seal) => done.has(seal.seq) ? "ended" : null);
1744
+ };
1745
+ return this.#decide([principal], async (d) => {
1746
+ const actor = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports);
1747
+ const environments = await environmentsById(d.tx, seals.map((seal) => seal.holder.environmentId));
1748
+ const codes = seals.map((seal) => {
1749
+ const holder = refuses(actor, "secret.write", placeIn(seal.holder, environments), false);
1750
+ if (reason === "abandoned" && seal.createdBy !== principal) return "not_allowed";
1751
+ if (holder === null || reason !== "broken") return holder;
1752
+ return refuses(actor, "grant.manage", { projectId: seal.source.projectId }, false) === null ? null : holder;
1753
+ });
1754
+ if (!codes.some((code) => code !== null)) (await ended(d.tx)).forEach((code, i) => codes[i] = code);
1755
+ const first = codes.find((code) => code !== null) ?? null;
1756
+ if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), vias.map((via, i) => entry(via, seals[i], "deny", codes[i] ?? first)));
1757
+ d.underLock.push(async (locked) => {
1758
+ if ((await ended(locked)).every((code) => code === null)) return;
1759
+ throw new Refused(refusal("ended", MESSAGES.ended), vias.map((via, i) => entry(via, seals[i], "deny", "ended")));
1760
+ });
1761
+ const entries = vias.map((via, i) => entry(via, seals[i], "allow", null));
1762
+ d.log.push(...entries);
1763
+ const answer = { seqs: [] };
1764
+ d.after.push((seqOf) => answer.seqs = entries.map(seqOf));
1765
+ return answer;
1766
+ });
1767
+ }
1768
+ /**
1769
+ * The reference each read names, as the vault's own `reference.create`
1770
+ * entry at its seq says it is: an entry of the vault's, allowed, under its
1771
+ * MAC, about that reference. Null for an item that names none, and for
1772
+ * one that names something else: a row the vault never sealed.
1773
+ */
1774
+ async #referencesAt(vias) {
1775
+ const found = await entriesAt(this.#db, vias.flatMap((via) => via === null ? [] : [BigInt(via.seq)]));
1776
+ return vias.map((via) => {
1777
+ if (via === null) return null;
1778
+ const entry = found.get(BigInt(via.seq));
1779
+ if (entry === void 0 || entry.author !== "vault" || entry.action !== "reference.create" || entry.decision !== "allow") return null;
1780
+ if (!this.#authentic(entry)) return null;
1781
+ return sealedReference(entry, via.reference);
1782
+ });
1783
+ }
1477
1784
  #isRootAdmin(principal) {
1478
1785
  return principal.startsWith("user:") && this.#config.rootAdmins.includes(principal.slice(5));
1479
1786
  }
@@ -1560,7 +1867,7 @@ var VaultService = class {
1560
1867
  fault,
1561
1868
  stored: grants$1
1562
1869
  };
1563
- if (!LIVE_PRINCIPAL.test(principal)) return {
1870
+ if (!PRINCIPAL.test(principal)) return {
1564
1871
  principal,
1565
1872
  status: "unknown",
1566
1873
  live: none,
@@ -1609,10 +1916,10 @@ var VaultService = class {
1609
1916
  since: null,
1610
1917
  by: null
1611
1918
  };
1612
- const active = LIVE_PRINCIPAL.test(principal) && !tampered && row?.status === "active";
1919
+ const active = PRINCIPAL.test(principal) && !tampered && row?.status === "active";
1613
1920
  return {
1614
1921
  principal,
1615
- status: tampered ? "tampered" : !LIVE_PRINCIPAL.test(principal) ? "unknown" : row?.status ?? "unknown",
1922
+ status: tampered ? "tampered" : !PRINCIPAL.test(principal) ? "unknown" : row?.status ?? "unknown",
1616
1923
  generation: row?.generation ?? 0,
1617
1924
  isRootAdmin: false,
1618
1925
  isOwner: active && row.owner && principal.startsWith("user:"),
@@ -1676,34 +1983,55 @@ var VaultService = class {
1676
1983
  }
1677
1984
  setAccess(input) {
1678
1985
  const { actor, principal } = input;
1679
- const action = input.changes.every((change) => change.role === null) ? "access.revoke" : "access.grant";
1680
- const [only] = input.changes.length === 1 ? input.changes : [];
1681
- const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [{
1682
- ...accessEntry(actor, action, principal, "deny", input, { changes: input.changes }, code),
1683
- ...only === void 0 || code === "invalid" ? {} : {
1684
- projectId: only.projectId,
1685
- environmentId: only.environmentId
1686
- }
1986
+ const changes = input.changes.map((change) => ({
1987
+ ...change,
1988
+ environmentSlug: change.environmentSlug ?? null
1989
+ }));
1990
+ const action = changes.every((change) => change.role === null) ? "access.revoke" : "access.grant";
1991
+ const [only] = changes.length === 1 ? changes : [];
1992
+ const refused = (code, message = MESSAGES[code]) => {
1993
+ const where = only === void 0 || code === "invalid" ? {
1994
+ ids: {},
1995
+ detail: {}
1996
+ } : located(only);
1997
+ return new Refused(refusal(code, message), [{
1998
+ ...accessEntry(actor, action, principal, "deny", input, {
1999
+ changes: input.changes,
2000
+ ...where.detail
2001
+ }, code),
2002
+ ...where.ids
2003
+ }]);
2004
+ };
2005
+ const onProjects = changes.flatMap(({ projectId, environmentId, role }) => projectId === null ? [] : [{
2006
+ projectId,
2007
+ environmentId,
2008
+ role
1687
2009
  }]);
1688
2010
  return this.#decide([actor, principal], async (d) => {
1689
2011
  validateCorrelation(input);
1690
- if (!LIVE_PRINCIPAL.test(principal)) throw refused("invalid", `not a principal: ${principal}`);
2012
+ if (!PRINCIPAL.test(principal)) throw refused("invalid", `not a principal: ${principal}`);
1691
2013
  if (this.#isRootAdmin(principal)) throw refused("root_admin");
1692
2014
  const places$1 = /* @__PURE__ */ new Set();
1693
- for (const change of input.changes) {
1694
- const key = `${change.projectId}/${change.environmentId ?? ""}`;
2015
+ for (const change of changes) {
2016
+ const shape = placeShape(change);
2017
+ if (shape !== null) throw refused("invalid", shape);
2018
+ const key = placeKey(change);
1695
2019
  if (places$1.has(key)) throw refused("invalid", "each place may be changed once per call");
1696
2020
  places$1.add(key);
1697
2021
  if (change.role !== null && !isRole(change.role)) throw refused("invalid", `no such role: ${change.role}`);
1698
- if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role)) throw refused("invalid", `${change.role} can only be granted on a project`);
2022
+ if (change.role !== null && (change.environmentId !== null || change.environmentSlug !== null) && !assignableToEnvironment(change.role)) throw refused("invalid", `${change.role} can only be granted on a project, or on every project`);
1699
2023
  const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
1700
2024
  if (Number.isNaN(expiresAt) || expiresAt !== null && expiresAt <= d.at) throw refused("invalid", "an end date must be in the future");
1701
2025
  }
1702
- const known = await places(d.tx, input.changes.map((change) => change.projectId), input.changes.flatMap((change) => change.environmentId === null ? [] : [change.environmentId]));
1703
- for (const { projectId, environmentId } of input.changes) if (!known.projects.has(projectId) || environmentId !== null && known.environments.get(environmentId) !== projectId) throw refused("invalid", `no such place: ${environmentId === null ? projectId : `${projectId}/${environmentId}`}`);
2026
+ if (onProjects.length > 0) await lockLogHead(d.tx);
2027
+ const known = await places(d.tx, onProjects.map((change) => change.projectId), onProjects.flatMap((change) => change.environmentId === null ? [] : [change.environmentId]));
2028
+ for (const { projectId, environmentId, role } of onProjects) {
2029
+ if (!known.projects.has(projectId) || environmentId !== null && known.environments.get(environmentId) !== projectId) throw refused("invalid", `no such place: ${environmentId === null ? projectId : `${projectId}/${environmentId}`}`);
2030
+ if (role !== null && (known.deleted.has(projectId) || environmentId !== null && known.deleted.has(environmentId))) throw refused("deleted");
2031
+ }
1704
2032
  const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
1705
2033
  if (acting.status === "tampered") throw refused("tampered");
1706
- if (!input.changes.every((change) => mayManageAccess(acting.live, change))) throw refused("not_allowed");
2034
+ if (!changes.every((change) => mayManageAccess(acting.live, change))) throw refused("not_allowed");
1707
2035
  const row = d.members.get(principal);
1708
2036
  const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
1709
2037
  if (subject.status === "tampered") throw refused("tampered", TAMPERED_SUBJECT);
@@ -1711,27 +2039,30 @@ var VaultService = class {
1711
2039
  if (row === void 0) throw refused("not_a_member");
1712
2040
  const held = subject.stored;
1713
2041
  d.grants.set(principal, [...held]);
1714
- const changes = input.changes.map((change) => this.#apply(d, actor, principal, held, change, input));
2042
+ const outcomes = changes.map((change) => this.#apply(d, actor, principal, held, change, input));
1715
2043
  if (d.writes.length > 0) d.touched.add(principal);
1716
- return { changes };
2044
+ return { changes: outcomes };
1717
2045
  });
1718
2046
  }
1719
2047
  /** One place's change, logged when it changes what is live. */
1720
2048
  #apply(d, actor, principal, held, change, correlation) {
1721
- const existing = held.find((grant) => grant.projectId === change.projectId && grant.environmentId === change.environmentId);
2049
+ const existing = held.find((grant) => placeKey(grant) === placeKey(change));
1722
2050
  const current = existing !== void 0 && live(existing, d.at) ? existing : void 0;
1723
2051
  const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
1724
2052
  const place = {
1725
2053
  projectId: change.projectId,
1726
- environmentId: change.environmentId
2054
+ environmentId: change.environmentId,
2055
+ environmentSlug: change.environmentSlug
1727
2056
  };
2057
+ const where = located(place);
1728
2058
  const entry = (action, role) => d.log.push({
1729
2059
  ...accessEntry(actor, action, principal, "allow", correlation, {
1730
2060
  role,
1731
2061
  expiresAt: expiresAt === null ? null : iso(expiresAt),
1732
- previousRole: current?.role ?? null
2062
+ previousRole: current?.role ?? null,
2063
+ ...where.detail
1733
2064
  }),
1734
- ...place
2065
+ ...where.ids
1735
2066
  });
1736
2067
  const clear = () => {
1737
2068
  if (existing === void 0) return;
@@ -1772,7 +2103,7 @@ var VaultService = class {
1772
2103
  const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
1773
2104
  if (acting.status === "tampered") throw refused("tampered");
1774
2105
  if (!acting.live.isOwner) throw refused("not_allowed", "only owners may add or restore members");
1775
- if (!LIVE_PRINCIPAL.test(principal)) throw refused("invalid", `not a member: ${principal}`);
2106
+ if (!PRINCIPAL.test(principal)) throw refused("invalid", `not a member: ${shownMember(principal)}`);
1776
2107
  if (this.#isRootAdmin(principal)) throw refused("root_admin");
1777
2108
  if (input.owner === true && !principal.startsWith("user:")) throw refused("invalid", "service accounts cannot be owners");
1778
2109
  const row = d.members.get(principal);
@@ -1826,7 +2157,7 @@ var VaultService = class {
1826
2157
  const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [accessEntry(actor, "member.remove", principal, "deny", input, {}, code)]);
1827
2158
  return this.#decide([actor, principal], async (d) => {
1828
2159
  validateCorrelation(input);
1829
- if (!LIVE_PRINCIPAL.test(principal)) throw refused("invalid", `not a member: ${principal}`);
2160
+ if (!PRINCIPAL.test(principal)) throw refused("invalid", `not a member: ${shownMember(principal)}`);
1830
2161
  if (this.#isRootAdmin(principal)) throw refused("root_admin");
1831
2162
  const row = d.members.get(principal);
1832
2163
  const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
@@ -1841,15 +2172,18 @@ var VaultService = class {
1841
2172
  if (!holder.isOwner) throw refused("not_allowed", "only owners may remove members");
1842
2173
  if (row?.status !== "active") throw refused(row === void 0 ? "not_a_member" : "removed");
1843
2174
  const revoked = held.filter((grant) => live(grant, d.at));
1844
- for (const grant of revoked) d.log.push({
1845
- ...accessEntry(actor, "access.revoke", principal, "allow", input, {
1846
- role: null,
1847
- expiresAt: null,
1848
- previousRole: grant.role
1849
- }),
1850
- projectId: grant.projectId,
1851
- environmentId: grant.environmentId
1852
- });
2175
+ for (const grant of revoked) {
2176
+ const where = located(grant);
2177
+ d.log.push({
2178
+ ...accessEntry(actor, "access.revoke", principal, "allow", input, {
2179
+ role: null,
2180
+ expiresAt: null,
2181
+ previousRole: grant.role,
2182
+ ...where.detail
2183
+ }),
2184
+ ...where.ids
2185
+ });
2186
+ }
1853
2187
  const generation = row.generation + 1;
1854
2188
  d.log.push(accessEntry(actor, "member.remove", principal, "allow", input, {
1855
2189
  revoked: revoked.length,
@@ -2428,15 +2762,96 @@ function validateItems(items, field) {
2428
2762
  }
2429
2763
  }
2430
2764
  }
2431
- /** Why `reader` may not do `permission` on `secret`, or null if they may. */
2432
- function refuses(reader, permission, secret) {
2765
+ /** What a genuine `reference.create` entry seals, if it is about `reference`; null if not, or malformed. */
2766
+ function sealedReference(entry, reference) {
2767
+ let detail;
2768
+ try {
2769
+ detail = JSON.parse(entry.metadata);
2770
+ } catch {
2771
+ return null;
2772
+ }
2773
+ const text = (value) => typeof value === "string" && value !== "";
2774
+ const also = detail.also ?? {};
2775
+ if (detail.reference !== reference || entry.projectId === null || entry.environmentId === null) return null;
2776
+ if (!text(detail.subject) || !text(detail.secretId) || !text(detail.source?.path)) return null;
2777
+ if (!text(also.projectId) || !text(also.environmentId) || !text(also.secretId)) return null;
2778
+ return {
2779
+ reference,
2780
+ seq: entry.seq,
2781
+ holder: {
2782
+ projectId: entry.projectId,
2783
+ environmentId: entry.environmentId,
2784
+ secretId: detail.secretId,
2785
+ path: detail.subject
2786
+ },
2787
+ source: {
2788
+ projectId: also.projectId,
2789
+ environmentId: also.environmentId,
2790
+ secretId: also.secretId,
2791
+ path: detail.source.path
2792
+ },
2793
+ createdBy: entry.actor,
2794
+ createdAt: entry.occurredAt
2795
+ };
2796
+ }
2797
+ /** A read's reference, checked for shape: null for none. */
2798
+ function validateVia(via) {
2799
+ if (via === void 0 || via === null) return null;
2800
+ if (typeof via.reference !== "string" || !UUID.test(via.reference)) throw new Error("a reference id must be a lowercase UUID");
2801
+ if (!Number.isSafeInteger(via.seq) || via.seq < 0) throw new Error("a reference's seq must be a whole number");
2802
+ return {
2803
+ reference: via.reference,
2804
+ seq: via.seq
2805
+ };
2806
+ }
2807
+ /**
2808
+ * How a read through a reference is logged: the way in, and `also`, the
2809
+ * holder's place, which puts the read in the holder's project's log too.
2810
+ */
2811
+ function viaDetail(via, seal) {
2812
+ if (seal === null) return { via: {
2813
+ reference: via.reference,
2814
+ seq: via.seq
2815
+ } };
2816
+ return {
2817
+ via: {
2818
+ reference: via.reference,
2819
+ seq: via.seq,
2820
+ path: seal.holder.path,
2821
+ createdBy: seal.createdBy,
2822
+ createdAt: iso(seal.createdAt)
2823
+ },
2824
+ also: {
2825
+ projectId: seal.holder.projectId,
2826
+ environmentId: seal.holder.environmentId,
2827
+ secretId: seal.holder.secretId
2828
+ }
2829
+ };
2830
+ }
2831
+ /**
2832
+ * Where a decision checks a permission: an environment, with its slug as
2833
+ * the store has it when it was read and is in the project claimed;
2834
+ * otherwise none, which no grant on a slug matches.
2835
+ */
2836
+ function placeIn(place, environments) {
2837
+ const environment = environments.get(place.environmentId);
2838
+ const slug = environment?.projectId === place.projectId ? environment.slug : null;
2839
+ return {
2840
+ projectId: place.projectId,
2841
+ environmentId: place.environmentId,
2842
+ environmentSlug: slug
2843
+ };
2844
+ }
2845
+ /**
2846
+ * Why `reader` may not do `permission` at `where`, or null if they may. A
2847
+ * deleted place is refused before any grant is asked, so no kind of grant,
2848
+ * on the project, the environment or every project, can reach into one.
2849
+ */
2850
+ function refuses(reader, permission, where, deleted) {
2433
2851
  if (reader.status === "tampered") return "tampered";
2434
2852
  if (reader.status === "removed") return "removed";
2435
2853
  if (reader.status === "unknown") return "not_a_member";
2436
- const where = {
2437
- projectId: secret.projectId,
2438
- environmentId: secret.environmentId
2439
- };
2854
+ if (deleted) return "deleted";
2440
2855
  if (allows(reader.live, permission, where)) return null;
2441
2856
  return allows(reader.all, permission, where) ? "expired" : "no_grant";
2442
2857
  }
@@ -2491,10 +2906,42 @@ function accessEntry(actor, action, principal, decision, correlation, detail, co
2491
2906
  function live(grant, at) {
2492
2907
  return grant.expiresAt === null || grant.expiresAt > at;
2493
2908
  }
2909
+ /**
2910
+ * Where an entry about a grant says it is: its project and environment; or,
2911
+ * on every project, neither, and the place as a path in its payload. Only a
2912
+ * grant that names no place at all is on every project (`grantKind`).
2913
+ */
2914
+ function located(place) {
2915
+ return grantKind(place) === "every-project" ? {
2916
+ ids: {},
2917
+ detail: { place: everyProjectPath(place.environmentSlug) }
2918
+ } : {
2919
+ ids: {
2920
+ projectId: place.projectId,
2921
+ environmentId: place.environmentId
2922
+ },
2923
+ detail: {}
2924
+ };
2925
+ }
2926
+ /** One key per place a member can hold a grant at, by what the grant names first: its environment, its project, or neither. */
2927
+ function placeKey(place) {
2928
+ if (place.environmentId !== null) return `environment:${place.environmentId}`;
2929
+ return place.projectId !== null ? `project:${place.projectId}` : everyProjectPath(place.environmentSlug);
2930
+ }
2931
+ /** Why a change's place is not one a grant can be at, or null when it is. */
2932
+ function placeShape(place) {
2933
+ const kind = grantKind(place);
2934
+ if (kind === null) return place.projectId === null ? "an environment is named in its project" : "a slug names environments on every project, not in one";
2935
+ if (kind === "every-project" && place.environmentSlug !== null && !SLUG.test(place.environmentSlug)) return `not an environment slug: ${place.environmentSlug}`;
2936
+ return null;
2937
+ }
2938
+ /** An environment's slug, as the schema has it. */
2939
+ const SLUG = /^[a-z0-9][a-z0-9-]{0,62}$/;
2494
2940
  function view(grant) {
2495
2941
  return {
2496
2942
  projectId: grant.projectId,
2497
2943
  environmentId: grant.environmentId,
2944
+ environmentSlug: grant.environmentSlug,
2498
2945
  role: grant.role,
2499
2946
  expiresAt: grant.expiresAt === null ? null : iso(grant.expiresAt),
2500
2947
  grantedAt: iso(grant.grantedAt),