@coffre/vault 0.8.2 → 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.
@@ -1,14 +1,14 @@
1
- import { ACCESS_ACTIONS, HANDED_BACK, checkpointMessage, checkpointVerifier, describeAccessFault } from "@coffre/core/vault";
1
+ import { ACCESS_ACTIONS, POLICY_REVISION, checkpointMessage, checkpointVerifier, describeAccessFault, handsBack, policyRevision } from "@coffre/core/vault";
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 { EVERYWHERE, NOTHING, allows, assignableToEnvironment, givesService, grantKind, isCredentialScope, isInstanceRole, isRole, isScope, managesService, mayManageAccess, normalScope, ownsInstance, runsInstance, setsUpServices, unscoped, withinCredential } from "@coffre/core/access";
5
+ import { DEFAULT_POLICY, EVERYWHERE, EVERY_ENVIRONMENT, FIXED_ROLES, INSTANCE_ROLES, NOTHING, PROJECT_ONLY_PERMISSIONS, ROLES, ROLE_ID, UNWRITTEN_FORK_MS, allows, assignableToEnvironment, definitionProblem, givesService, grantKind, isBuiltinRole, isCredentialScope, isInstanceRole, isRole, isScope, managesService, mayManageAccess, normalDefinition, normalScope, ownsInstance, runsInstance, setsUpServices, unscoped, withinCredential } from "@coffre/core/access";
6
6
  import { GENESIS_HASH, deriveLogKey, verifyEntries } from "@coffre/core/audit";
7
7
  import { checkContext, ownerKey } from "@coffre/core/envelope";
8
8
  import { shownMember } from "@coffre/core/schemas";
9
9
  import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUniqueViolation, tombstone, truth } from "@coffre/db/dialect";
10
10
  import { accessEntriesAbout, everyProjectConversion, insertGrant, liveProjects, newestAccess, readGrants, reportStands, roleColumns, storedRole } from "@coffre/db/grants";
11
- import { LOOK_BACK, appendEntries, entryColumns, lockLogHead, newestAuthentic, newestVaultEntries, withinBytes } from "@coffre/db/log";
11
+ import { LOOK_BACK, appendEntries, entryColumns, lockLogHead, newestAuthentic, newestVaultEntries, newestVaultSeq, withinBytes } from "@coffre/db/log";
12
12
  import { and, asc, count, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm";
13
13
  //#region src/accounting.ts
14
14
  const KEY_ACTIONS = /* @__PURE__ */ new Set([
@@ -456,6 +456,12 @@ async function endedReferences(db, seqs) {
456
456
  return new Set(rows.map((row) => row.relatedSeq));
457
457
  }
458
458
  /** The project each environment is in: a holder's ids, checked against each other. */
459
+ /** Whether a project of this id is there, and not deleted. */
460
+ async function projectLive(db, id) {
461
+ const { projects } = tablesOf(db);
462
+ const rows = await db.select({ deleted: truth(tombstone(projects.slug)) }).from(projects).where(eq(projects.id, id));
463
+ return rows.length > 0 && !rows[0].deleted;
464
+ }
459
465
  async function projectsOfEnvironments(db, ids) {
460
466
  if (ids.length === 0) return /* @__PURE__ */ new Map();
461
467
  const { environments } = tablesOf(db);
@@ -482,6 +488,41 @@ async function secretsByIds(db, ids) {
482
488
  path: `${project}/${environment}/${key}`
483
489
  }]));
484
490
  }
491
+ /**
492
+ * Each secret's nature, as its current version has it: plain where its
493
+ * value is stored as it is, sensitive where it is bound so, and otherwise
494
+ * secret, as is one with no value of its own.
495
+ */
496
+ async function natures(db, secretIds) {
497
+ if (secretIds.length === 0) return /* @__PURE__ */ new Map();
498
+ const { secrets, secretVersions } = tablesOf(db);
499
+ const rows = await db.select({
500
+ id: secrets.id,
501
+ sensitive: secretVersions.sensitive,
502
+ plain: truth(isNotNull(secretVersions.plaintext))
503
+ }).from(secrets).innerJoin(secretVersions, eq(secretVersions.id, secrets.currentVersionId)).where(inArray(secrets.id, [...new Set(secretIds)]));
504
+ return new Map(rows.map((row) => [row.id, Boolean(row.sensitive) ? "sensitive" : Boolean(row.plain) ? "plain" : "secret"]));
505
+ }
506
+ /**
507
+ * For each version a rewrap would bring back into its secret, its nature
508
+ * as stored when it is an old value, not the secret's current one; null
509
+ * for the current one, which a mark wraps again with no value changing.
510
+ */
511
+ async function restoredNatures(db, items) {
512
+ if (items.length === 0) return [];
513
+ const { secrets, secretVersions } = tablesOf(db);
514
+ const [current, versions] = await Promise.all([db.select({
515
+ id: secrets.id,
516
+ version: secrets.currentVersionId
517
+ }).from(secrets).where(inArray(secrets.id, [...new Set(items.map((item) => item.secretId))])), db.select({
518
+ id: secretVersions.id,
519
+ sensitive: secretVersions.sensitive,
520
+ plain: truth(isNotNull(secretVersions.plaintext))
521
+ }).from(secretVersions).where(inArray(secretVersions.id, [...new Set(items.map((item) => item.from))]))]);
522
+ const currentOf = new Map(current.map((row) => [row.id, row.version]));
523
+ const natureOf = new Map(versions.map((row) => [row.id, Boolean(row.sensitive) ? "sensitive" : Boolean(row.plain) ? "plain" : "secret"]));
524
+ return items.map(({ secretId, from }) => currentOf.get(secretId) === from ? null : natureOf.get(from) ?? "secret");
525
+ }
485
526
  /** The vault's newest entry of any of these actions, allowed, or undefined. */
486
527
  async function latestVaultEntry(db, actions) {
487
528
  const { auditLog } = tablesOf(db);
@@ -500,6 +541,19 @@ async function vaultEntriesOf(db, actions, afterSeq, limit) {
500
541
  const { auditLog } = tablesOf(db);
501
542
  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));
