@coffre/vault 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,14 @@
1
- import { ACCESS_ACTIONS, POLICY_REVISION, checkpointMessage, checkpointVerifier, describeAccessFault, handsBack, policyRevision } from "@coffre/core/vault";
1
+ import { ACCESS_ACTIONS, ACCESS_ACTIONS as ACCESS_ACTIONS$1, 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 { createHash, createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
4
4
  import { prepared, tablesOf } from "@coffre/db";
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";
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, ownsProject, readsSensitive, runsInstance, setsUpServices, unscoped, useOnly, 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
- import { shownMember } from "@coffre/core/schemas";
9
- import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUniqueViolation, isUnreachable, tombstone, truth } from "@coffre/db/dialect";
8
+ import { TOMBSTONE, shownMember } from "@coffre/core/schemas";
9
+ import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUniqueViolation, isUnreachable, rowsOf, tombstone, truth } from "@coffre/db/dialect";
10
10
  import { accessEntriesAbout, everyProjectConversion, insertGrant, liveProjects, newestAccess, readGrants, reportStands, roleColumns, storedRole } from "@coffre/db/grants";
11
- import { appendEntries, entryColumns, lockLogHead, missingEntry, newestAuthentic, newestVaultEntries, newestVaultSeq, withinBytes } from "@coffre/db/log";
11
+ import { LogHeadMismatch, appendEntries, entryColumns, lockLogHead, missingEntry, newestAuthentic, newestVaultEntries, withinBytes } from "@coffre/db/log";
12
12
  import { and, asc, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm";
13
13
  //#region src/checkpoint.ts
14
14
  const PKCS8_ED25519 = [
@@ -43,6 +43,138 @@ async function signer(seed) {
43
43
  };
44
44
  }
45
45
  //#endregion
46
+ //#region src/decide.ts
47
+ /**
48
+ * Where a decision checks a permission: an environment, with its slug as
49
+ * the store has it when it was read and is in the project claimed;
50
+ * otherwise none, which no grant on a slug matches.
51
+ */
52
+ function placeIn(place, environments) {
53
+ const environment = environments.get(place.environmentId);
54
+ const slug = environment?.projectId === place.projectId ? environment.slug : null;
55
+ return {
56
+ projectId: place.projectId,
57
+ environmentId: place.environmentId,
58
+ environmentSlug: slug
59
+ };
60
+ }
61
+ /**
62
+ * A key of a personal config is its owner's alone (docs/design/forking.md,
63
+ * section 4): no grant or role opens or wraps it, an Owner's included,
64
+ * only its owner, while they read the environment it is a config of,
65
+ * whatever the call is. Nor does any reference reach into or out of one.
66
+ * Undefined for a key of any other environment, decided as ever.
67
+ */
68
+ function personalRefusal(reader, principal, permission, secret, at, environments) {
69
+ const own = environments.get(secret.environmentId);
70
+ const elsewhere = at.environmentId !== secret.environmentId;
71
+ const held = elsewhere ? environments.get(at.environmentId) : own;
72
+ if (own?.owner == null && held?.owner == null) return void 0;
73
+ if (elsewhere || own === void 0 || principal !== own.owner || own.parentId === null) return "personal";
74
+ const parent = placeIn({
75
+ projectId: secret.projectId,
76
+ environmentId: own.parentId
77
+ }, environments);
78
+ const gone = own.deleted || (environments.get(own.parentId)?.deleted ?? true);
79
+ const refused = refuses(reader, "secret.use", parent, gone) === null ? null : refuses(reader, "secret.read", parent, gone);
80
+ if (refused !== null) return refused;
81
+ return withinCredential(reader.live, permission === "secret.read" ? "secret.use" : permission, parent) ? null : "no_grant";
82
+ }
83
+ /**
84
+ * Why `reader` may not do `permission` at `where`, or null if they may. A
85
+ * deleted place is refused before any grant is asked, so no kind of grant,
86
+ * on the project, the environment or every project, can reach into one.
87
+ */
88
+ function refuses(reader, permission, where, deleted) {
89
+ if (reader.status === "tampered") return "tampered";
90
+ if (reader.status === "removed") return "removed";
91
+ if (reader.status === "unknown") return "not_a_member";
92
+ if (deleted) return "deleted";
93
+ if (allows(reader.live, permission, where)) return null;
94
+ return allows(reader.all, permission, where) ? "expired" : "no_grant";
95
+ }
96
+ /**
97
+ * What refuses each key of a call by where it is, before any `vet`: its
98
+ * personal config's owner, any one of the permissions it `needs` besides,
99
+ * `permission` itself, at `at`, its reference's holder or where it is; and
100
+ * a key it `owns` that its reader does not own the project of.
101
+ */
102
+ function keyRefusals(reader, call) {
103
+ const { principal, permission, secrets, at, environments, needs } = call;
104
+ const gone = (place) => environments.get(place.environmentId)?.deleted ?? false;
105
+ const refusalOf = (secret, i) => {
106
+ const personal = personalRefusal(reader, principal, permission, secret, at[i], environments);
107
+ if (personal !== void 0) return personal;
108
+ const also = needs[i];
109
+ if (also != null) {
110
+ const place = placeIn(at[i], environments);
111
+ const refused = also.map((need) => refuses(reader, need, place, gone(secret) || gone(at[i])));
112
+ if (refused.every((code) => code !== null)) return refused[0] ?? "no_grant";
113
+ }
114
+ return refuses(reader, permission, placeIn(at[i], environments), gone(secret) || gone(at[i]));
115
+ };
116
+ return secrets.map((secret, i) => {
117
+ const code = refusalOf(secret, i);
118
+ return code === null && call.owns?.[i] === true && !ownsProject(reader.live, secret.projectId) ? "not_allowed" : code;
119
+ });
120
+ }
121
+ /**
122
+ * Each key's refusal once its sensitivity is weighed too, after `vet`. A
123
+ * sensitive value is only ever used for a person, never handed back, by
124
+ * whatever credential of theirs asks; and handed back to a service account
125
+ * only on a credential an Owner of its own project issued, a reference's
126
+ * source's too, whose value it is (`readsSensitive`, R32). And a fork
127
+ * copies one, for anyone, a service account too, only where its app says
128
+ * it keeps it sensitive (an app of 0.9, mid-deploy, would write the copy as
129
+ * a secret its people read), and never one read through a reference.
130
+ */
131
+ function sensitiveRefusals(codes, reader, call) {
132
+ const copying = call.action === "secret.read" && call.purpose === "copy";
133
+ const handedBack = call.action === "secret.read" && handsBack({
134
+ purpose: call.purpose ?? "",
135
+ keepsSensitive: call.keepsSensitive === true
136
+ });
137
+ const person = call.principal.startsWith("user:");
138
+ return call.secrets.map((secret, i) => {
139
+ const code = codes[i] ?? null;
140
+ if (code !== null || secret.sensitive !== true) return code;
141
+ if (copying && call.keepsSensitive !== true || copying && call.viaReference?.[i] === true || handedBack && person) return "sensitive";
142
+ if (handedBack && !readsSensitive(reader.live, secret.projectId)) return "not_owner_issued";
143
+ return null;
144
+ });
145
+ }
146
+ /**
147
+ * Each key's refusal once a workload's listing is weighed too, after `vet`
148
+ * and before its sensitivity (docs/design/workload-access.md, section 6.1).
149
+ * A workload is handed back, for any purpose but the proxy's use, only a
150
+ * value its credential lists where the value is held (`useOnly`): each
151
+ * secret is that place, a reference's source, a fork's parent, whatever
152
+ * place the call checked its permission at. A copy too, kept sensitive or
153
+ * not: it would land where the workload may read it. The vault opens no
154
+ * plain value, so every key here is a secret or a sensitive one.
155
+ */
156
+ function useOnlyRefusals(codes, reader, call) {
157
+ const handedBack = call.action === "secret.read" && call.purpose !== "use";
158
+ return call.secrets.map((secret, i) => codes[i] ?? (handedBack && useOnly(reader.live, secret) ? "use_only" : null));
159
+ }
160
+ /**
161
+ * Why `reader` may not make a reference at `holder` to `source`, or null, past
162
+ * what only the database settles (a source of another claim, a reference to
163
+ * itself). A personal config's values are bound to its owner: no reference
164
+ * holds one, nor reads one. Making one hands its source's value to whoever
165
+ * reads the holder, whatever its nature becomes: its maker writes the holder,
166
+ * reads the source there, as any direct access, and, for a sensitive one,
167
+ * which nobody reads, uses it there too (R24). A workload refers only to a
168
+ * value it is handed back, a plain one or one it lists (`useOnly`): it
169
+ * would otherwise move a value it uses to a place whose keys it reads.
170
+ */
171
+ function referenceRefusal(reader, holder, source, environments, nature) {
172
+ if (environments.get(holder.environmentId)?.owner != null || environments.get(source.environmentId)?.owner != null) return "personal";
173
+ const gone = (place) => environments.get(place.environmentId)?.deleted ?? false;
174
+ const at = placeIn(source, environments);
175
+ return refuses(reader, "secret.write", placeIn(holder, environments), gone(holder)) ?? refuses(reader, "secret.read", at, gone(source)) ?? (nature === "sensitive" ? refuses(reader, "secret.use", at, gone(source)) : null) ?? (nature !== "plain" && useOnly(reader.live, source) ? "use_only" : null);
176
+ }
177
+ //#endregion
46
178
  //#region src/store.ts
47
179
  /**
48
180
  * On Postgres, fail a lock wait in this transaction after `ms`, rather than
@@ -75,7 +207,7 @@ function memberColumns(db) {
75
207
  };
76
208
  }
77
209
  /** A member as their row says (`storedRole`). */
78
- function memberOf({ owner, role, scope, ...row }) {
210
+ function memberOf$1({ owner, role, scope, ...row }) {
79
211
  return {
80
212
  ...row,
81
213
  ...storedRole({
@@ -95,7 +227,7 @@ function storedChange(change) {
95
227
  }
96
228
  async function member(db, principal) {
97
229
  const [row] = await memberRow(db, { principal });
98
- return row === void 0 ? void 0 : memberOf(row);
230
+ return row === void 0 ? void 0 : memberOf$1(row);
99
231
  }
100
232
  const memberRow = prepared((db) => {
101
233
  const { vaultMembers } = tablesOf(db);
@@ -103,7 +235,7 @@ const memberRow = prepared((db) => {
103
235
  });
104
236
  async function allMembers(db) {
105
237
  const { vaultMembers } = tablesOf(db);
106
- return (await db.select(memberColumns(db)).from(vaultMembers).orderBy(asc(vaultMembers.principal))).map(memberOf);
238
+ return (await db.select(memberColumns(db)).from(vaultMembers).orderBy(asc(vaultMembers.principal))).map(memberOf$1);
107
239
  }
108
240
  /**
109
241
  * The rows that say their member is an active Owner of the whole instance
@@ -113,7 +245,7 @@ async function allMembers(db) {
113
245
  */
114
246
  async function instanceOwners(db) {
115
247
  const { vaultMembers } = tablesOf(db);
116
- return (await db.select(memberColumns(db)).from(vaultMembers).where(and(eq(vaultMembers.status, "active"), eq(vaultMembers.role, "owner"))).orderBy(asc(vaultMembers.principal))).map(memberOf).filter(ownsInstance);
248
+ return (await db.select(memberColumns(db)).from(vaultMembers).where(and(eq(vaultMembers.status, "active"), eq(vaultMembers.role, "owner"))).orderBy(asc(vaultMembers.principal))).map(memberOf$1).filter(ownsInstance);
117
249
  }
118
250
  /**
119
251
  * Lock these members' rows for the rest of the transaction, in one order
@@ -124,7 +256,7 @@ async function instanceOwners(db) {
124
256
  async function lockMembers(tx, principals) {
125
257
  const { vaultMembers } = tablesOf(tx);
126
258
  const rows = await forUpdate(tx, tx.select(memberColumns(tx)).from(vaultMembers).where(inArray(vaultMembers.principal, [...new Set(principals)])).orderBy(asc(vaultMembers.principal)), "no key update");
127
- return new Map(rows.map((row) => [row.principal, memberOf(row)]));
259
+ return new Map(rows.map((row) => [row.principal, memberOf$1(row)]));
128
260
  }
129
261
  /**
130
262
  * Add `row`. A principal has one row: when two decisions admit the same
@@ -221,25 +353,6 @@ async function everyProjectHolders(db) {
221
353
  const { vaultGrants } = tablesOf(db);
222
354
  return (await db.selectDistinct({ principal: vaultGrants.principal }).from(vaultGrants).where(and(isNull(vaultGrants.projectId), isNull(vaultGrants.environmentId))).orderBy(asc(vaultGrants.principal))).map((row) => row.principal);
223
355
  }
224
- /** The action of an entry that releases a key, which the bulk limit counts. */
225
- const RELEASE = "secret.read";
226
- /**
227
- * How many keys the vault has released to `principal` since `after`: one
228
- * for an entry about one secret, and as many as it lists for one about
229
- * several (`secrets`), a read logged once per place.
230
- */
231
- async function releasesSince(db, principal, after) {
232
- const [{ n }] = await released(db, {
233
- principal,
234
- after
235
- });
236
- return Number(n ?? 0);
237
- }
238
- const released = prepared((db) => {
239
- const { auditLog } = tablesOf(db);
240
- const listed = engineOf(db) === "postgres" ? sql`jsonb_array_length((${auditLog.metadata})::jsonb -> 'secrets')` : sql`json_array_length(${auditLog.metadata}, '$.secrets')`;
241
- return db.select({ n: sql`SUM(COALESCE(${listed}, 1))` }).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.actor, sql.placeholder("principal")), eq(auditLog.action, RELEASE), eq(auditLog.decision, "allow"), gt(auditLog.occurredAt, sql.placeholder("after"))));
242
- });
243
356
  /** Rows of the log as the chain's codec reads them: `author` and `decision` are checked by the table. */
244
357
  function stored(rows) {
245
358
  return rows;
@@ -364,8 +477,10 @@ async function natures(db, secretIds) {
364
477
  }
365
478
  /**
366
479
  * For each version a rewrap would bring back into its secret, its nature
367
- * as stored when it is an old value, not the secret's current one; null
368
- * for the current one, which a mark wraps again with no value changing.
480
+ * when it is an old value, no looser than it was stored nor than its value
481
+ * was made since (the newest version of its very ciphertext), not the
482
+ * secret's current one; null for the current one, which a mark wraps again
483
+ * with no value changing.
369
484
  */
370
485
  async function restoredNatures(db, items) {
371
486
  if (items.length === 0) return [];
@@ -375,8 +490,8 @@ async function restoredNatures(db, items) {
375
490
  version: secrets.currentVersionId
376
491
  }).from(secrets).where(inArray(secrets.id, [...new Set(items.map((item) => item.secretId))])), db.select({
377
492
  id: secretVersions.id,
378
- sensitive: secretVersions.sensitive,
379
- plain: truth(isNotNull(secretVersions.plaintext))
493
+ sensitive: sql`(${secretVersions.sensitive} OR (${heldAs(secretVersions, sql`same.sensitive`)}))`,
494
+ plain: sql`(${secretVersions.plaintext} IS NOT NULL AND (${heldAs(secretVersions, sql`same.plaintext IS NOT NULL`)}))`
380
495
  }).from(secretVersions).where(inArray(secretVersions.id, [...new Set(items.map((item) => item.from))]))]);
381
496
  const currentOf = new Map(current.map((row) => [row.id, row.version]));
382
497
  const natureOf = new Map(versions.map((row) => [row.id, Boolean(row.sensitive) ? "sensitive" : Boolean(row.plain) ? "plain" : "secret"]));
@@ -436,7 +551,7 @@ async function talliedAfter(db, after, limit) {
436
551
  /** The vault's newest allowed access entry about each member who has one. */
437
552
  async function newestAccessEntries(db) {
438
553
  const { auditLog } = tablesOf(db);
439
- const newest = db.select({ seq: sql`max(${auditLog.seq})` }).from(auditLog).where(and(eq(auditLog.author, "vault"), inArray(auditLog.action, [...ACCESS_ACTIONS]), eq(auditLog.decision, "allow"))).groupBy(auditLog.subjectPrincipal);
554
+ const newest = db.select({ seq: sql`max(${auditLog.seq})` }).from(auditLog).where(and(eq(auditLog.author, "vault"), inArray(auditLog.action, [...ACCESS_ACTIONS$1]), eq(auditLog.decision, "allow"))).groupBy(auditLog.subjectPrincipal);
440
555
  const rows = stored(await db.select(entryColumns(db)).from(auditLog).where(inArray(auditLog.seq, newest)));
441
556
  return new Map(rows.flatMap((row) => row.subjectPrincipal === null ? [] : [[row.subjectPrincipal, row]]));
442
557
  }
@@ -486,7 +601,8 @@ async function versions(db, ids) {
486
601
  currentVersionId: secrets.currentVersionId,
487
602
  sensitive: secretVersions.sensitive,
488
603
  newest: sql`(SELECT max(newest.version) FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId})`,
489
- newestSensitive: sql`(SELECT newest.sensitive FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId} ORDER BY newest.version DESC LIMIT 1)`
604
+ newestSensitive: sql`(SELECT newest.sensitive FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId} ORDER BY newest.version DESC LIMIT 1)`,
605
+ heldSensitive: sql`(${heldAs(secretVersions, sql`same.sensitive`)})`
490
606
  }).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) => ({
491
607
  id: row.id,
492
608
  deleted: row.deleted,
@@ -505,9 +621,18 @@ async function versions(db, ids) {
505
621
  bytes: row.wrappedDek.toString("base64")
506
622
  },
507
623
  current: row.currentVersionId === row.id && Number(row.newest) === row.version,
508
- newestSensitive: Boolean(row.newestSensitive)
624
+ newestSensitive: Boolean(row.newestSensitive),
625
+ heldSensitive: Boolean(row.heldSensitive)
509
626
  }));
510
627
  }
628
+ /**
629
+ * `column` of the newest version of a version's secret with its very ciphertext: its value, as it is held now. Known
630
+ * by its IV and tag, which under one data key name one ciphertext, and which, unlike the ciphertext, no erasure empties.
631
+ */
632
+ function heldAs(secretVersions, column) {
633
+ return sql`SELECT ${column} FROM secret_versions same WHERE same.secret_id = ${secretVersions.secretId}
634
+ AND same.iv = ${secretVersions.iv} AND same.auth_tag = ${secretVersions.authTag} ORDER BY same.version DESC LIMIT 1`;
635
+ }
511
636
  //#endregion
512
637
  //#region src/log.ts
513
638
  /**
@@ -994,6 +1119,255 @@ function sealed(key, member, grants) {
994
1119
  return member.mac.length === expected.length && timingSafeEqual(member.mac, expected);
995
1120
  }
996
1121
  //#endregion
1122
+ //#region src/snapshot.postgres.ts
1123
+ /** An entry of `audit_log` aliased `e`, as JSON (`RawEntry`): whole numbers past 2^53 as text, bytes as hex. */
1124
+ const ENTRY$1 = sql.raw(`json_build_object(
1125
+ 'seq', e.seq::text, 'author', e.author, 'keyId', e.key_id, 'occurredAt', e.occurred_at, 'actor', e.actor, 'action', e.action,
1126
+ 'decision', e.decision, 'code', e.code, 'subjectPrincipal', e.subject_principal, 'projectId', e.project_id,
1127
+ 'environmentId', e.environment_id, 'secretId', e.secret_id, 'secretVersionId', e.secret_version_id,
1128
+ 'operationId', e.operation_id, 'requestId', e.request_id, 'sourceIp', e.source_ip, 'relatedSeq', e.related_seq::text,
1129
+ 'metadata', e.metadata, 'prevHash', encode(e.prev_hash, 'hex'), 'mac', encode(e.mac, 'hex'), 'hash', encode(e.hash, 'hex'))`);
1130
+ const NOW$1 = sql.raw("(extract(epoch from clock_timestamp()) * 1000)::bigint");
1131
+ /** `snapshot`'s statement on Postgres: one row, one JSON column. Each part reads one index range. */
1132
+ function snapshotQuery$1(asked) {
1133
+ const principals = sql`${sql.param([...asked.principals])}::text[]`;
1134
+ const environments = sql`${sql.param([...asked.environments ?? []])}::uuid[]`;
1135
+ const references = sql`${sql.param((asked.references ?? []).map(String))}::bigint[]`;
1136
+ const actions = sql`${sql.param([...ACCESS_ACTIONS])}::text[]`;
1137
+ const tomb = `%${TOMBSTONE}%`;
1138
+ const released = asked.released;
1139
+ const head = asked.head === true ? sql`, head AS (SELECT next_seq, head_hash FROM audit_chain_head WHERE only_row FOR UPDATE SKIP LOCKED)` : sql``;
1140
+ return sql`
1141
+ WITH policy AS (
1142
+ SELECT * FROM audit_log WHERE author = 'vault' AND action = 'policy.change' AND decision = 'allow' ORDER BY seq DESC LIMIT 1
1143
+ )${head}
1144
+ SELECT json_build_object(
1145
+ 'at', ${NOW$1},
1146
+ 'head', ${asked.head === true ? sql`(SELECT json_build_object('nextSeq', next_seq::text, 'hash', encode(head_hash, 'hex')) FROM head)` : sql`NULL`},
1147
+ 'last', ${asked.head === true ? sql`(SELECT json_build_object('seq', seq::text, 'hash', encode(hash, 'hex')) FROM audit_log ORDER BY seq DESC LIMIT 1)` : sql`NULL`},
1148
+ 'rotation', ${asked.head === true ? sql`(SELECT ${ENTRY$1} FROM audit_log e WHERE author = 'vault' AND action = 'key.rotate' AND decision = 'allow' ORDER BY seq DESC LIMIT 1)` : sql`NULL`},
1149
+ 'members', ${asked.members === true ? sql`(SELECT coalesce(json_agg(json_build_object(
1150
+ 'principal', principal, 'status', status, 'owner', owner, 'role', role, 'scope', scope, 'generation', generation,
1151
+ 'createdAt', created_at, 'createdBy', created_by, 'statusChangedAt', status_changed_at, 'statusChangedBy', status_changed_by,
1152
+ 'accessSeq', access_seq::text, 'mac', encode(mac, 'hex'))), '[]') FROM vault_members WHERE principal = ANY(${principals}))` : sql`'[]'::json`},
1153
+ 'grants', (
1154
+ SELECT coalesce(json_agg(json_build_object(
1155
+ 'principal', g.principal, 'projectId', coalesce(g.project_id, env.project_id), 'environmentId', g.environment_id,
1156
+ 'environmentSlug', g.environment_slug, 'role', g.role, 'expiresAt', g.expires_at, 'grantedAt', g.granted_at, 'grantedBy', g.granted_by)), '[]')
1157
+ FROM vault_grants g LEFT JOIN environments env ON env.id = g.environment_id
1158
+ WHERE g.principal = ANY(${principals})
1159
+ ),
1160
+ 'access', (
1161
+ SELECT coalesce(json_agg(${ENTRY$1}), '[]')
1162
+ FROM unnest(${principals}) AS asked (principal)
1163
+ CROSS JOIN LATERAL (
1164
+ SELECT * FROM audit_log
1165
+ WHERE author = 'vault' AND subject_principal = asked.principal AND action = ANY(${actions}) AND decision = 'allow'
1166
+ ORDER BY seq DESC LIMIT 1
1167
+ ) e
1168
+ ),
1169
+ 'policy', (SELECT ${ENTRY$1} FROM policy e),
1170
+ 'environments', (
1171
+ SELECT coalesce(json_agg(json_build_object(
1172
+ 'id', env.id, 'projectId', env.project_id, 'slug', env.slug, 'owner', env.owner, 'parentId', env.parent_id,
1173
+ 'deleted', env.slug LIKE ${tomb} OR project.slug LIKE ${tomb},
1174
+ 'archived', env.archived_at IS NOT NULL OR project.archived_at IS NOT NULL)), '[]')
1175
+ FROM environments env JOIN projects project ON project.id = env.project_id
1176
+ -- One list of ids, each found by the primary key.
1177
+ WHERE env.id = ANY(ARRAY(
1178
+ SELECT unnest(${environments})
1179
+ UNION SELECT parent_id FROM environments WHERE id = ANY(${environments}) AND owner IS NOT NULL
1180
+ -- What the newest policy protects, by the ids its JSON names; one that is no UUID names nothing.
1181
+ UNION SELECT (place #>> '{}')::uuid FROM policy, jsonb_path_query(policy.metadata::jsonb, '$.policy.protected[*].environmentId') AS place
1182
+ WHERE (place #>> '{}') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
1183
+ ))
1184
+ ),
1185
+ 'ended', (
1186
+ SELECT coalesce(json_agg(related_seq::text), '[]') FROM audit_log
1187
+ WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq = ANY(${references})
1188
+ ),
1189
+ 'released', ${released === void 0 ? sql`0` : sql`(
1190
+ SELECT count(DISTINCT key) FROM (
1191
+ -- A read of one key names it; of several at one place, lists them (readsByPlace, vault.ts). An entry, or an
1192
+ -- item of its list, that names no key, which only a forged entry is, counts as one key of its own.
1193
+ SELECT coalesce(e.secret_id::text, CASE jsonb_typeof(one -> 'secretId') WHEN 'string' THEN one ->> 'secretId' END, 'entry:' || e.seq) FROM audit_log e
1194
+ -- Each item of a list, never what lax paths would make of an object; silent, as strict paths fail on what is missing.
1195
+ LEFT JOIN LATERAL jsonb_path_query(CASE WHEN e.secret_id IS NULL THEN e.metadata::jsonb END, 'strict $.secrets ? (@.type() == "array")[*]', '{}', true) AS one ON true
1196
+ WHERE e.author = 'vault' AND e.actor = ${released.principal} AND e.action = 'secret.read' AND e.decision = 'allow'
1197
+ AND e.occurred_at > ${NOW$1} + ${released.clockOffsetMs} - ${released.windowMs}
1198
+ UNION ALL SELECT unnest(${sql.param([...released.keys])}::text[])
1199
+ ) AS released (key)
1200
+ )`}
1201
+ ) AS snapshot`;
1202
+ }
1203
+ //#endregion
1204
+ //#region src/snapshot.sqlite.ts
1205
+ /**
1206
+ * An entry of `audit_log` aliased `e`, as JSON (`RawEntry`), bytes as hex.
1207
+ * A JSON value loses its type leaving a subquery, so whoever takes one
1208
+ * from a subquery wraps it in `json(…)`.
1209
+ */
1210
+ const ENTRY = sql.raw(`json_object(
1211
+ 'seq', e.seq, 'author', e.author, 'keyId', e.key_id, 'occurredAt', e.occurred_at, 'actor', e.actor, 'action', e.action,
1212
+ 'decision', e.decision, 'code', e.code, 'subjectPrincipal', e.subject_principal, 'projectId', e.project_id,
1213
+ 'environmentId', e.environment_id, 'secretId', e.secret_id, 'secretVersionId', e.secret_version_id,
1214
+ 'operationId', e.operation_id, 'requestId', e.request_id, 'sourceIp', e.source_ip, 'relatedSeq', e.related_seq,
1215
+ 'metadata', e.metadata, 'prevHash', hex(e.prev_hash), 'mac', hex(e.mac), 'hash', hex(e.hash))`);
1216
+ const NOW = sql.raw(`CAST(unixepoch('subsec') * 1000 AS INTEGER)`);
1217
+ /**
1218
+ * `snapshot`'s statement on SQLite, the same as on Postgres. Nothing is
1219
+ * locked: one transaction runs at a time (connect.ts), so the head is the
1220
+ * last entry's.
1221
+ */
1222
+ function snapshotQuery(asked) {
1223
+ const principals = JSON.stringify(asked.principals);
1224
+ const environments = JSON.stringify(asked.environments ?? []);
1225
+ const references = JSON.stringify((asked.references ?? []).map(Number));
1226
+ const actions = JSON.stringify(ACCESS_ACTIONS);
1227
+ const tomb = `%${TOMBSTONE}%`;
1228
+ const released = asked.released;
1229
+ const head = asked.head === true;
1230
+ return sql`
1231
+ WITH policy AS (
1232
+ SELECT * FROM audit_log WHERE author = 'vault' AND action = 'policy.change' AND decision = 'allow' ORDER BY seq DESC LIMIT 1
1233
+ )
1234
+ SELECT json_object(
1235
+ 'at', ${NOW},
1236
+ 'head', ${head ? sql`json((SELECT json_object('nextSeq', next_seq, 'hash', hex(head_hash)) FROM audit_chain_head WHERE only_row))` : sql`NULL`},
1237
+ 'last', ${head ? sql`json((SELECT json_object('seq', seq, 'hash', hex(hash)) FROM audit_log ORDER BY seq DESC LIMIT 1))` : sql`NULL`},
1238
+ 'rotation', ${head ? sql`json((SELECT ${ENTRY} FROM audit_log e WHERE author = 'vault' AND action = 'key.rotate' AND decision = 'allow' ORDER BY seq DESC LIMIT 1))` : sql`NULL`},
1239
+ 'members', json(${asked.members === true ? sql`(SELECT json_group_array(json_object(
1240
+ 'principal', principal, 'status', status, 'owner', owner, 'role', role, 'scope', scope, 'generation', generation,
1241
+ 'createdAt', created_at, 'createdBy', created_by, 'statusChangedAt', status_changed_at, 'statusChangedBy', status_changed_by,
1242
+ 'accessSeq', access_seq, 'mac', hex(mac))) FROM vault_members WHERE principal IN (SELECT value FROM json_each(${principals})))` : sql`'[]'`}),
1243
+ 'grants', json((
1244
+ SELECT json_group_array(json_object(
1245
+ 'principal', g.principal, 'projectId', coalesce(g.project_id, env.project_id), 'environmentId', g.environment_id,
1246
+ 'environmentSlug', g.environment_slug, 'role', g.role, 'expiresAt', g.expires_at, 'grantedAt', g.granted_at, 'grantedBy', g.granted_by))
1247
+ FROM vault_grants g LEFT JOIN environments env ON env.id = g.environment_id
1248
+ WHERE g.principal IN (SELECT value FROM json_each(${principals}))
1249
+ )),
1250
+ 'access', json((
1251
+ SELECT json_group_array(json(newest)) FROM (
1252
+ SELECT (
1253
+ SELECT ${ENTRY} FROM audit_log e
1254
+ WHERE author = 'vault' AND subject_principal = asked.value AND action IN (SELECT value FROM json_each(${actions})) AND decision = 'allow'
1255
+ ORDER BY seq DESC LIMIT 1
1256
+ ) AS newest
1257
+ FROM json_each(${principals}) AS asked
1258
+ ) WHERE newest IS NOT NULL
1259
+ )),
1260
+ 'policy', json((SELECT ${ENTRY} FROM policy e)),
1261
+ 'environments', json((
1262
+ SELECT json_group_array(json_object(
1263
+ 'id', env.id, 'projectId', env.project_id, 'slug', env.slug, 'owner', env.owner, 'parentId', env.parent_id,
1264
+ 'deleted', env.slug LIKE ${tomb} OR project.slug LIKE ${tomb},
1265
+ 'archived', env.archived_at IS NOT NULL OR project.archived_at IS NOT NULL))
1266
+ FROM environments env JOIN projects project ON project.id = env.project_id
1267
+ WHERE env.id IN (SELECT value FROM json_each(${environments}))
1268
+ OR env.id IN (SELECT parent_id FROM environments WHERE id IN (SELECT value FROM json_each(${environments})) AND owner IS NOT NULL)
1269
+ OR env.id IN (
1270
+ SELECT json_extract(place.value, '$.environmentId')
1271
+ FROM policy, json_each(policy.metadata, '$.policy.protected') AS place
1272
+ -- Of a forged entry's, an element that is no object names nothing: json_extract would fail on a string.
1273
+ WHERE json_valid(policy.metadata) AND json_type(policy.metadata, '$.policy.protected') = 'array' AND place.type = 'object'
1274
+ )
1275
+ )),
1276
+ 'ended', json((
1277
+ SELECT json_group_array(related_seq) FROM audit_log
1278
+ WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq IN (SELECT value FROM json_each(${references}))
1279
+ )),
1280
+ 'released', ${released === void 0 ? sql`0` : sql`(
1281
+ SELECT count(DISTINCT key) FROM (
1282
+ -- A read of one key names it; of several at one place, lists them (readsByPlace, vault.ts). An entry, or an
1283
+ -- item of its list, that names no key, which only a forged entry is, counts as one key of its own.
1284
+ SELECT coalesce(e.secret_id, CASE WHEN one.type = 'object' AND json_type(one.value, '$.secretId') = 'text' THEN json_extract(one.value, '$.secretId') END, 'entry:' || e.seq) AS key
1285
+ FROM audit_log e
1286
+ LEFT JOIN json_each(CASE WHEN e.secret_id IS NULL AND json_type(e.metadata, '$.secrets') = 'array' THEN e.metadata END, '$.secrets') AS one
1287
+ WHERE e.author = 'vault' AND e.actor = ${released.principal} AND e.action = 'secret.read' AND e.decision = 'allow'
1288
+ AND e.occurred_at > ${NOW} + ${released.clockOffsetMs} - ${released.windowMs}
1289
+ UNION ALL SELECT value FROM json_each(${JSON.stringify(released.keys)})
1290
+ )
1291
+ )`}
1292
+ ) AS snapshot`;
1293
+ }
1294
+ //#endregion
1295
+ //#region src/snapshot.ts
1296
+ async function snapshot(db, asked) {
1297
+ const read = () => readOnce(db, asked);
1298
+ const first = await read();
1299
+ if (asked.head !== true || first.head !== null && current(first.head)) return first;
1300
+ if (first.head === null) await lockLogHead(db);
1301
+ const again = await read();
1302
+ if (again.head === null) throw new Error("the log's head, held by this transaction, was read as held by another");
1303
+ if (!current(again.head)) throw new LogHeadMismatch(`the log's head says entry ${again.head.nextSeq} comes next, but the log ends ${again.head.last === null ? "empty" : `at entry ${again.head.last.seq}`}, or with another hash`);
1304
+ return again;
1305
+ }
1306
+ /** Whether the head names the last entry the same statement saw: nothing was appended between its snapshot and its lock. */
1307
+ function current(head) {
1308
+ if (head.last === null) return head.nextSeq === 0n;
1309
+ return head.nextSeq === head.last.seq + 1n && head.headHash.equals(head.last.hash);
1310
+ }
1311
+ async function readOnce(db, asked) {
1312
+ const query = (engineOf(db) === "postgres" ? snapshotQuery$1 : snapshotQuery)(asked);
1313
+ const [row] = await rowsOf(db, query);
1314
+ const raw = typeof row.snapshot === "string" ? JSON.parse(row.snapshot) : row.snapshot;
1315
+ return {
1316
+ at: Number(raw.at),
1317
+ members: new Map(raw.members.map((member) => [member.principal, memberOf(member)])),
1318
+ grants: new Map(asked.principals.map((principal) => [principal, raw.grants.filter((grant) => grant.principal === principal).map(grantOf)])),
1319
+ newestAccess: new Map(raw.access.map((entry) => [entry.subjectPrincipal, entryOf(entry)])),
1320
+ newestPolicy: raw.policy === null ? null : entryOf(raw.policy),
1321
+ environments: new Map(raw.environments.map(({ id, ...environment }) => [id, {
1322
+ ...environment,
1323
+ deleted: Boolean(environment.deleted),
1324
+ archived: Boolean(environment.archived)
1325
+ }])),
1326
+ ended: new Set(raw.ended.map((seq) => BigInt(seq))),
1327
+ released: Number(raw.released ?? 0),
1328
+ head: raw.head === null ? null : {
1329
+ nextSeq: BigInt(raw.head.nextSeq),
1330
+ headHash: Buffer.from(raw.head.hash, "hex"),
1331
+ last: raw.last === null ? null : {
1332
+ seq: BigInt(raw.last.seq),
1333
+ hash: Buffer.from(raw.last.hash, "hex")
1334
+ },
1335
+ rotation: raw.rotation === null ? null : entryOf(raw.rotation)
1336
+ }
1337
+ };
1338
+ }
1339
+ /** An entry as the chain's codec reads it, from what `entryJson` wrote. */
1340
+ function entryOf(raw) {
1341
+ return {
1342
+ ...raw,
1343
+ seq: BigInt(raw.seq),
1344
+ author: raw.author,
1345
+ occurredAt: Number(raw.occurredAt),
1346
+ relatedSeq: raw.relatedSeq === null ? null : BigInt(raw.relatedSeq),
1347
+ prevHash: Buffer.from(raw.prevHash, "hex"),
1348
+ mac: Buffer.from(raw.mac, "hex"),
1349
+ hash: Buffer.from(raw.hash, "hex")
1350
+ };
1351
+ }
1352
+ function grantOf(raw) {
1353
+ return {
1354
+ ...raw,
1355
+ expiresAt: raw.expiresAt === null ? null : Number(raw.expiresAt),
1356
+ grantedAt: Number(raw.grantedAt)
1357
+ };
1358
+ }
1359
+ function memberOf({ owner, ...raw }) {
1360
+ return memberOf$1({
1361
+ ...raw,
1362
+ owner: owner === true || owner === 1,
1363
+ generation: Number(raw.generation),
1364
+ createdAt: Number(raw.createdAt),
1365
+ statusChangedAt: Number(raw.statusChangedAt),
1366
+ accessSeq: BigInt(raw.accessSeq),
1367
+ mac: Buffer.from(raw.mac, "hex")
1368
+ });
1369
+ }
1370
+ //#endregion
997
1371
  //#region src/accounting.ts
998
1372
  const KEY_ACTIONS = /* @__PURE__ */ new Set([
999
1373
  "secret.read",
@@ -1358,7 +1732,7 @@ var Walk = class {
1358
1732
  };
1359
1733
  /** The action of the vault's entry that signs a prefix of the log. */
1360
1734
  const CHECKPOINT = "audit.checkpoint";
1361
- const ACCESS = new Set(ACCESS_ACTIONS);
1735
+ const ACCESS = new Set(ACCESS_ACTIONS$1);
1362
1736
  /** JSON with every object's keys sorted: one text for one value, however it was built. */
1363
1737
  function canonical(value) {
1364
1738
  return JSON.stringify(value, (_, inner) => inner !== null && typeof inner === "object" && !Array.isArray(inner) ? Object.fromEntries(Object.entries(inner).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : inner);
@@ -1493,7 +1867,7 @@ var Outage = class {
1493
1867
  }
1494
1868
  };
1495
1869
  /** A rewrap that would make a value less sensitive than it is. */
1496
- 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";
1870
+ const LOOSER = "a value is made less sensitive only by writing it again: an old version comes back, and a key is made stricter, as sensitive as its newest version or more; only its current sensitive value is made a secret in place, by an Owner of its project";
1497
1871
  function refusal(code, message) {
1498
1872
  return {
1499
1873
  code,
@@ -1511,6 +1885,8 @@ const MESSAGES = {
1511
1885
  ended: "the reference read through was ended",
1512
1886
  personal: "a personal config's values are its owner's alone",
1513
1887
  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",
1888
+ not_owner_issued: "a service account reads a sensitive value only on a token or trust binding an Owner of its project issued",
1889
+ use_only: "a workload uses this value through coffre and never holds it: list it in its binding's reads, where the value is held, if it must",
1514
1890
  not_allowed: "not allowed to change this",
1515
1891
  last_owner: "the last Owner of the whole instance stays one: make someone else an Owner first",
1516
1892
  invalid: "not something the rules allow",
@@ -1521,21 +1897,30 @@ const MESSAGES = {
1521
1897
  };
1522
1898
  /**
1523
1899
  * What the one asking holds through the credential their request came in
1524
- * on: no more than its scope (`allows`). A scope that is not one is a bug
1525
- * of the app's, which fails the call.
1900
+ * on: no more than its scope (`allows`), and, a workload's, handed back
1901
+ * only what the scope lists (`useOnly`). A scope that is not one, a kind of
1902
+ * caller its principal cannot be, or a workload with no scope to list what
1903
+ * it reads, is a bug of the app's, which fails the call. None said is an
1904
+ * app older than 0.11, which has no workloads (`Through`).
1526
1905
  */
1527
- function through(standing, { credentialScope }) {
1528
- if (credentialScope == null) return standing;
1529
- if (!isCredentialScope(credentialScope)) throw new Error("credentialScope must be a list of grants, one a place");
1906
+ function through(standing, { credentialScope, caller }) {
1907
+ const { principal } = standing;
1908
+ if (caller !== void 0 && !(caller === "person" ? principal.startsWith("user:") : (caller === "bearer" || caller === "workload") && principal.startsWith("token:"))) throw new Error(`caller must be person for a user: principal, bearer or workload for a token: one, got: ${String(caller)}`);
1909
+ if (caller === "workload" && credentialScope == null) throw new Error("a workload caller comes with its credential scope, which lists what it reads");
1910
+ if (credentialScope != null && !isCredentialScope(credentialScope)) throw new Error("credentialScope must be a list of grants, one a place");
1911
+ const held = {
1912
+ ...credentialScope == null ? {} : { credentialScope },
1913
+ ...caller === void 0 ? {} : { kind: caller }
1914
+ };
1530
1915
  return {
1531
1916
  ...standing,
1532
1917
  live: {
1533
1918
  ...standing.live,
1534
- credentialScope
1919
+ ...held
1535
1920
  },
1536
1921
  all: {
1537
1922
  ...standing.all,
1538
- credentialScope
1923
+ ...held
1539
1924
  }
1540
1925
  };
1541
1926
  }
@@ -1571,6 +1956,24 @@ var VaultService = class {
1571
1956
  async #now(db) {
1572
1957
  return await now(db) + this.#prepared.options.clockOffset();
1573
1958
  }
1959
+ /** A snapshot's clock, as the vault keeps time (`#now`). */
1960
+ #at(reads) {
1961
+ return reads.at + this.#prepared.options.clockOffset();
1962
+ }
1963
+ /** What a decision about `principals` reads, and `asked` besides, in one statement: under the head when `head` is set. */
1964
+ #snapshot(tx, principals, asked, head) {
1965
+ const { released, ...rest } = asked;
1966
+ return snapshot(tx, {
1967
+ ...rest,
1968
+ principals,
1969
+ head,
1970
+ ...released === void 0 ? {} : { released: {
1971
+ ...released,
1972
+ windowMs: this.#config.bulkLimit.windowMs,
1973
+ clockOffsetMs: this.#prepared.options.clockOffset()
1974
+ } }
1975
+ });
1976
+ }
1574
1977
  /**
1575
1978
  * Decide in one transaction, with `principals`' rows locked first, then
1576
1979
  * the log's head. A `Refused` or an `Outage` rolls back all but its own
@@ -1583,34 +1986,35 @@ var VaultService = class {
1583
1986
  * alone takes the head later (`late`), once its key service has answered,
1584
1987
  * which it must not wait on holding it, and decides again then (`#keys`).
1585
1988
  */
1586
- async #decide(principals, decide, late = false) {
1989
+ async #decide(principals, decide, late = false, asked = {}) {
1587
1990
  const superseded = await this.#settled();
1588
1991
  if (superseded !== null) return {
1589
1992
  ok: false,
1590
1993
  refusal: refusal("wrong_kek", superseded)
1591
1994
  };
1592
- return this.#decideNow(principals, decide, late);
1995
+ return this.#decideNow(principals, decide, late, asked);
1593
1996
  }
1594
1997
  /** `#decide`, once the vault is settled. */
1595
- async #decideNow(principals, decide, late = false) {
1998
+ async #decideNow(principals, decide, late = false, asked = {}) {
1596
1999
  const reports = [];
1597
2000
  for (let attempt = 1;; attempt += 1) try {
1598
- return await this.#decideOnce(principals, decide, reports, late);
2001
+ return await this.#decideOnce(principals, decide, reports, late, asked);
1599
2002
  } catch (error) {
1600
2003
  if (attempt < 3 && (error instanceof Retry || isUniqueViolation(error))) continue;
1601
2004
  throw error;
1602
2005
  }
1603
2006
  }
1604
- async #decideOnce(principals, decide, reports, late) {
2007
+ async #decideOnce(principals, decide, reports, late, asked) {
1605
2008
  try {
1606
2009
  const result = await this.#db.transaction(async (tx) => {
1607
2010
  await boundLockWaits(tx, LOCK_TIMEOUT_MS);
1608
2011
  const members = principals.length === 0 ? /* @__PURE__ */ new Map() : await lockMembers(tx, principals);
1609
- if (!late) await lockLogHead(tx);
2012
+ const reads = await this.#snapshot(tx, principals, asked, !late);
1610
2013
  const d = {
1611
2014
  tx,
1612
2015
  members,
1613
- at: await this.#now(tx),
2016
+ at: this.#at(reads),
2017
+ reads,
1614
2018
  log: [],
1615
2019
  writes: [],
1616
2020
  touched: /* @__PURE__ */ new Set(),
@@ -1624,7 +2028,8 @@ var VaultService = class {
1624
2028
  let at = d.at;
1625
2029
  const accessSeq = /* @__PURE__ */ new Map();
1626
2030
  if (entries.length > 0) {
1627
- const appended = await this.#append(tx, entries, d.underLock);
2031
+ if (d.reads.head === null) throw new Error("a decision appends only under the head it read with");
2032
+ const appended = await this.#append(tx, entries, d.underLock, d.reads);
1628
2033
  at = appended.occurredAt;
1629
2034
  entries.forEach((entry, i) => {
1630
2035
  if (isAccessEntry(entry)) accessSeq.set(entry.subjectPrincipal, appended.seqStart + BigInt(i));
@@ -1689,12 +2094,12 @@ var VaultService = class {
1689
2094
  * MAC are passed over, and reported: otherwise whoever can insert a row
1690
2095
  * could lock any member out.
1691
2096
  */
1692
- async #integrity(db, principal, row, grants, reports) {
2097
+ async #integrity(db, principal, row, grants, reports, reads) {
1693
2098
  if (row !== void 0 && !this.#sealed(row, grants)) {
1694
2099
  this.#report(reports, principal, "mac", row.mac.toString("hex"));
1695
2100
  return "mac";
1696
2101
  }
1697
- const newest = await this.#newestAccess(db, principal, reports);
2102
+ const newest = await this.#newestAccess(db, principal, reports, reads);
1698
2103
  const seq = typeof newest !== "string" ? newest.seq : newest === "unreached" ? void 0 : null;
1699
2104
  if (row === void 0 ? seq === null : seq === row.accessSeq) return null;
1700
2105
  if (row === void 0 && await member(db, principal) !== void 0) throw new Retry();
@@ -1702,8 +2107,10 @@ var VaultService = class {
1702
2107
  return "stale";
1703
2108
  }
1704
2109
  /** The newest access entry about `principal` that carries the vault's MAC (`newestAccess` in @coffre/db/grants). */
1705
- #newestAccess(db, principal, reports) {
1706
- return newestAccess(db, principal, this.#check(reports, principal));
2110
+ #newestAccess(db, principal, reports, reads) {
2111
+ if (reads === void 0 || !reads.grants.has(principal)) return newestAccess(db, principal, this.#check(reports, principal));
2112
+ const newest = reads.newestAccess.get(principal);
2113
+ return newestAuthentic((limit, before) => before === void 0 ? Promise.resolve(newest === void 0 ? [] : [newest]) : accessEntriesAbout(db, principal, limit, before), this.#check(reports, principal));
1707
2114
  }
1708
2115
  /** Entries in the vault's name told apart by their MACs, each it did not write reported, about `principal`. */
1709
2116
  #check(reports, principal) {
@@ -1801,7 +2208,7 @@ var VaultService = class {
1801
2208
  }
1802
2209
  async #replaceEveryProject(d, principal) {
1803
2210
  const row = d.members.get(principal);
1804
- const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
2211
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
1805
2212
  if (row === void 0 || subject.status !== "active") return;
1806
2213
  const everyProject = subject.stored.filter((grant) => grant.projectId === null && grant.environmentId === null);
1807
2214
  if (everyProject.length === 0) return;
@@ -1953,16 +2360,31 @@ var VaultService = class {
1953
2360
  * that an instance still running with the replaced KEK, as during a
1954
2361
  * deploy, cannot write after the rotation.
1955
2362
  */
1956
- #append(tx, entries, underLock = []) {
2363
+ async #append(tx, entries, underLock = [], held) {
1957
2364
  const prepared = this.#prepared;
1958
- return appendEntries(tx, prepared.logKey, entries, async (locked) => {
1959
- const rotation = await latestVaultEntry(locked, [KEY_ROTATE]);
2365
+ const head = held?.head ?? void 0;
2366
+ const appended = await appendEntries(tx, prepared.logKey, entries, async (locked) => {
2367
+ const rotation = head === void 0 ? await latestVaultEntry(locked, [KEY_ROTATE]) : head.rotation ?? void 0;
1960
2368
  if (rotation !== void 0 && !prepared.logKeys.some((key) => key.keyId === rotation.keyId)) {
1961
2369
  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`;
1962
2370
  throw new Error(prepared.superseded);
1963
2371
  }
1964
2372
  for (const check of underLock) await check(locked);
2373
+ }, head === void 0 ? void 0 : {
2374
+ head,
2375
+ last: head.last,
2376
+ at: held.at
1965
2377
  });
2378
+ if (head !== void 0) held.head = {
2379
+ ...head,
2380
+ nextSeq: appended.nextSeq,
2381
+ headHash: appended.headHash,
2382
+ last: {
2383
+ seq: appended.nextSeq - 1n,
2384
+ hash: appended.headHash
2385
+ }
2386
+ };
2387
+ return appended;
1966
2388
  }
1967
2389
  async unwrap(input) {
1968
2390
  const { principal } = input;
@@ -2019,10 +2441,9 @@ var VaultService = class {
2019
2441
  permission: input.purpose === "use" ? "secret.use" : "secret.read",
2020
2442
  secrets: items.map((item) => item.secret),
2021
2443
  places: sealed.map((seal, i) => seal?.holder ?? items[i].secret),
2022
- vet: ends.length === 0 ? void 0 : async (db) => {
2023
- const ended = await endedReferences(db, ends);
2024
- return sealed.map((seal) => seal !== null && ended.has(seal.seq) ? "ended" : null);
2025
- },
2444
+ viaReference: sealed.map((seal) => seal !== null),
2445
+ references: ends,
2446
+ vet: ends.length === 0 ? void 0 : async (_, reads) => sealed.map((seal) => seal !== null && reads.ended.has(seal.seq) ? "ended" : null),
2026
2447
  remote,
2027
2448
  entry,
2028
2449
  input
@@ -2084,13 +2505,19 @@ var VaultService = class {
2084
2505
  ...keyEntry("key.rewrap", principal, source.secret, "deny", "bad_claim", input),
2085
2506
  secretVersionId: source.id
2086
2507
  })));
2087
- 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) => ({
2508
+ const loosens = items.map((item, i) => item.secret.sensitive !== true && loaded.versions[i].secret.sensitive === true && loaded.versions[i].current);
2509
+ const strict = (i) => loaded.versions[i].secret.sensitive || loaded.versions[i].heldSensitive || loaded.versions[i].newestSensitive;
2510
+ if (items.some((item, i) => !loosens[i] && item.secret.sensitive !== true && strict(i))) return this.#refuseVersions(principal, "invalid", loaded.versions.map((source) => ({
2088
2511
  ...keyEntry("key.rewrap", principal, source.secret, "deny", "invalid", input),
2089
2512
  secretVersionId: source.id
2090
2513
  })), LOOSER);
2091
2514
  const sources = new Map(items.map((item, i) => [item.secret, loaded.versions[i]]));
2515
+ const loosened = new Set(items.flatMap((item, i) => loosens[i] ? [item.secret] : []));
2092
2516
  const entry = (secret, decision, code) => ({
2093
- ...keyEntry("key.rewrap", principal, secret, decision, code, input, { from: sources.get(secret).secret.version }),
2517
+ ...keyEntry("key.rewrap", principal, secret, decision, code, input, {
2518
+ from: sources.get(secret).secret.version,
2519
+ ...loosened.has(secret) ? { was: "sensitive" } : {}
2520
+ }),
2094
2521
  secretVersionId: sources.get(secret).id
2095
2522
  });
2096
2523
  const remote = this.#remote(this.#config.keks.primary) || items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
@@ -2104,8 +2531,14 @@ var VaultService = class {
2104
2531
  input,
2105
2532
  vet: async (db) => {
2106
2533
  const newest = await newestSensitive(db, items.map(({ secret }) => secret.secretId));
2107
- return items.map(({ secret }) => secret.sensitive !== true && newest.has(secret.secretId) ? "invalid" : null);
2534
+ const again = new Map((await versions(db, items.map(({ secret }) => sources.get(secret).id))).map((version) => [version.id, version]));
2535
+ return items.map(({ secret }, i) => {
2536
+ const now = again.get(sources.get(secret).id);
2537
+ if (loosens[i]) return now?.current === true ? null : "invalid";
2538
+ return secret.sensitive !== true && (newest.has(secret.secretId) || now?.heldSensitive === true) ? "invalid" : null;
2539
+ });
2108
2540
  },
2541
+ owns: loosens,
2109
2542
  needs: async (db) => {
2110
2543
  return (await restoredNatures(db, items.map(({ secret }) => ({
2111
2544
  secretId: secret.secretId,
@@ -2203,38 +2636,47 @@ var VaultService = class {
2203
2636
  });
2204
2637
  const owners = /* @__PURE__ */ new Map();
2205
2638
  const check = async (d) => {
2206
- const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), call.input);
2639
+ const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), call.input);
2207
2640
  const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
2208
- const environments = await environmentsById(d.tx, [...secrets, ...at].map((place) => place.environmentId));
2209
- const parents = [...environments.values()].flatMap((environment) => environment.owner === null || environment.parentId === null ? [] : [environment.parentId]);
2210
- for (const [id, parent] of await environmentsById(d.tx, parents)) environments.set(id, parent);
2211
- const gone = (place) => environments.get(place.environmentId)?.deleted ?? false;
2641
+ const environments = d.reads.environments;
2212
2642
  owners.clear();
2213
2643
  for (const [id, environment] of environments) if (environment.owner !== null) owners.set(id, ownerKey(environment.owner));
2214
2644
  const needs = call.needs === void 0 ? [] : await call.needs(d.tx);
2215
- const codes = secrets.map((secret, i) => {
2216
- const personal = personalRefusal(reader, principal, call.permission, secret, at[i], environments);
2217
- if (personal !== void 0) return personal;
2218
- const also = needs[i];
2219
- if (also != null) {
2220
- const place = placeIn(at[i], environments);
2221
- const refused = also.map((permission) => refuses(reader, permission, place, gone(secret) || gone(at[i])));
2222
- if (refused.every((code) => code !== null)) return refused[0];
2223
- }
2224
- return refuses(reader, call.permission, placeIn(at[i], environments), gone(secret) || gone(at[i]));
2645
+ let codes = keyRefusals(reader, {
2646
+ principal,
2647
+ permission: call.permission,
2648
+ secrets,
2649
+ at,
2650
+ environments,
2651
+ needs,
2652
+ owns: call.owns
2225
2653
  });
2226
- if (call.vet !== void 0 && !codes.some((code) => code !== null)) (await call.vet(d.tx)).forEach((code, i) => codes[i] = code);
2227
- const copying = action === "secret.read" && call.input.purpose === "copy";
2228
- if (action === "secret.read" && principal.startsWith("user:") && handsBack({
2229
- purpose: call.input.purpose ?? "",
2230
- keepsSensitive: call.input.keepsSensitive === true
2231
- }) || copying && call.input.keepsSensitive !== true) secrets.forEach((secret, i) => {
2232
- if (codes[i] === null && secret.sensitive === true) codes[i] = "sensitive";
2654
+ if (call.vet !== void 0 && !codes.some((code) => code !== null)) codes = await call.vet(d.tx, d.reads);
2655
+ codes = useOnlyRefusals(codes, reader, {
2656
+ action,
2657
+ purpose: call.input.purpose,
2658
+ secrets
2659
+ });
2660
+ codes = sensitiveRefusals(codes, reader, {
2661
+ action,
2662
+ principal,
2663
+ purpose: call.input.purpose,
2664
+ keepsSensitive: call.input.keepsSensitive,
2665
+ secrets,
2666
+ viaReference: call.viaReference
2233
2667
  });
2234
2668
  let first = codes.find((code) => code !== null) ?? null;
2235
- if (first === null && action === "secret.read" && await this.#overBulkLimit(d, principal, secrets.length)) first = "bulk_limit";
2669
+ if (first === null && action === "secret.read" && this.#overBulkLimit(d)) first = "bulk_limit";
2236
2670
  if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, i) => entry(secret, "deny", codes[i] ?? first)));
2237
2671
  };
2672
+ const reads = {
2673
+ environments: [...secrets, ...secrets.map((secret, i) => call.places?.[i] ?? secret)].map((place) => place.environmentId),
2674
+ references: call.references ?? [],
2675
+ ...action === "secret.read" ? { released: {
2676
+ principal,
2677
+ keys: secrets.map((secret) => secret.secretId)
2678
+ } } : {}
2679
+ };
2238
2680
  const intentId = call.remote ? randomUUID() : null;
2239
2681
  let intentSeq = null;
2240
2682
  const outcomeEntry = (secret, item, decision, code) => {
@@ -2253,7 +2695,7 @@ var VaultService = class {
2253
2695
  if (call.remote) {
2254
2696
  const intent = await this.#decide([principal], async (d) => {
2255
2697
  await check(d);
2256
- const at = await this.#now(d.tx);
2698
+ const at = d.at;
2257
2699
  return { seq: (await this.#append(d.tx, [{
2258
2700
  actor: principal,
2259
2701
  action: "key.intent",
@@ -2273,8 +2715,8 @@ var VaultService = class {
2273
2715
  version: secret.version
2274
2716
  }))
2275
2717
  })
2276
- }])).seqStart };
2277
- });
2718
+ }], [], d.reads)).seqStart };
2719
+ }, false, reads);
2278
2720
  if (!intent.ok) return intent;
2279
2721
  intentSeq = intent.seq;
2280
2722
  }
@@ -2291,7 +2733,7 @@ var VaultService = class {
2291
2733
  await decided(d);
2292
2734
  const { vet } = call;
2293
2735
  if (late && vet !== void 0) d.underLock.push(async (locked) => {
2294
- const codes = await vet(locked);
2736
+ const codes = await vet(locked, d.reads);
2295
2737
  const first = codes.find((code) => code !== null);
2296
2738
  if (first === void 0 || first === null) return;
2297
2739
  throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, "deny", codes[item] ?? first)));
@@ -2307,11 +2749,10 @@ var VaultService = class {
2307
2749
  if (expired) throw new Outage(new KekUnavailableError("key operation exceeded its deadline"), entries());
2308
2750
  if (done.length < outcomes.length) throw new Refused(refusal("bad_claim", MESSAGES.bad_claim), entries());
2309
2751
  if (late) {
2310
- await lockLogHead(d.tx);
2752
+ d.reads = await this.#snapshot(d.tx, [principal], reads, true);
2753
+ d.at = this.#at(d.reads);
2311
2754
  await decided(d);
2312
- const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
2313
- const now = await environmentsById(d.tx, places.map((place) => place.environmentId));
2314
- 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")));
2755
+ if (secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]).some((place) => d.reads.environments.get(place.environmentId)?.deleted ?? false)) throw new Refused(refusal("deleted", MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, "deny", "deleted")));
2315
2756
  }
2316
2757
  const released = secrets.map((secret, i) => outcomeEntry(secret, i, "allow", null));
2317
2758
  d.log.push(...released);
@@ -2321,12 +2762,16 @@ var VaultService = class {
2321
2762
  } finally {
2322
2763
  for (const value of done) value.wipe();
2323
2764
  }
2324
- }, late);
2765
+ }, late, reads);
2325
2766
  }
2326
- /** Whether `n` more keys would take `principal` past the bulk limit; counted under their row's lock, so exactly. */
2327
- async #overBulkLimit(d, principal, n) {
2328
- const { count, windowMs } = this.#config.bulkLimit;
2329
- return await releasesSince(d.tx, principal, d.at - windowMs) + n > count;
2767
+ /**
2768
+ * Whether this read's keys would take the reader past the bulk limit: the
2769
+ * distinct keys released to them in the window, these among them. A key
2770
+ * read again exposes nothing new, so it counts once, whatever its version.
2771
+ * Counted under their row's lock, so exactly (`Reads`).
2772
+ */
2773
+ #overBulkLimit(d) {
2774
+ return d.reads.released > this.#config.bulkLimit.count;
2330
2775
  }
2331
2776
  /**
2332
2777
  * Make each holder a reference to its source: the actor writes the holder
@@ -2376,20 +2821,17 @@ var VaultService = class {
2376
2821
  };
2377
2822
  };
2378
2823
  return this.#decide([principal], async (d) => {
2379
- const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
2824
+ const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), input);
2380
2825
  const natures$1 = await natures(d.tx, [...sources.values()].map((source) => source.secretId));
2381
2826
  const environments = await environmentsById(d.tx, items.flatMap((item) => {
2382
2827
  const source = sources.get(item.source.secretId);
2383
2828
  return source === void 0 ? [item.holder.environmentId] : [item.holder.environmentId, source.environmentId];
2384
2829
  }));
2385
- const gone = (place) => environments.get(place.environmentId)?.deleted ?? false;
2386
2830
  const codes = items.map((item) => {
2387
2831
  const source = sources.get(item.source.secretId);
2388
2832
  if (source === void 0 || projectOf.get(item.holder.environmentId) !== item.holder.projectId) return "bad_claim";
2389
2833
  if (source.secretId === item.holder.secretId) return "invalid";
2390
- if (environments.get(item.holder.environmentId)?.owner != null || environments.get(source.environmentId)?.owner != null) return "personal";
2391
- const at = placeIn(source, environments);
2392
- 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);
2834
+ return referenceRefusal(reader, item.holder, source, environments, natures$1.get(source.secretId) ?? "secret");
2393
2835
  });
2394
2836
  const first = codes.find((code) => code !== null) ?? null;
2395
2837
  if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), items.map((item, i) => entry(item, "deny", codes[i] ?? first)));
@@ -2455,7 +2897,7 @@ var VaultService = class {
2455
2897
  return seals.map((seal) => done.has(seal.seq) ? "ended" : null);
2456
2898
  };
2457
2899
  return this.#decide([principal], async (d) => {
2458
- const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
2900
+ const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), input);
2459
2901
  const environments = await environmentsById(d.tx, seals.map((seal) => seal.holder.environmentId));
2460
2902
  const codes = seals.map((seal) => {
2461
2903
  const holder = refuses(actor, "secret.write", placeIn(seal.holder, environments), false);
@@ -2556,7 +2998,7 @@ var VaultService = class {
2556
2998
  */
2557
2999
  async #makeOwner(d, principal, reason) {
2558
3000
  const row = d.members.get(principal);
2559
- const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
3001
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
2560
3002
  if (subject.status === "active" && ownsInstance(subject.live)) return false;
2561
3003
  const tampered = subject.status === "tampered";
2562
3004
  if (tampered && await this.#logged(d.tx, principal) === void 0) return false;
@@ -2610,10 +3052,10 @@ var VaultService = class {
2610
3052
  * What `principal` holds, their row checked first (`#integrity`): a row
2611
3053
  * that fails holds nothing, and is `tampered`.
2612
3054
  */
2613
- async #standing(db, principal, row, at, reports) {
3055
+ async #standing(db, principal, row, at, reports, reads) {
2614
3056
  const none = NOTHING;
2615
- const grants$1 = row === void 0 ? [] : await grants(db, principal);
2616
- const fault = await this.#integrity(db, principal, row, grants$1, reports);
3057
+ const grants$1 = row === void 0 ? [] : reads?.grants.get(principal) ?? await grants(db, principal);
3058
+ const fault = await this.#integrity(db, principal, row, grants$1, reports, reads);
2617
3059
  if (fault !== null) return {
2618
3060
  principal,
2619
3061
  status: "tampered",
@@ -2643,7 +3085,7 @@ var VaultService = class {
2643
3085
  role: grant.role
2644
3086
  }));
2645
3087
  const { role, scope } = roleOf(principal, row);
2646
- const { policy } = await this.#policyAt(db, reports, at);
3088
+ const { policy } = await this.#policyAt(db, reports, at, reads);
2647
3089
  const service = !principal.startsWith("user:");
2648
3090
  return {
2649
3091
  principal,
@@ -2703,13 +3145,17 @@ var VaultService = class {
2703
3145
  /** `access`, as the rows are now. */
2704
3146
  async #read(principal, superseded) {
2705
3147
  const read = async (db, reports) => {
2706
- const [row, held, at] = await Promise.all([
2707
- member(db, principal),
2708
- grants(db, principal),
2709
- this.#now(db)
2710
- ]);
2711
- const fault = await this.#integrity(db, principal, row, held, reports);
2712
- const { policy } = await this.#policyAt(db, reports, at);
3148
+ const reads = await snapshot(db, {
3149
+ principals: [principal],
3150
+ members: true
3151
+ });
3152
+ const [row, held, at] = [
3153
+ reads.members.get(principal),
3154
+ reads.grants.get(principal),
3155
+ this.#at(reads)
3156
+ ];
3157
+ const fault = await this.#integrity(db, principal, row, held, reports, reads);
3158
+ const { policy } = await this.#policyAt(db, reports, at, reads);
2713
3159
  return this.#access(principal, row, held, at, fault !== null, policy);
2714
3160
  };
2715
3161
  const found = [];
@@ -2784,7 +3230,7 @@ var VaultService = class {
2784
3230
  const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
2785
3231
  if (Number.isNaN(expiresAt) || expiresAt !== null && expiresAt <= d.at) throw refused("invalid", "an end date must be in the future");
2786
3232
  }
2787
- const { policy } = await this.#policyAt(d.tx, d.reports, d.at);
3233
+ const { policy } = await this.#policyAt(d.tx, d.reports, d.at, d.reads);
2788
3234
  for (const change of changes) {
2789
3235
  if (change.role !== null && !isRole(change.role, policy)) throw refused("invalid", `no such role: ${change.role}`);
2790
3236
  if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role, policy)) throw refused("invalid", `${change.role} can only be granted on a project`);
@@ -2800,13 +3246,13 @@ var VaultService = class {
2800
3246
  environmentSlug: environment.slug
2801
3247
  };
2802
3248
  });
2803
- const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
3249
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
2804
3250
  if (acting.status === "tampered") throw refused("tampered");
2805
3251
  const manages = where.every((place) => mayManageAccess(acting.live, place));
2806
3252
  if (!manages && !(SERVICE.test(principal) && PERSON.test(actor))) throw refused("not_allowed");
2807
3253
  if (actor === principal && changes.some((change) => change.role !== null)) throw refused("not_allowed", "nobody grants themselves a role: ask another admin");
2808
3254
  const row = d.members.get(principal);
2809
- const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
3255
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
2810
3256
  if (subject.status === "tampered") throw refused("tampered", TAMPERED_SUBJECT);
2811
3257
  const admitted = input.admit === true && row?.status !== "active" ? await this.#admitWith(d, acting, principal, row, input) : void 0;
2812
3258
  if (admitted === void 0 && row?.status === "removed") throw refused("removed");
@@ -2906,7 +3352,7 @@ var VaultService = class {
2906
3352
  const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [accessEntry(actor, "member.add", principal, "deny", input, asked, code)]);
2907
3353
  return this.#decide([actor, principal], async (d) => {
2908
3354
  validateCorrelation(input);
2909
- const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
3355
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
2910
3356
  if (acting.status === "tampered") throw refused("tampered");
2911
3357
  const service = SERVICE.test(principal) && (input.role ?? "member") === "member" && input.scope === void 0;
2912
3358
  if (!runsInstance(acting.live)) await this.#setsUp(d, acting, service, refused);
@@ -2919,7 +3365,7 @@ var VaultService = class {
2919
3365
  };
2920
3366
  if (wanted !== void 0 && wanted.role !== "member" && !principal.startsWith("user:")) throw refused("invalid", "service accounts hold project grants only, never an instance role");
2921
3367
  const row = d.members.get(principal);
2922
- const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
3368
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
2923
3369
  if (subject.status === "tampered") throw refused("tampered", TAMPERED_SUBJECT);
2924
3370
  d.touched.add(principal);
2925
3371
  d.grants.set(principal, [...subject.stored]);
@@ -3020,10 +3466,10 @@ var VaultService = class {
3020
3466
  validateCorrelation(input);
3021
3467
  if (!PRINCIPAL.test(principal)) throw refused("invalid", `not a member: ${shownMember(principal)}`);
3022
3468
  const row = d.members.get(principal);
3023
- const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
3469
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
3024
3470
  if (acting.status === "tampered") throw refused("tampered");
3025
3471
  const holder = acting.live;
3026
- const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
3472
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
3027
3473
  const held = subject.stored;
3028
3474
  if (this.#listed(principal)) throw refused("not_allowed", declaredOwner(principal));
3029
3475
  if (subject.status === "tampered") {
@@ -3127,20 +3573,21 @@ var VaultService = class {
3127
3573
  * no role holds anything but Owner, otherwise whoever can insert rows
3128
3574
  * could unprotect them. Its edits are then unknown, not none.
3129
3575
  */
3130
- async #policyAt(db, reports, at) {
3131
- const entry = await newestAuthentic((limit, before) => newestVaultEntries(db, POLICY, limit, before), this.#check(reports, null));
3576
+ async #policyAt(db, reports, at, reads) {
3577
+ const entry = await newestAuthentic((limit, before) => reads !== void 0 && before === void 0 ? Promise.resolve(reads.newestPolicy === null ? [] : [reads.newestPolicy]) : newestVaultEntries(db, POLICY, limit, before), this.#check(reports, null));
3132
3578
  if (entry === "none") return {
3133
3579
  policy: DEFAULT_POLICY,
3134
3580
  edits: NO_EDITS,
3135
3581
  readable: true
3136
3582
  };
3137
3583
  const edits = typeof entry === "string" ? null : readEdits(JSON.parse(entry.metadata));
3138
- return edits === null ? {
3584
+ if (edits === null) return {
3139
3585
  policy: CLOSED_POLICY,
3140
3586
  edits: NO_EDITS,
3141
3587
  readable: false
3142
- } : {
3143
- policy: await livePolicy(db, edits, at),
3588
+ };
3589
+ return {
3590
+ policy: livePolicy(entry === reads?.newestPolicy ? reads.environments : await environmentsById(db, edits.protected.map((place) => place.environmentId)), edits, at),
3144
3591
  edits,
3145
3592
  readable: true
3146
3593
  };
@@ -3165,11 +3612,11 @@ var VaultService = class {
3165
3612
  const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [entry("deny", { change: describeChange(change) }, code)]);
3166
3613
  return this.#decide([actor], async (d) => {
3167
3614
  validateCorrelation(input);
3168
- const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
3615
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
3169
3616
  if (acting.status === "tampered") throw refused("tampered");
3170
3617
  if (acting.status !== "active") throw refused(acting.status === "removed" ? "removed" : "not_a_member");
3171
- const newest = await newestVaultSeq(d.tx, POLICY);
3172
- const { policy: before, edits, readable } = await this.#policyAt(d.tx, d.reports, d.at);
3618
+ const newest = d.reads.newestPolicy?.seq ?? null;
3619
+ const { policy: before, edits, readable } = await this.#policyAt(d.tx, d.reports, d.at, d.reads);
3173
3620
  if (revision !== void 0 && policyRevision(newest, before) !== revision) throw refused("policy_changed");
3174
3621
  const owner = acting.live.credentialScope == null && ownsInstance(acting.live);
3175
3622
  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");
@@ -3220,12 +3667,12 @@ var VaultService = class {
3220
3667
  at: d.at
3221
3668
  }))].sort((a, b) => a.environmentId < b.environmentId ? -1 : 1);
3222
3669
  } else {
3223
- if (!owner) throw refused("not_allowed", "only an Owner of the whole instance unprotects an environment");
3224
- next.protected = next.protected.filter((place) => !change.environmentIds.includes(place.environmentId));
3670
+ if (!ownsProject(acting.live, change.projectId)) throw refused("not_allowed", "only an Owner of its project, or of the whole instance, unprotects an environment");
3671
+ next.protected = next.protected.filter((place) => place.projectId !== change.projectId || !change.environmentIds.includes(place.environmentId));
3225
3672
  }
3226
3673
  } else throw refused("invalid", "a change is to a role, an instance role, or protection");
3227
3674
  const answer = {
3228
- policy: await livePolicy(d.tx, next, d.at),
3675
+ policy: livePolicy(await environmentsById(d.tx, next.protected.map((place) => place.environmentId)), next, d.at),
3229
3676
  seq: newest === null ? null : Number(newest)
3230
3677
  };
3231
3678
  if (!readable || JSON.stringify(next) !== JSON.stringify(edits)) {
@@ -3257,7 +3704,7 @@ var VaultService = class {
3257
3704
  const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [entry("deny", { serviceAccounts: input.settings?.serviceAccounts ?? null }, code)]);
3258
3705
  return this.#decide([actor], async (d) => {
3259
3706
  validateCorrelation(input);
3260
- const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
3707
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
3261
3708
  if (acting.status === "tampered") throw refused("tampered");
3262
3709
  if (!runsInstance(acting.live)) throw refused("not_allowed", "only admins and owners of the whole instance change its settings");
3263
3710
  if (!isScope(input.settings?.serviceAccounts)) throw refused("invalid", "who sets up service accounts is a scope: { projects, environments }, each all, only or except");
@@ -4226,54 +4673,6 @@ function viaDetail(via, seal) {
4226
4673
  };
4227
4674
  }
4228
4675
  /**
4229
- * Where a decision checks a permission: an environment, with its slug as
4230
- * the store has it when it was read and is in the project claimed;
4231
- * otherwise none, which no grant on a slug matches.
4232
- */
4233
- function placeIn(place, environments) {
4234
- const environment = environments.get(place.environmentId);
4235
- const slug = environment?.projectId === place.projectId ? environment.slug : null;
4236
- return {
4237
- projectId: place.projectId,
4238
- environmentId: place.environmentId,
4239
- environmentSlug: slug
4240
- };
4241
- }
4242
- /**
4243
- * Why `reader` may not do `permission` at `where`, or null if they may. A
4244
- * deleted place is refused before any grant is asked, so no kind of grant,
4245
- * on the project, the environment or every project, can reach into one.
4246
- */
4247
- /**
4248
- * A key of a personal config is its owner's alone (docs/design/forking.md,
4249
- * section 4): no grant or role opens or wraps it, an Owner's included,
4250
- * only its owner, while they read the environment it is a config of,
4251
- * whatever the call is. Nor does any reference reach into or out of one.
4252
- * Undefined for a key of any other environment, decided as ever.
4253
- */
4254
- function personalRefusal(reader, principal, permission, secret, at, environments) {
4255
- const own = environments.get(secret.environmentId);
4256
- const held = at.environmentId === secret.environmentId ? own : environments.get(at.environmentId);
4257
- if (own?.owner == null && held?.owner == null) return void 0;
4258
- if (own !== held || principal !== own?.owner || own.parentId === null) return "personal";
4259
- const parent = placeIn({
4260
- projectId: secret.projectId,
4261
- environmentId: own.parentId
4262
- }, environments);
4263
- const gone = own.deleted || (environments.get(own.parentId)?.deleted ?? true);
4264
- const refused = refuses(reader, "secret.use", parent, gone) === null ? null : refuses(reader, "secret.read", parent, gone);
4265
- if (refused !== null) return refused;
4266
- return withinCredential(reader.live, permission === "secret.read" ? "secret.use" : permission, parent) ? null : "no_grant";
4267
- }
4268
- function refuses(reader, permission, where, deleted) {
4269
- if (reader.status === "tampered") return "tampered";
4270
- if (reader.status === "removed") return "removed";
4271
- if (reader.status === "unknown") return "not_a_member";
4272
- if (deleted) return "deleted";
4273
- if (allows(reader.live, permission, where)) return null;
4274
- return allows(reader.all, permission, where) ? "expired" : "no_grant";
4275
- }
4276
- /**
4277
4676
  * An entry about a key. A new secret's row is not committed when its key is
4278
4677
  * wrapped, so a wrap names the secret in its payload; the others name it.
4279
4678
  */
@@ -4382,7 +4781,7 @@ function oneRead(group) {
4382
4781
  }
4383
4782
  /** Whether `entry` changes a member's access: what their row's `access_seq` names. */
4384
4783
  function isAccessEntry(entry) {
4385
- return entry.decision === "allow" && entry.subjectPrincipal !== void 0 && entry.subjectPrincipal !== null && ACCESS_ACTIONS.includes(entry.action);
4784
+ return entry.decision === "allow" && entry.subjectPrincipal !== void 0 && entry.subjectPrincipal !== null && ACCESS_ACTIONS$1.includes(entry.action);
4386
4785
  }
4387
4786
  /** An entry about a member's access. */
4388
4787
  function accessEntry(actor, action, principal, decision, correlation, detail, code = null) {
@@ -4486,8 +4885,7 @@ function readEdits(stored) {
4486
4885
  * keeps its slug, which a scoped instance role's filters read. The edits
4487
4886
  * keep them all: no write is needed.
4488
4887
  */
4489
- async function livePolicy(db, edits, at) {
4490
- const rows = await environmentsById(db, edits.protected.map((place) => place.environmentId));
4888
+ function livePolicy(rows, edits, at) {
4491
4889
  const live = edits.protected.filter((place) => {
4492
4890
  const row = rows.get(place.environmentId);
4493
4891
  return row === void 0 ? at - place.at < UNWRITTEN_FORK_MS : !row.deleted;