502
543
  }
544
+ /** Which of these secrets' newest versions are sensitive, by the versions' own order. */
545
+ async function newestSensitive(db, secretIds) {
546
+ if (secretIds.length === 0) return /* @__PURE__ */ new Set();
547
+ const { secretVersions } = tablesOf(db);
548
+ const rows = await db.select({ secretId: secretVersions.secretId }).from(secretVersions).where(and(inArray(secretVersions.secretId, [...new Set(secretIds)]), eq(secretVersions.sensitive, true), sql`${secretVersions.version} = (SELECT max(newest.version) FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId})`));
549
+ return new Set(rows.map((row) => row.secretId));
550
+ }
551
+ /** How many grants name `role`: all of them, or those on environments. */
552
+ async function grantsNaming(db, role, filter = {}) {
553
+ const { vaultGrants } = tablesOf(db);
554
+ const rows = await db.select({ count: sql`count(*)` }).from(vaultGrants).where(and(eq(vaultGrants.role, role), filter.onEnvironments === true ? or(isNotNull(vaultGrants.environmentId), isNotNull(vaultGrants.environmentSlug)) : void 0));
555
+ return Number(rows[0]?.count ?? 0);
556
+ }
503
557
  /**
504
558
  * A version that still holds its wrapped key. A deleted place's versions are
505
559
  * erased, their keys emptied: there is nothing left to open, or to try a
@@ -526,7 +580,9 @@ async function versions(db, ids) {
526
580
  wrappedDek: secretVersions.wrappedDek,
527
581
  deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug))),
528
582
  currentVersionId: secrets.currentVersionId,
529
- newest: sql`(SELECT max(newest.version) FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId})`
583
+ sensitive: secretVersions.sensitive,
584
+ newest: sql`(SELECT max(newest.version) FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId})`,
585
+ newestSensitive: sql`(SELECT newest.sensitive FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId} ORDER BY newest.version DESC LIMIT 1)`
530
586
  }).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) => ({
531
587
  id: row.id,
532
588
  deleted: row.deleted,
@@ -535,7 +591,8 @@ async function versions(db, ids) {
535
591
  environmentId: row.environmentId,
536
592
  secretId: row.secretId,
537
593
  version: row.version,
538
- path: `${row.project}/${row.environment}/${row.key}`
594
+ path: `${row.project}/${row.environment}/${row.key}`,
595
+ sensitive: row.sensitive
539
596
  },
540
597
  wrapped: {
541
598
  kekProvider: row.kekProvider,
@@ -543,7 +600,8 @@ async function versions(db, ids) {
543
600
  kekVersion: row.kekVersion,
544
601
  bytes: row.wrappedDek.toString("base64")
545
602
  },
546
- current: row.currentVersionId === row.id && Number(row.newest) === row.version
603
+ current: row.currentVersionId === row.id && Number(row.newest) === row.version,
604
+ newestSensitive: Boolean(row.newestSensitive)
547
605
  }));
548
606
  }
549
607
  //#endregion
@@ -1024,6 +1082,31 @@ const CHECKPOINT = "audit.checkpoint";
1024
1082
  const SETTINGS = "settings.change";
1025
1083
  /** The settings before anyone changed them: people set up service accounts anywhere they hold access. */
1026
1084
  const DEFAULT_SETTINGS = { serviceAccounts: EVERYWHERE };
1085
+ /** The action of the vault's entry that holds the instance's access policy, and changes it (`policy`). */
1086
+ const POLICY = "policy.change";
1087
+ const NO_EDITS = {
1088
+ roles: {},
1089
+ instanceRoles: {},
1090
+ protected: []
1091
+ };
1092
+ /** A role holding nothing, which every role but Owner is under the closed policy. */
1093
+ const NOTHING_HELD = (definition) => ({
1094
+ ...definition,
1095
+ permissions: [],
1096
+ protected: []
1097
+ });
1098
+ /**
1099
+ * The policy when the log has some, but none the vault wrote within reach:
1100
+ * whoever inserted rows may have hidden an Owner's narrowing, so no role
1101
+ * holds anything, a service account's neither, but Owner, which holds
1102
+ * everything always and so may write the policy again.
1103
+ */
1104
+ const CLOSED_POLICY = {
1105
+ roles: Object.fromEntries(Object.entries(ROLES).map(([id, definition]) => [id, id === "owner" ? definition : NOTHING_HELD(definition)])),
1106
+ instanceRoles: Object.fromEntries(Object.entries(INSTANCE_ROLES).map(([id, definition]) => [id, id === "owner" ? definition : NOTHING_HELD(definition)])),
1107
+ protected: [EVERY_ENVIRONMENT],
1108
+ protectedIn: {}
1109
+ };
1027
1110
  /** The settings when the log has some, but none the vault wrote within reach: people set up no service accounts. */
1028
1111
  const CLOSED_SETTINGS = { serviceAccounts: {
1029
1112
  projects: { only: [] },
@@ -1067,6 +1150,8 @@ var Outage = class {
1067
1150
  this.entries = entries;
1068
1151
  }
1069
1152
  };
1153
+ /** A rewrap that would make a value less sensitive than it is. */
1154
+ 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";
1070
1155
  function refusal(code, message) {
1071
1156
  return {
1072
1157
  code,
@@ -1083,10 +1168,11 @@ const MESSAGES = {
1083
1168
  deleted: "this project or environment was deleted",
1084
1169
  ended: "the reference read through was ended",
1085
1170
  personal: "a personal config's values are its owner's alone",
1086
- 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",
1171
+ 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",
1087
1172
  not_allowed: "not allowed to change this",
1088
1173
  last_owner: "the last Owner of the whole instance stays one: make someone else an Owner first",
1089
1174
  invalid: "not something the rules allow",
1175
+ policy_changed: "the instance's roles or protected environments changed while this was asked: try again",
1090
1176
  log_broken: "the vault log does not hold from the last checkpoint",
1091
1177
  wrong_kek: "this vault's key does not open the data it holds",
1092
1178
  tampered: "this member's record failed the vault's integrity check"
@@ -1144,36 +1230,44 @@ var VaultService = class {
1144
1230
  return await now(db) + this.#prepared.options.clockOffset();
1145
1231
  }
1146
1232
  /**
1147
- * Decide in one transaction, with `principals`' rows locked first. A
1148
- * `Refused` or an `Outage` rolls back all but its own entries, which
1149
- * commit on their own: then the refusal is the answer, and the outage
1150
- * fails the call.
1233
+ * Decide in one transaction, with `principals`' rows locked first, then
1234
+ * the log's head. A `Refused` or an `Outage` rolls back all but its own
1235
+ * entries, which commit on their own: then the refusal is the answer, and
1236
+ * the outage fails the call.
1237
+ *
1238
+ * Every change to the policy, the settings, a member or a place appends
1239
+ * under the head, so whatever the decision reads of them stands until it
1240
+ * commits: no decision acts on a policy read before it. A key operation
1241
+ * alone takes the head later (`late`), once its key service has answered,
1242
+ * which it must not wait on holding it, and decides again then (`#keys`).
1151
1243
  */
1152
- async #decide(principals, decide) {
1244
+ async #decide(principals, decide, late = false) {
1153
1245
  const superseded = await this.#settled();
1154
1246
  if (superseded !== null) return {
1155
1247
  ok: false,
1156
1248
  refusal: refusal("wrong_kek", superseded)
1157
1249
  };
1158
- return this.#decideNow(principals, decide);
1250
+ return this.#decideNow(principals, decide, late);
1159
1251
  }
1160
1252
  /** `#decide`, once the vault is settled. */
1161
- async #decideNow(principals, decide) {
1253
+ async #decideNow(principals, decide, late = false) {
1162
1254
  const reports = [];
1163
1255
  for (let attempt = 1;; attempt += 1) try {
1164
- return await this.#decideOnce(principals, decide, reports);
1256
+ return await this.#decideOnce(principals, decide, reports, late);
1165
1257
  } catch (error) {
1166
1258
  if (attempt < 3 && (error instanceof Retry || isUniqueViolation(error))) continue;
1167
1259
  throw error;
1168
1260
  }
1169
1261
  }
1170
- async #decideOnce(principals, decide, reports) {
1262
+ async #decideOnce(principals, decide, reports, late) {
1171
1263
  try {
1172
1264
  const result = await this.#db.transaction(async (tx) => {
1173
1265
  await boundLockWaits(tx, LOCK_TIMEOUT_MS);
1266
+ const members = principals.length === 0 ? /* @__PURE__ */ new Map() : await lockMembers(tx, principals);
1267
+ if (!late) await lockLogHead(tx);
1174
1268
  const d = {
1175
1269
  tx,
1176
- members: principals.length === 0 ? /* @__PURE__ */ new Map() : await lockMembers(tx, principals),
1270
+ members,
1177
1271
  at: await this.#now(tx),
1178
1272
  log: [],
1179
1273
  writes: [],
@@ -1367,7 +1461,6 @@ var VaultService = class {
1367
1461
  const everyProject = subject.stored.filter((grant) => grant.projectId === null && grant.environmentId === null);
1368
1462
  if (everyProject.length === 0) return;
1369
1463
  if (await reportStands(d.tx, principal, this.#check(d.reports, principal))) return;
1370
- await lockLogHead(d.tx);
1371
1464
  const before = roleOf(principal, row);
1372
1465
  const conversion = everyProjectConversion(principal, before.role, subject.stored, d.at, await liveProjects(d.tx));
1373
1466
  const after = {
@@ -1529,6 +1622,7 @@ var VaultService = class {
1529
1622
  async unwrap(input) {
1530
1623
  const { principal } = input;
1531
1624
  validateText(input.purpose);
1625
+ if (input.keepsSensitive !== void 0 && typeof input.keepsSensitive !== "boolean") throw new Error("keepsSensitive is true, false or absent");
1532
1626
  const vias = input.items.map((item) => validateVia(item.via));
1533
1627
  const throughs = input.items.map((item) => validateThrough(item.through));
1534
1628
  const loaded = await this.#versions(input, "secret.read", { purpose: input.purpose });
@@ -1577,7 +1671,7 @@ var VaultService = class {
1577
1671
  return this.#keys({
1578
1672
  action: "secret.read",
1579
1673
  principal,
1580
- permission: "secret.read",
1674
+ permission: input.purpose === "use" ? "secret.use" : "secret.read",
1581
1675
  secrets: items.map((item) => item.secret),
1582
1676
  places: sealed.map((seal, i) => seal?.holder ?? items[i].secret),
1583
1677
  vet: ends.length === 0 ? void 0 : async (db) => {
@@ -1627,7 +1721,8 @@ var VaultService = class {
1627
1721
  }
1628
1722
  }), (done) => ({
1629
1723
  wrapped: done.map(({ wrapped }) => wrapped),
1630
- seqs: []
1724
+ seqs: [],
1725
+ sensitive: items.map(({ secret }) => secret.sensitive === true)
1631
1726
  }));
1632
1727
  }
1633
1728
  async rewrap(input) {
@@ -1644,6 +1739,10 @@ var VaultService = class {
1644
1739
  ...keyEntry("key.rewrap", principal, source.secret, "deny", "bad_claim", input),
1645
1740
  secretVersionId: source.id
1646
1741
  })));
1742
+ if (items.some((item, i) => item.secret.sensitive !== true && (loaded.versions[i].secret.sensitive || loaded.versions[i].newestSensitive))) return this.#refuseVersions(principal, "invalid", loaded.versions.map((source) => ({
1743
+ ...keyEntry("key.rewrap", principal, source.secret, "deny", "invalid", input),
1744
+ secretVersionId: source.id
1745
+ })), LOOSER);
1647
1746
  const sources = new Map(items.map((item, i) => [item.secret, loaded.versions[i]]));
1648
1747
  const entry = (secret, decision, code) => ({
1649
1748
  ...keyEntry("key.rewrap", principal, secret, decision, code, input, { from: sources.get(secret).secret.version }),
@@ -1657,9 +1756,19 @@ var VaultService = class {
1657
1756
  secrets: items.map((item) => item.secret),
1658
1757
  remote,
1659
1758
  entry,
1660
- input
1759
+ input,
1760
+ vet: async (db) => {
1761
+ const newest = await newestSensitive(db, items.map(({ secret }) => secret.secretId));
1762
+ return items.map(({ secret }) => secret.sensitive !== true && newest.has(secret.secretId) ? "invalid" : null);
1763
+ },
1764
+ needs: async (db) => {
1765
+ return (await restoredNatures(db, items.map(({ secret }) => ({
1766
+ secretId: secret.secretId,
1767
+ from: sources.get(secret).id
1768
+ })))).map((nature) => nature === "secret" ? ["secret.read"] : nature === "sensitive" ? ["secret.use"] : null);
1769
+ }
1661
1770
  }, (contextOf) => items.map(({ secret, wrapped }) => async (operation) => {
1662
- const key = await this.#open(wrapped, contextOf(secret), operation);
1771
+ const key = await this.#open(wrapped, contextOf(sources.get(secret).secret), operation);
1663
1772
  if (key === null) return null;
1664
1773
  try {
1665
1774
  return {
@@ -1671,7 +1780,8 @@ var VaultService = class {
1671
1780
  }
1672
1781
  }), (done) => ({
1673
1782
  wrapped: done.map(({ wrapped }) => wrapped),
1674
- seqs: []
1783
+ seqs: [],
1784
+ sensitive: items.map(({ secret }) => secret.sensitive === true)
1675
1785
  }));
1676
1786
  }
1677
1787
  /** 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. */
@@ -1710,9 +1820,9 @@ var VaultService = class {
1710
1820
  versions: ids.map((id) => found.get(id))
1711
1821
  };
1712
1822
  }
1713
- async #refuseVersions(principal, code, entries) {
1823
+ async #refuseVersions(principal, code, entries, message = MESSAGES[code]) {
1714
1824
  return this.#decide([principal], async () => {
1715
- throw new Refused(refusal(code, MESSAGES[code]), entries);
1825
+ throw new Refused(refusal(code, message), entries);
1716
1826
  });
1717
1827
  }
1718
1828
  /**
@@ -1756,14 +1866,27 @@ var VaultService = class {
1756
1866
  const gone = (place) => environments.get(place.environmentId)?.deleted ?? false;
1757
1867
  owners.clear();
1758
1868
  for (const [id, environment] of environments) if (environment.owner !== null) owners.set(id, ownerKey(environment.owner));
1869
+ const needs = call.needs === void 0 ? [] : await call.needs(d.tx);
1759
1870
  const codes = secrets.map((secret, i) => {
1760
1871
  const personal = personalRefusal(reader, principal, call.permission, secret, at[i], environments);
1761
1872
  if (personal !== void 0) return personal;
1873
+ const also = needs[i];
1874
+ if (also != null) {
1875
+ const place = placeIn(at[i], environments);
1876
+ const refused = also.map((permission) => refuses(reader, permission, place, gone(secret) || gone(at[i])));
1877
+ if (refused.every((code) => code !== null)) return refused[0];
1878
+ }
1762
1879
  return refuses(reader, call.permission, placeIn(at[i], environments), gone(secret) || gone(at[i]));
1763
1880
  });
1764
1881
  if (call.vet !== void 0 && !codes.some((code) => code !== null)) (await call.vet(d.tx)).forEach((code, i) => codes[i] = code);
1882
+ const copying = action === "secret.read" && call.input.purpose === "copy";
1883
+ if (action === "secret.read" && principal.startsWith("user:") && handsBack({
1884
+ purpose: call.input.purpose ?? "",
1885
+ keepsSensitive: call.input.keepsSensitive === true
1886
+ }) || copying && call.input.keepsSensitive !== true) secrets.forEach((secret, i) => {
1887
+ if (codes[i] === null && secret.sensitive === true) codes[i] = "sensitive";
1888
+ });
1765
1889
  let first = codes.find((code) => code !== null) ?? null;
1766
- if (first === null && action === "secret.read" && this.#config.people === "stand-ins" && principal.startsWith("user:") && HANDED_BACK.includes(call.input.purpose)) first = "stand_ins";
1767
1890
  if (first === null && action === "secret.read" && await this.#overBulkLimit(d, principal, secrets.length)) first = "bulk_limit";
1768
1891
  if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, i) => entry(secret, "deny", codes[i] ?? first)));
1769
1892
  };
@@ -1785,7 +1908,6 @@ var VaultService = class {
1785
1908
  if (call.remote) {
1786
1909
  const intent = await this.#decide([principal], async (d) => {
1787
1910
  await check(d);
1788
- await lockLogHead(d.tx);
1789
1911
  const at = await this.#now(d.tx);
1790
1912
  return { seq: (await this.#append(d.tx, [{
1791
1913
  actor: principal,
@@ -1836,6 +1958,12 @@ var VaultService = class {
1836
1958
  if (expired) throw new Outage(new KekUnavailableError("key operation exceeded its deadline"), entries());
1837
1959
  if (done.length < outcomes.length) throw new Refused(refusal("bad_claim", MESSAGES.bad_claim), entries());
1838
1960
  await lockLogHead(d.tx);
1961
+ try {
1962
+ await check(d);
1963
+ } catch (error) {
1964
+ if (error instanceof Refused) throw new Refused(error.refusal, secrets.map((secret, item) => outcomeEntry(secret, item, "deny", error.entries[item].code ?? error.refusal.code)));
1965
+ throw error;
1966
+ }
1839
1967
  const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
1840
1968
  const now = await environmentsById(d.tx, places.map((place) => place.environmentId));
1841
1969
  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")));
@@ -1847,7 +1975,7 @@ var VaultService = class {
1847
1975
  } finally {
1848
1976
  for (const value of done) value.wipe();
1849
1977
  }
1850
- });
1978
+ }, true);
1851
1979
  }
1852
1980
  /** Whether `n` more keys would take `principal` past the bulk limit; counted under their row's lock, so exactly. */
1853
1981
  async #overBulkLimit(d, principal, n) {
@@ -1903,6 +2031,7 @@ var VaultService = class {
1903
2031
  };
1904
2032
  return this.#decide([principal], async (d) => {
1905
2033
  const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
2034
+ const natures$1 = await natures(d.tx, [...sources.values()].map((source) => source.secretId));
1906
2035
  const environments = await environmentsById(d.tx, items.flatMap((item) => {
1907
2036
  const source = sources.get(item.source.secretId);
1908
2037
  return source === void 0 ? [item.holder.environmentId] : [item.holder.environmentId, source.environmentId];
@@ -1913,14 +2042,11 @@ var VaultService = class {
1913
2042
  if (source === void 0 || projectOf.get(item.holder.environmentId) !== item.holder.projectId) return "bad_claim";
1914
2043
  if (source.secretId === item.holder.secretId) return "invalid";
1915
2044
  if (environments.get(item.holder.environmentId)?.owner != null || environments.get(source.environmentId)?.owner != null) return "personal";
1916
- return refuses(reader, "secret.write", placeIn(item.holder, environments), gone(item.holder)) ?? refuses(reader, "secret.read", placeIn(source, environments), gone(source));
2045
+ const at = placeIn(source, environments);
2046
+ return refuses(reader, "secret.write", placeIn(item.holder, environments), gone(item.holder)) ?? refuses(reader, "secret.read", at, gone(source)) ?? (natures$1.get(source.secretId) === "sensitive" ? refuses(reader, "secret.use", at, gone(source)) : null);
1917
2047
  });
1918
2048
  const first = codes.find((code) => code !== null) ?? null;
1919
2049
  if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), items.map((item, i) => entry(item, "deny", codes[i] ?? first)));
1920
- d.underLock.push(async (locked) => {
1921
- if (![...(await environmentsById(locked, [...environments.keys()])).values()].some((environment) => environment.deleted)) return;
1922
- throw new Refused(refusal("deleted", MESSAGES.deleted), items.map((item) => entry(item, "deny", "deleted")));
1923
- });
1924
2050
  const entries = items.map((item) => entry(item, "allow", null));
1925
2051
  d.log.push(...entries);
1926
2052
  const answer = { seqs: [] };
@@ -1994,10 +2120,6 @@ var VaultService = class {
1994
2120
  if (!codes.some((code) => code !== null)) (await ended(d.tx)).forEach((code, i) => codes[i] = code);
1995
2121
  const first = codes.find((code) => code !== null) ?? null;
1996
2122
  if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), vias.map((via, i) => entry(via, seals[i], "deny", codes[i] ?? first)));
1997
- d.underLock.push(async (locked) => {
1998
- if ((await ended(locked)).every((code) => code === null)) return;
1999
- throw new Refused(refusal("ended", MESSAGES.ended), vias.map((via, i) => entry(via, seals[i], "deny", "ended")));
2000
- });
2001
2123
  const entries = vias.map((via, i) => entry(via, seals[i], "allow", null));
2002
2124
  d.log.push(...entries);
2003
2125
  const answer = { seqs: [] };
@@ -2071,11 +2193,11 @@ var VaultService = class {
2071
2193
  /**
2072
2194
  * Refuse a change that would leave `principal`, an Owner of the whole
2073
2195
  * instance, no longer one, unless another is. Counted under the log's
2074
- * head: two changes at once, each demoting or removing a different
2075
- * Owner, lock different rows, and only the head puts one after the other.
2196
+ * head, which every decision holds: two changes at once, each demoting or
2197
+ * removing a different Owner, lock different rows, and only the head puts
2198
+ * one after the other.
2076
2199
  */
2077
2200
  async #keepAnOwner(d, actor, principal, refused) {
2078
- await lockLogHead(d.tx);
2079
2201
  if (await this.#anOwner(d.tx, d.reports, principal) !== null) return;
2080
2202
  throw refused("last_owner", actor === principal ? "you're the last Owner of the whole instance: make someone else an Owner first" : `${principal.slice(5)} is the last Owner of the whole instance: make someone else an Owner first`);
2081
2203
  }
@@ -2175,24 +2297,30 @@ var VaultService = class {
2175
2297
  role: grant.role
2176
2298
  }));
2177
2299
  const { role, scope } = roleOf(principal, row);
2300
+ const { policy } = await this.#policyAt(db, reports, at);
2301
+ const service = !principal.startsWith("user:");
2178
2302
  return {
2179
2303
  principal,
2180
2304
  status: "active",
2181
2305
  live: {
2182
2306
  role,
2183
2307
  scope,
2184
- grants: held.filter((grant) => live(grant, at))
2308
+ grants: held.filter((grant) => live(grant, at)),
2309
+ policy,
2310
+ service
2185
2311
  },
2186
2312
  all: {
2187
2313
  role,
2188
2314
  scope,
2189
- grants: held
2315
+ grants: held,
2316
+ policy,
2317
+ service
2190
2318
  },
2191
2319
  fault,
2192
2320
  stored: grants$1
2193
2321
  };
2194
2322
  }
2195
- #access(principal, row, held, at, tampered) {
2323
+ #access(principal, row, held, at, tampered, policy) {
2196
2324
  const active = PRINCIPAL.test(principal) && !tampered && row?.status === "active";
2197
2325
  return {
2198
2326
  principal,
@@ -2204,7 +2332,8 @@ var VaultService = class {
2204
2332
  },
2205
2333
  grants: active ? held.filter((grant) => live(grant, at) && grantKind(grant) !== null).map(view) : [],
2206
2334
  since: row ? iso(row.statusChangedAt) : null,
2207
- by: row?.statusChangedBy ?? null
2335
+ by: row?.statusChangedBy ?? null,
2336
+ policy
2208
2337
  };
2209
2338
  }
2210
2339
  /**
@@ -2234,7 +2363,8 @@ var VaultService = class {
2234
2363
  this.#now(db)
2235
2364
  ]);
2236
2365
  const fault = await this.#integrity(db, principal, row, held, reports);
2237
- return this.#access(principal, row, held, at, fault !== null);
2366
+ const { policy } = await this.#policyAt(db, reports, at);
2367
+ return this.#access(principal, row, held, at, fault !== null, policy);
2238
2368
  };
2239
2369
  const found = [];
2240
2370
  const quick = await read(this.#db, found).catch((error) => {
@@ -2304,12 +2434,15 @@ var VaultService = class {
2304
2434
  const key = placeKey(change);
2305
2435
  if (places$1.has(key)) throw refused("invalid", "each place may be changed once per call");
2306
2436
  places$1.add(key);
2307
- if (change.role !== null && !isRole(change.role)) throw refused("invalid", `no such role: ${change.role}`);
2308
- if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role)) throw refused("invalid", `${change.role} can only be granted on a project`);
2437
+ if (change.role !== null && typeof change.role !== "string") throw refused("invalid", `no such role: ${String(change.role)}`);
2309
2438
  const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
2310
2439
  if (Number.isNaN(expiresAt) || expiresAt !== null && expiresAt <= d.at) throw refused("invalid", "an end date must be in the future");
2311
2440
  }
2312
- await lockLogHead(d.tx);
2441
+ const { policy } = await this.#policyAt(d.tx, d.reports, d.at);
2442
+ for (const change of changes) {
2443
+ if (change.role !== null && !isRole(change.role, policy)) throw refused("invalid", `no such role: ${change.role}`);
2444
+ if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role, policy)) throw refused("invalid", `${change.role} can only be granted on a project`);
2445
+ }
2313
2446
  const known = await places(d.tx, changes.map((change) => change.projectId), changes.flatMap((change) => change.environmentId === null ? [] : [change.environmentId]));
2314
2447
  const where = changes.map(({ projectId, environmentId, role }) => {
2315
2448
  const environment = environmentId === null ? void 0 : known.environments.get(environmentId);
@@ -2633,6 +2766,134 @@ var VaultService = class {
2633
2766
  const { serviceAccounts } = JSON.parse(entry.metadata);
2634
2767
  return isScope(serviceAccounts) ? { serviceAccounts } : CLOSED_SETTINGS;
2635
2768
  }
2769
+ async policy() {
2770
+ await this.#settled();
2771
+ const reports = [];
2772
+ const { policy } = await this.#policyAt(this.#db, reports, await this.#now(this.#db));
2773
+ await this.#record(reports);
2774
+ return policy;
2775
+ }
2776
+ /**
2777
+ * The policy the vault's newest `policy.change` says, by its MAC, and its
2778
+ * edits, as of `at`: one in its name it did not write is passed over, and
2779
+ * reported. With such entries and none of its own within reach, the
2780
+ * policy is unreadable, and closed: every environment is protected, and
2781
+ * no role holds anything but Owner, otherwise whoever can insert rows
2782
+ * could unprotect them. Its edits are then unknown, not none.
2783
+ */
2784
+ async #policyAt(db, reports, at) {
2785
+ const entry = await newestAuthentic((before) => newestVaultEntries(db, POLICY, LOOK_BACK.page, before), this.#check(reports, null));
2786
+ if (entry === "none") return {
2787
+ policy: DEFAULT_POLICY,
2788
+ edits: NO_EDITS,
2789
+ readable: true
2790
+ };
2791
+ const edits = typeof entry === "string" ? null : readEdits(JSON.parse(entry.metadata));
2792
+ return edits === null ? {
2793
+ policy: CLOSED_POLICY,
2794
+ edits: NO_EDITS,
2795
+ readable: false
2796
+ } : {
2797
+ policy: await livePolicy(db, edits, at),
2798
+ edits,
2799
+ readable: true
2800
+ };
2801
+ }
2802
+ changePolicy(input) {
2803
+ const { actor, change, revision } = input;
2804
+ if (revision !== void 0 && (typeof revision !== "string" || !POLICY_REVISION.test(revision))) throw new Error("revision names a policy as policyRevision writes it");
2805
+ const at = {};
2806
+ const entry = (decision, detail, code = null) => ({
2807
+ actor,
2808
+ action: POLICY,
2809
+ decision,
2810
+ code,
2811
+ operationId: input.operationId ?? null,
2812
+ requestId: input.requestId ?? null,
2813
+ ...at,
2814
+ metadata: JSON.stringify({
2815
+ ...detail,
2816
+ ...traced(input)
2817
+ })
2818
+ });
2819
+ const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [entry("deny", { change: describeChange(change) }, code)]);
2820
+ return this.#decide([actor], async (d) => {
2821
+ validateCorrelation(input);
2822
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
2823
+ if (acting.status === "tampered") throw refused("tampered");
2824
+ if (acting.status !== "active") throw refused(acting.status === "removed" ? "removed" : "not_a_member");
2825
+ const newest = await newestVaultSeq(d.tx, POLICY);
2826
+ const { policy: before, edits, readable } = await this.#policyAt(d.tx, d.reports, d.at);
2827
+ if (revision !== void 0 && policyRevision(newest, before) !== revision) throw refused("policy_changed");
2828
+ const owner = acting.live.credentialScope == null && ownsInstance(acting.live);
2829
+ 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");
2830
+ const next = {
2831
+ roles: { ...edits.roles },
2832
+ instanceRoles: { ...edits.instanceRoles },
2833
+ protected: edits.protected.filter((place) => before.protected.includes(place.environmentId)).map((place) => ({ ...place }))
2834
+ };
2835
+ if (change?.kind === "role" || change?.kind === "instance-role") {
2836
+ if (!owner) throw refused("not_allowed", "only an Owner of the whole instance edits roles");
2837
+ const instance = change.kind === "instance-role";
2838
+ const fixed = instance ? FIXED_ROLES.instanceRoles.includes(change.role) : FIXED_ROLES.roles.includes(change.role);
2839
+ if (typeof change.role !== "string" || fixed) throw refused("invalid", `${change.role} is not edited: it holds ${change.role === "member" ? "nothing" : "everything"}, always`);
2840
+ if (instance && !isInstanceRole(change.role)) throw refused("invalid", `no instance role ${change.role}: an Owner makes project roles`);
2841
+ 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");
2842
+ const table = instance ? next.instanceRoles : next.roles;
2843
+ if (change.definition === null) {
2844
+ if (!instance && !isBuiltinRole(change.role)) {
2845
+ if (table[change.role] === void 0) throw refused("invalid", `no role ${change.role}`);
2846
+ const held = await grantsNaming(d.tx, change.role);
2847
+ if (held > 0) throw refused("invalid", `${held === 1 ? "1 grant names" : `${held} grants name`} ${change.role}: revoke ${held === 1 ? "it" : "them"} first`);
2848
+ }
2849
+ const shipped = instance ? void 0 : isBuiltinRole(change.role) ? ROLES[change.role] : void 0;
2850
+ if (shipped !== void 0 && shipped.permissions.some((permission) => PROJECT_ONLY_PERMISSIONS.includes(permission)) && await grantsNaming(d.tx, change.role, { onEnvironments: true }) > 0) throw refused("invalid", `${change.role} is granted on environments: as it ships, it holds a project-wide permission, which would make those grants incoherent`);
2851
+ delete table[change.role];
2852
+ } else {
2853
+ const problem = definitionProblem(change.definition);
2854
+ if (problem !== null) throw refused("invalid", problem);
2855
+ const definition = normalDefinition(change.definition);
2856
+ if (!instance && definition.permissions.some((permission) => PROJECT_ONLY_PERMISSIONS.includes(permission)) && await grantsNaming(d.tx, change.role, { onEnvironments: true }) > 0) throw refused("invalid", `${change.role} is granted on environments: a project-wide permission would make those grants incoherent`);
2857
+ const shipped = instance ? INSTANCE_ROLES[change.role] : isBuiltinRole(change.role) ? ROLES[change.role] : void 0;
2858
+ if (shipped !== void 0 && JSON.stringify(normalDefinition(shipped)) === JSON.stringify(definition)) delete table[change.role];
2859
+ else table[change.role] = definition;
2860
+ }
2861
+ } else if (change?.kind === "protect") {
2862
+ if (typeof change.projectId !== "string" || !UUID.test(change.projectId) || !Array.isArray(change.environmentIds) || change.environmentIds.length === 0 || change.environmentIds.some((id) => typeof id !== "string" || !UUID.test(id)) || typeof change.protected !== "boolean") throw refused("invalid", "protection names a project and some of its environments, by id, and whether they are protected");
2863
+ if (!await projectLive(d.tx, change.projectId)) throw refused("invalid", "no such project");
2864
+ at.projectId = change.projectId;
2865
+ const found = await environmentsById(d.tx, change.environmentIds);
2866
+ if ([...found.values()].some((environment) => environment.projectId !== change.projectId)) throw refused("invalid", "each environment is one of the project's");
2867
+ if (change.environmentIds.length === 1 && found.has(change.environmentIds[0])) at.environmentId = change.environmentIds[0];
2868
+ if (change.protected) {
2869
+ if (!allows(acting.live, "environment.manage", { projectId: change.projectId })) throw refused("not_allowed", "protecting an environment takes managing its project's environments");
2870
+ const known = new Set(next.protected.map((place) => place.environmentId));
2871
+ next.protected = [...next.protected, ...change.environmentIds.filter((id) => !known.has(id)).map((environmentId) => ({
2872
+ projectId: change.projectId,
2873
+ environmentId,
2874
+ at: d.at
2875
+ }))].sort((a, b) => a.environmentId < b.environmentId ? -1 : 1);
2876
+ } else {
2877
+ if (!owner) throw refused("not_allowed", "only an Owner of the whole instance unprotects an environment");
2878
+ next.protected = next.protected.filter((place) => !change.environmentIds.includes(place.environmentId));
2879
+ }
2880
+ } else throw refused("invalid", "a change is to a role, an instance role, or protection");
2881
+ const answer = {
2882
+ policy: await livePolicy(d.tx, next, d.at),
2883
+ seq: newest === null ? null : Number(newest)
2884
+ };
2885
+ if (!readable || JSON.stringify(next) !== JSON.stringify(edits)) {
2886
+ const made = entry("allow", {
2887
+ policy: next,
2888
+ change: describeChange(change),
2889
+ before: describeBefore(change, before)
2890
+ });
2891
+ d.log.push(made);
2892
+ d.after.push((seqOf) => answer.seq = seqOf(made));
2893
+ }
2894
+ return answer;
2895
+ });
2896
+ }
2636
2897
  setSettings(input) {
2637
2898
  const { actor } = input;
2638
2899
  const entry = (decision, detail, code = null) => ({
@@ -2654,7 +2915,6 @@ var VaultService = class {
2654
2915
  if (acting.status === "tampered") throw refused("tampered");
2655
2916
  if (!runsInstance(acting.live)) throw refused("not_allowed", "only admins and owners of the whole instance change its settings");
2656
2917
  if (!isScope(input.settings?.serviceAccounts)) throw refused("invalid", "who sets up service accounts is a scope: { projects, environments }, each all, only or except");
2657
- await lockLogHead(d.tx);
2658
2918
  const previous = await this.#settingsAt(d.tx, d.reports);
2659
2919
  const settings = { serviceAccounts: normalScope(input.settings.serviceAccounts) };
2660
2920
  if (JSON.stringify(settings) !== JSON.stringify(previous)) d.log.push(entry("allow", {
@@ -3199,6 +3459,7 @@ function validateItems(items, field) {
3199
3459
  if (field === "key" && typeof key !== "string") throw new Error("DEK must be base64");
3200
3460
  if (field === "wrapped" && (wrapped === void 0 || wrapped === null)) throw new Error("wrapped key is required");
3201
3461
  checkContext(context(secret));
3462
+ if (secret.sensitive !== void 0 && typeof secret.sensitive !== "boolean") throw new Error("a secret is sensitive or not: true, false or absent");
3202
3463
  if (!Number.isSafeInteger(secret.version) || secret.version < 1) throw new Error("secret version must be a positive integer");
3203
3464
  if (typeof secret.path !== "string" || /[\uD800-\uDFFF]/u.test(secret.path)) throw new Error("secret path must be a well-formed string");
3204
3465
  if (key !== void 0) {
@@ -3346,9 +3607,10 @@ function personalRefusal(reader, principal, permission, secret, at, environments
3346
3607
  projectId: secret.projectId,
3347
3608
  environmentId: own.parentId
3348
3609
  }, environments);
3349
- const refused = refuses(reader, "secret.read", parent, own.deleted || (environments.get(own.parentId)?.deleted ?? true));
3610
+ const gone = own.deleted || (environments.get(own.parentId)?.deleted ?? true);
3611
+ const refused = refuses(reader, "secret.use", parent, gone) === null ? null : refuses(reader, "secret.read", parent, gone);
3350
3612
  if (refused !== null) return refused;
3351
- return withinCredential(reader.live, permission, parent) ? null : "no_grant";
3613
+ return withinCredential(reader.live, permission === "secret.read" ? "secret.use" : permission, parent) ? null : "no_grant";
3352
3614
  }
3353
3615
  function refuses(reader, permission, where, deleted) {
3354
3616
  if (reader.status === "tampered") return "tampered";
@@ -3545,13 +3807,87 @@ function view(grant) {
3545
3807
  function iso(ms) {
3546
3808
  return new Date(ms).toISOString();
3547
3809
  }
3548
- /** A secret's binding context: its ids, and a personal config's owner, hashed, as the decision read it. */
3810
+ /** A policy entry's edits, checked as the vault wrote them, or null for any that does not hold. */
3811
+ function readEdits(stored) {
3812
+ const edits = stored.policy;
3813
+ if (typeof edits !== "object" || edits === null) return null;
3814
+ const { roles, instanceRoles, protected: protectedIds } = edits;
3815
+ if (typeof roles !== "object" || roles === null || typeof instanceRoles !== "object" || instanceRoles === null || !Array.isArray(protectedIds)) return null;
3816
+ const defined = (table) => Object.values(table).every((definition) => definitionProblem(definition) === null);
3817
+ if (!defined(roles) || !defined(instanceRoles) || Object.keys(instanceRoles).some((role) => !isInstanceRole(role))) return null;
3818
+ if (protectedIds.some((place) => typeof place !== "object" || place === null || !UUID.test(place.projectId) || !UUID.test(place.environmentId) || !Number.isSafeInteger(place.at))) return null;
3819
+ return {
3820
+ roles,
3821
+ instanceRoles,
3822
+ protected: protectedIds
3823
+ };
3824
+ }
3825
+ /**
3826
+ * The policy `edits` say as of `at`: the roles as they ship, each edited
3827
+ * one replaced and each made one added; and the protected environments, but
3828
+ * for those deleted since, the project around one's included: a deleted
3829
+ * prod protects nothing, and holds nobody back from giving a service
3830
+ * account a role on all of its project (`givesService`). A fork's id with
3831
+ * no row yet stays, protected before it is written, ten minutes: one with
3832
+ * none after that is a fork refused or abandoned, never written. Each
3833
+ * keeps its slug, which a scoped instance role's filters read. The edits
3834
+ * keep them all: no write is needed.
3835
+ */
3836
+ async function livePolicy(db, edits, at) {
3837
+ const rows = await environmentsById(db, edits.protected.map((place) => place.environmentId));
3838
+ const live = edits.protected.filter((place) => {
3839
+ const row = rows.get(place.environmentId);
3840
+ return row === void 0 ? at - place.at < UNWRITTEN_FORK_MS : !row.deleted;
3841
+ });
3842
+ const slugOf = (place) => rows.get(place.environmentId)?.slug ?? null;
3843
+ return {
3844
+ roles: {
3845
+ ...ROLES,
3846
+ ...edits.roles
3847
+ },
3848
+ instanceRoles: {
3849
+ ...INSTANCE_ROLES,
3850
+ ...edits.instanceRoles
3851
+ },
3852
+ protected: live.map((place) => place.environmentId),
3853
+ protectedIn: live.reduce((byProject, place) => ({
3854
+ ...byProject,
3855
+ [place.projectId]: [...byProject[place.projectId] ?? [], {
3856
+ environmentId: place.environmentId,
3857
+ slug: slugOf(place)
3858
+ }]
3859
+ }), {})
3860
+ };
3861
+ }
3862
+ /** A change, as its entry names it. */
3863
+ function describeChange(change) {
3864
+ if (change === void 0 || change === null || typeof change !== "object") return null;
3865
+ if (change.kind === "protect") return {
3866
+ kind: "protect",
3867
+ projectId: change.projectId,
3868
+ environmentIds: change.environmentIds,
3869
+ protected: change.protected
3870
+ };
3871
+ return {
3872
+ kind: change.kind,
3873
+ role: change.role,
3874
+ definition: change.definition
3875
+ };
3876
+ }
3877
+ /** What a change changed, as it was: the role's definition, or which environments were protected. */
3878
+ function describeBefore(change, before) {
3879
+ if (change.kind === "protect") return { protected: change.environmentIds.filter((id) => before.protected.includes(id)) };
3880
+ const table = change.kind === "instance-role" ? before.instanceRoles : before.roles;
3881
+ return { definition: Object.hasOwn(table, change.role) ? table[change.role] : null };
3882
+ }
3883
+ /** A secret's binding context: its ids, a personal config's owner, hashed, as the decision read it, and whether it is sensitive. */
3549
3884
  function context(secret, owner) {
3550
3885
  return {
3551
3886
  projectId: secret.projectId,
3552
3887
  environmentId: secret.environmentId,
3553
3888
  secretId: secret.secretId,
3554
- ...owner === void 0 ? {} : { owner }
3889
+ ...owner === void 0 ? {} : { owner },
3890
+ ...secret.sensitive === true ? { sensitive: true } : {}
3555
3891
  };
3556
3892
  }
3557
3893
  function unwrappable(wrapped) {