@coffre/vault 0.6.2 → 0.7.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,5 +1,5 @@
1
1
  import { a as verifyCheckpoint, i as checkpointMessage, n as KekUnavailableError, o as derivedSigningKey, r as awsKms, s as resolveVaultConfig, t as KekBadClaimError } from "./src-CQJS3OZZ.js";
2
- import { n as prepareVault, t as openVault } from "./vault-ihE3Yc4a.js";
2
+ import { n as prepareVault, t as openVault } from "./vault-jbn0I41Z.js";
3
3
  import { createDatabase } from "@coffre/db";
4
4
  import { HyperdrivePool, postgres } from "@coffre/db/hyperdrive";
5
5
  import { WorkerEntrypoint } from "cloudflare:workers";
package/dist/node.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { a as verifyCheckpoint, i as checkpointMessage, n as KekUnavailableError, o as derivedSigningKey, r as awsKms, s as resolveVaultConfig, t as KekBadClaimError } from "./src-CQJS3OZZ.js";
2
- import { n as prepareVault, t as openVault } from "./vault-ihE3Yc4a.js";
2
+ import { n as prepareVault, t as openVault } from "./vault-jbn0I41Z.js";
3
3
  import { chmodSync, existsSync, lstatSync, rmSync } from "node:fs";
4
4
  import { Agent, createServer, request } from "node:http";
5
5
  import { openDatabase } from "@coffre/db/connect";
@@ -2,14 +2,14 @@ import { ACCESS_ACTIONS, checkpointMessage, checkpointVerifier, describeAccessFa
2
2
  import { DEK_BYTES, KEY_CHECK, KEY_CHECK_CONTEXT, KEY_CHECK_VALUE, KekBadClaimError, KekCancelledError, KekUnavailableError, LocalKekProvider } from "@coffre/core/kek";
3
3
  import { createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
4
4
  import { tablesOf } from "@coffre/db";
5
- import { EVERYWHERE, NOTHING, allows, assignableToEnvironment, givesService, grantKind, isCredentialScope, isInstanceRole, isRole, isScope, managesService, mayManageAccess, normalScope, ownsInstance, runsInstance, setsUpServices, unscoped } from "@coffre/core/access";
5
+ import { EVERYWHERE, NOTHING, allows, assignableToEnvironment, givesService, grantKind, isCredentialScope, isInstanceRole, isRole, isScope, managesService, mayManageAccess, normalScope, ownsInstance, runsInstance, setsUpServices, unscoped, withinCredential } from "@coffre/core/access";
6
6
  import { GENESIS_HASH, deriveLogKey, verifyEntries } from "@coffre/core/audit";
7
- import { checkContext } from "@coffre/core/envelope";
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 } from "@coffre/db/log";
12
- import { and, asc, count, desc, eq, gt, gte, inArray, isNull, lt, lte, or, sql } from "drizzle-orm";
11
+ import { LOOK_BACK, appendEntries, entryColumns, lockLogHead, newestAuthentic, newestVaultEntries, withinBytes } from "@coffre/db/log";
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([
15
15
  "secret.read",
@@ -49,12 +49,14 @@ var KeyAccounting = class {
49
49
  const parsed = parseOutcome(entry);
50
50
  if (typeof parsed === "string") return broken(parsed);
51
51
  const intent = this.#intents.get(parsed.relatedSeq);
52
- const key = intent?.keys[parsed.item];
53
- if (intent === void 0 || key === void 0 || intent.seen.has(parsed.item)) return broken("key outcome does not identify one item of its intent");
54
- if (parsed.intentId !== intent.intentId || entry.actor !== intent.entry.actor || entry.action !== intent.operation) return broken("key outcome belongs to another operation");
55
- if (parsed.secretId !== key.secretId || parsed.version !== key.version || parsed.subject !== key.subject) return broken("key outcome does not match its intended secret");
56
- intent.seen.add(parsed.item);
57
- if (intent.seen.size === intent.keys.length) this.#intents.delete(intent.entry.seq);
52
+ for (const outcome of parsed.items) {
53
+ const key = intent?.keys[outcome.item];
54
+ if (intent === void 0 || key === void 0 || intent.seen.has(outcome.item)) return broken("key outcome does not identify one item of its intent");
55
+ if (parsed.intentId !== intent.intentId || entry.actor !== intent.entry.actor || entry.action !== intent.operation) return broken("key outcome belongs to another operation");
56
+ if (outcome.secretId !== key.secretId || outcome.version !== key.version || outcome.subject !== key.subject) return broken("key outcome does not match its intended secret");
57
+ intent.seen.add(outcome.item);
58
+ }
59
+ if (intent !== void 0 && intent.seen.size === intent.keys.length) this.#intents.delete(intent.entry.seq);
58
60
  }
59
61
  }
60
62
  /** The first fault, or the batches still under way at `at`: live calls can finish after the snapshot. */
@@ -109,19 +111,32 @@ function parseIntent(entry) {
109
111
  function parseOutcome(entry) {
110
112
  const detail = payload(entry.metadata);
111
113
  if (detail === null) return "key outcome has no valid accounting payload";
112
- const { intent, item, version, subject } = detail;
114
+ const { intent } = detail;
113
115
  if (typeof intent !== "string" || entry.relatedSeq === null) return "key outcome has no intent identity";
114
- if (typeof item !== "number" || !Number.isSafeInteger(item) || item < 0) return "key outcome has no valid item number";
115
- const secretId = entry.secretId ?? detail.secretId;
116
- if (typeof secretId !== "string" || typeof subject !== "string") return "key outcome has no secret identity";
117
- if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key outcome has no valid version";
116
+ const many = detail.secrets;
117
+ if (many !== void 0 && (!Array.isArray(many) || many.length === 0)) return "key outcome has no valid item list";
118
+ const listed = many === void 0 ? [{
119
+ ...detail,
120
+ secretId: entry.secretId ?? detail.secretId
121
+ }] : many.map(record);
122
+ const items = [];
123
+ for (const one of listed) {
124
+ if (one === null) return "key outcome has no valid item list";
125
+ const { item, secretId, version, subject } = one;
126
+ if (typeof item !== "number" || !Number.isSafeInteger(item) || item < 0) return "key outcome has no valid item number";
127
+ if (typeof secretId !== "string" || typeof subject !== "string") return "key outcome has no secret identity";
128
+ if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key outcome has no valid version";
129
+ items.push({
130
+ item,
131
+ secretId,
132
+ version,
133
+ subject
134
+ });
135
+ }
118
136
  return {
119
137
  intentId: intent,
120
138
  relatedSeq: entry.relatedSeq,
121
- item,
122
- secretId,
123
- version,
124
- subject
139
+ items
125
140
  };
126
141
  }
127
142
  function payload(text) {
@@ -274,12 +289,6 @@ function at(db, place) {
274
289
  if (place.projectId !== null) return and(eq(vaultGrants.projectId, place.projectId), isNull(vaultGrants.environmentId));
275
290
  return and(isNull(vaultGrants.projectId), isNull(vaultGrants.environmentId), place.environmentSlug === null ? isNull(vaultGrants.environmentSlug) : eq(vaultGrants.environmentSlug, place.environmentSlug));
276
291
  }
277
- /**
278
- * Each of these environments that exists, by id, with its project, its
279
- * slug, what grants on one slug in every project match, and whether it was
280
- * deleted, alone or with its project: then no key under it opens, or is
281
- * wrapped, whatever grant would cover it.
282
- */
283
292
  async function environmentsById(db, ids) {
284
293
  if (ids.length === 0) return /* @__PURE__ */ new Map();
285
294
  const { environments, projects } = tablesOf(db);
@@ -287,7 +296,10 @@ async function environmentsById(db, ids) {
287
296
  id: environments.id,
288
297
  projectId: environments.projectId,
289
298
  slug: environments.slug,
290
- deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug)))
299
+ owner: environments.owner,
300
+ parentId: environments.parentId,
301
+ deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug))),
302
+ archived: truth(or(isNotNull(environments.archivedAt), isNotNull(projects.archivedAt)))
291
303
  }).from(environments).innerJoin(projects, eq(projects.id, environments.projectId)).where(inArray(environments.id, [...new Set(ids)]));
292
304
  return new Map(rows.map(({ id, ...environment }) => [id, environment]));
293
305
  }
@@ -348,11 +360,16 @@ async function everyProjectHolders(db) {
348
360
  }
349
361
  /** The action of an entry that releases a key, which the bulk limit counts. */
350
362
  const RELEASE = "secret.read";
351
- /** How many keys the vault has released to `principal` since `after`. */
363
+ /**
364
+ * How many keys the vault has released to `principal` since `after`: one
365
+ * for an entry about one secret, and as many as it lists for one about
366
+ * several (`secrets`), a read logged once per place.
367
+ */
352
368
  async function releasesSince(db, principal, after) {
353
369
  const { auditLog } = tablesOf(db);
354
- const [{ n }] = await db.select({ n: count() }).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.actor, principal), eq(auditLog.action, RELEASE), eq(auditLog.decision, "allow"), gt(auditLog.occurredAt, after)));
355
- return Number(n);
370
+ const listed = engineOf(db) === "postgres" ? sql`jsonb_array_length((${auditLog.metadata})::jsonb -> 'secrets')` : sql`json_array_length(${auditLog.metadata}, '$.secrets')`;
371
+ const [{ n }] = await db.select({ n: sql`SUM(COALESCE(${listed}, 1))` }).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.actor, principal), eq(auditLog.action, RELEASE), eq(auditLog.decision, "allow"), gt(auditLog.occurredAt, after)));
372
+ return Number(n ?? 0);
356
373
  }
357
374
  /** Rows of the log as the chain's codec reads them: `author` and `decision` are checked by the table. */
358
375
  function stored(rows) {
@@ -361,12 +378,17 @@ function stored(rows) {
361
378
  /** Up to `limit` entries of either author from `fromSeq`, oldest first: the chain as it runs. */
362
379
  async function entriesFrom(db, fromSeq, limit) {
363
380
  const { auditLog } = tablesOf(db);
364
- return stored(await db.select(entryColumns(db)).from(auditLog).where(gte(auditLog.seq, fromSeq)).orderBy(asc(auditLog.seq)).limit(limit));
381
+ const batch = await withinBytes(db, gte(auditLog.seq, fromSeq), limit);
382
+ if (batch === null) return [];
383
+ return stored(await db.select(entryColumns(db)).from(auditLog).where(and(gte(auditLog.seq, batch.from), lte(auditLog.seq, batch.to))).orderBy(asc(auditLog.seq)));
365
384
  }
366
385
  /** Up to `limit` of the vault's entries after `afterSeq`, oldest first: every one, whatever its action or decision. */
367
386
  async function vaultEntriesAfter(db, afterSeq, limit) {
368
387
  const { auditLog } = tablesOf(db);
369
- return stored(await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), gt(auditLog.seq, afterSeq))).orderBy(asc(auditLog.seq)).limit(limit));
388
+ const vault = eq(auditLog.author, "vault");
389
+ const batch = await withinBytes(db, and(vault, gt(auditLog.seq, afterSeq)), limit);
390
+ if (batch === null) return [];
391
+ return stored(await db.select(entryColumns(db)).from(auditLog).where(and(vault, gte(auditLog.seq, batch.from), lte(auditLog.seq, batch.to))).orderBy(asc(auditLog.seq)));
370
392
  }
371
393
  /** The hash of the entry at each of `seqs` the log holds, by seq. */
372
394
  async function hashesAt(db, seqs) {
@@ -552,7 +574,7 @@ const UNVERIFIED = {
552
574
  function further(a, b) {
553
575
  return b.nextSeq > a.nextSeq ? b : a;
554
576
  }
555
- /** Entries read at once: a few megabytes, well within a Worker's memory, and a fifth of the round trips of 1000. */
577
+ /** Entries read at once, at most, and no more than `BATCH_BYTES` of what they say: well within a Worker's memory, and a fifth of the round trips of 1000. */
556
578
  const VERIFY_BATCH = 5e3;
557
579
  /**
558
580
  * Check the chain, a batch in memory at a time, and return where it is now
@@ -611,7 +633,6 @@ async function verifyChain(db, logKeys, shown, anchor, onBatch = () => {}) {
611
633
  vaultEntries: verified.vaultEntries + result.authenticated,
612
634
  vaultKeys: moved.keys
613
635
  };
614
- if (batch.length < 5e3) break;
615
636
  }
616
637
  return {
617
638
  verification: {
@@ -1061,6 +1082,7 @@ const MESSAGES = {
1061
1082
  bad_claim: "the key does not belong to this secret",
1062
1083
  deleted: "this project or environment was deleted",
1063
1084
  ended: "the reference read through was ended",
1085
+ personal: "a personal config's values are its owner's alone",
1064
1086
  not_allowed: "not allowed to change this",
1065
1087
  last_owner: "the last Owner of the whole instance stays one: make someone else an Owner first",
1066
1088
  invalid: "not something the rules allow",
@@ -1161,7 +1183,7 @@ var VaultService = class {
1161
1183
  underLock: []
1162
1184
  };
1163
1185
  const result = await decide(d);
1164
- const entries = [...reports, ...d.log];
1186
+ const entries = [...reports, ...readsByPlace(d.log)];
1165
1187
  let at = d.at;
1166
1188
  const accessSeq = /* @__PURE__ */ new Map();
1167
1189
  if (entries.length > 0) {
@@ -1184,7 +1206,7 @@ var VaultService = class {
1184
1206
  };
1185
1207
  } catch (error) {
1186
1208
  if (!(error instanceof Refused || error instanceof Outage)) throw error;
1187
- const entries = [...reports, ...error.entries];
1209
+ const entries = [...reports, ...readsByPlace(error.entries)];
1188
1210
  if (entries.length > 0) await this.#db.transaction(async (tx) => {
1189
1211
  await boundLockWaits(tx, LOCK_TIMEOUT_MS);
1190
1212
  await this.#append(tx, entries);
@@ -1507,6 +1529,7 @@ var VaultService = class {
1507
1529
  const { principal } = input;
1508
1530
  validateText(input.purpose);
1509
1531
  const vias = input.items.map((item) => validateVia(item.via));
1532
+ const throughs = input.items.map((item) => validateThrough(item.through));
1510
1533
  const loaded = await this.#versions(input, "secret.read", { purpose: input.purpose });
1511
1534
  if (!loaded.ok) return loaded;
1512
1535
  const items = loaded.versions.map((version) => ({
@@ -1515,22 +1538,39 @@ var VaultService = class {
1515
1538
  }));
1516
1539
  validateItems(items, "wrapped");
1517
1540
  const sealed = await this.#referencesAt(vias);
1541
+ const forks = await environmentsById(this.#db, throughs.flatMap((through) => through === null ? [] : [through]));
1542
+ const viaOk = items.map((item, i) => vias[i] === null || sealed[i] !== null && sealed[i].source.secretId === item.secret.secretId && item.current);
1543
+ const throughOk = items.map((item, i) => {
1544
+ const through = throughs[i];
1545
+ if (through === null) return true;
1546
+ if (!viaOk[i]) return false;
1547
+ const fork = forks.get(through);
1548
+ const decidedAt = sealed[i]?.holder ?? item.secret;
1549
+ return fork !== void 0 && !fork.deleted && !fork.archived && fork.projectId === decidedAt.projectId && through !== decidedAt.environmentId;
1550
+ });
1518
1551
  const versionIds = new Map(items.map((item) => [item.secret, item.id]));
1519
1552
  const viaOf = new Map(items.map((item, i) => [item.secret, {
1520
1553
  via: vias[i],
1521
- sealed: sealed[i]
1554
+ sealed: sealed[i],
1555
+ through: throughs[i],
1556
+ throughOk: throughOk[i]
1522
1557
  }]));
1523
1558
  const entry = (secret, decision, code) => {
1524
- const { via, sealed: seal } = viaOf.get(secret);
1559
+ const { via, sealed: seal, through, throughOk: ok } = viaOf.get(secret);
1525
1560
  return {
1526
1561
  ...keyEntry("secret.read", principal, secret, decision, code, input, {
1527
1562
  purpose: input.purpose,
1528
- ...via === null ? {} : viaDetail(via, seal)
1563
+ ...via === null ? {} : viaDetail(via, seal),
1564
+ ...through === null ? {} : ok ? throughDetail(through, forks.get(through), via !== null) : { through: {
1565
+ environmentId: through,
1566
+ refused: true
1567
+ } }
1529
1568
  }),
1530
1569
  secretVersionId: versionIds.get(secret)
1531
1570
  };
1532
1571
  };
1533
- if (items.some((item, i) => vias[i] !== null && (sealed[i] === null || sealed[i].source.secretId !== item.secret.secretId || !item.current))) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")));
1572
+ if (viaOk.includes(false)) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")));
1573
+ if (throughOk.includes(false)) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")));
1534
1574
  const ends = sealed.flatMap((seal) => seal === null ? [] : [seal.seq]);
1535
1575
  const remote = items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
1536
1576
  return this.#keys({
@@ -1546,8 +1586,8 @@ var VaultService = class {
1546
1586
  remote,
1547
1587
  entry,
1548
1588
  input
1549
- }, () => items.map(({ secret, wrapped }) => async (operation) => {
1550
- const key = await this.#open(wrapped, secret, operation);
1589
+ }, (contextOf) => items.map(({ secret, wrapped }) => async (operation) => {
1590
+ const key = await this.#open(wrapped, contextOf(secret), operation);
1551
1591
  return key && {
1552
1592
  key,
1553
1593
  wipe: () => key.fill(0)
@@ -1574,11 +1614,11 @@ var VaultService = class {
1574
1614
  remote: this.#remote(this.#config.keks.primary),
1575
1615
  entry,
1576
1616
  input
1577
- }, () => items.map(({ secret, key }) => async (operation) => {
1617
+ }, (contextOf) => items.map(({ secret, key }) => async (operation) => {
1578
1618
  const dek = Buffer.from(key, "base64");
1579
1619
  try {
1580
1620
  return {
1581
- wrapped: serialisable(await this.#config.keks.wrap(dek, context(secret), operation)),
1621
+ wrapped: serialisable(await this.#config.keks.wrap(dek, contextOf(secret), operation)),
1582
1622
  wipe: () => {}
1583
1623
  };
1584
1624
  } finally {
@@ -1617,12 +1657,12 @@ var VaultService = class {
1617
1657
  remote,
1618
1658
  entry,
1619
1659
  input
1620
- }, () => items.map(({ secret, wrapped }) => async (operation) => {
1621
- const key = await this.#open(wrapped, secret, operation);
1660
+ }, (contextOf) => items.map(({ secret, wrapped }) => async (operation) => {
1661
+ const key = await this.#open(wrapped, contextOf(secret), operation);
1622
1662
  if (key === null) return null;
1623
1663
  try {
1624
1664
  return {
1625
- wrapped: serialisable(await this.#config.keks.wrap(key, context(secret), operation)),
1665
+ wrapped: serialisable(await this.#config.keks.wrap(key, contextOf(secret), operation)),
1626
1666
  wipe: () => {}
1627
1667
  };
1628
1668
  } finally {
@@ -1679,9 +1719,9 @@ var VaultService = class {
1679
1719
  * that is not what it says. A key service that cannot answer is an outage,
1680
1720
  * not a verdict on the claim, and throws.
1681
1721
  */
1682
- async #open(wrapped, secret, operation) {
1722
+ async #open(wrapped, context, operation) {
1683
1723
  try {
1684
- return await this.#config.keks.unwrap(unwrappable(wrapped), context(secret), operation);
1724
+ return await this.#config.keks.unwrap(unwrappable(wrapped), context, operation);
1685
1725
  } catch (error) {
1686
1726
  if (error instanceof KekBadClaimError) return null;
1687
1727
  throw error;
@@ -1705,12 +1745,21 @@ var VaultService = class {
1705
1745
  if (wrongKek !== null) return this.#decide([], async () => {
1706
1746
  throw new Refused(refusal("wrong_kek", wrongKek), secrets.map((secret) => entry(secret, "deny", "wrong_kek")));
1707
1747
  });
1748
+ const owners = /* @__PURE__ */ new Map();
1708
1749
  const check = async (d) => {
1709
1750
  const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), call.input);
1710
1751
  const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
1711
1752
  const environments = await environmentsById(d.tx, [...secrets, ...at].map((place) => place.environmentId));
1753
+ const parents = [...environments.values()].flatMap((environment) => environment.owner === null || environment.parentId === null ? [] : [environment.parentId]);
1754
+ for (const [id, parent] of await environmentsById(d.tx, parents)) environments.set(id, parent);
1712
1755
  const gone = (place) => environments.get(place.environmentId)?.deleted ?? false;
1713
- const codes = secrets.map((secret, i) => refuses(reader, call.permission, placeIn(at[i], environments), gone(secret) || gone(at[i])));
1756
+ owners.clear();
1757
+ for (const [id, environment] of environments) if (environment.owner !== null) owners.set(id, ownerKey(environment.owner));
1758
+ const codes = secrets.map((secret, i) => {
1759
+ const personal = personalRefusal(reader, principal, call.permission, secret, at[i], environments);
1760
+ if (personal !== void 0) return personal;
1761
+ return refuses(reader, call.permission, placeIn(at[i], environments), gone(secret) || gone(at[i]));
1762
+ });
1714
1763
  if (call.vet !== void 0 && !codes.some((code) => code !== null)) (await call.vet(d.tx)).forEach((code, i) => codes[i] = code);
1715
1764
  let first = codes.find((code) => code !== null) ?? null;
1716
1765
  if (first === null && action === "secret.read" && await this.#overBulkLimit(d, principal, secrets.length)) first = "bulk_limit";
@@ -1774,7 +1823,7 @@ var VaultService = class {
1774
1823
  if (first === void 0 || first === null) return;
1775
1824
  throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, "deny", codes[item] ?? first)));
1776
1825
  });
1777
- const { outcomes, expired } = await settle(operations(), this.#prepared.options.keyBudgetMs);
1826
+ const { outcomes, expired } = await settle(operations((secret) => context(secret, owners.get(secret.environmentId))), this.#prepared.options.keyBudgetMs);
1778
1827
  const done = outcomes.flatMap((outcome) => outcome.ok ? [outcome.value] : []);
1779
1828
  try {
1780
1829
  const entries = () => secrets.map((secret, i) => outcomeEntry(secret, i, "deny", outcomes[i].ok ? "withheld" : outcomes[i].code));
@@ -1861,6 +1910,7 @@ var VaultService = class {
1861
1910
  const source = sources.get(item.source.secretId);
1862
1911
  if (source === void 0 || projectOf.get(item.holder.environmentId) !== item.holder.projectId) return "bad_claim";
1863
1912
  if (source.secretId === item.holder.secretId) return "invalid";
1913
+ if (environments.get(item.holder.environmentId)?.owner != null || environments.get(source.environmentId)?.owner != null) return "personal";
1864
1914
  return refuses(reader, "secret.write", placeIn(item.holder, environments), gone(item.holder)) ?? refuses(reader, "secret.read", placeIn(source, environments), gone(source));
1865
1915
  });
1866
1916
  const first = codes.find((code) => code !== null) ?? null;
@@ -2965,6 +3015,7 @@ var VaultService = class {
2965
3015
  let keys = [];
2966
3016
  for (let after = -1n;;) {
2967
3017
  const batch = await vaultEntriesAfter(tx, after, VERIFY_BATCH);
3018
+ if (batch.length === 0) return { ok: true };
2968
3019
  for (const entry of batch) {
2969
3020
  const result = verifyEntries([entry], {
2970
3021
  startSeq: entry.seq,
@@ -2985,7 +3036,6 @@ var VaultService = class {
2985
3036
  };
2986
3037
  keys = moved.keys;
2987
3038
  for (const entry of batch) findings.add(entry);
2988
- if (batch.length < 5e3) return { ok: true };
2989
3039
  after = batch[batch.length - 1].seq;
2990
3040
  }
2991
3041
  }
@@ -3211,6 +3261,30 @@ function validateVia(via) {
3211
3261
  seq: via.seq
3212
3262
  };
3213
3263
  }
3264
+ /** A read's fork, checked for shape: null for none. */
3265
+ function validateThrough(through) {
3266
+ if (through === void 0 || through === null) return null;
3267
+ if (typeof through !== "string" || !UUID.test(through)) throw new Error("a fork's id must be a lowercase UUID");
3268
+ return through;
3269
+ }
3270
+ /**
3271
+ * How a read of an inherited value is logged: the fork it was read in,
3272
+ * `through`, and as `also`, which puts it in the fork's log too, unless it
3273
+ * was read through the parent's reference. The fork decides nothing: the
3274
+ * reader's grants where the value is held do, as for any read.
3275
+ */
3276
+ function throughDetail(through, fork, viaReference) {
3277
+ return {
3278
+ through: {
3279
+ environmentId: through,
3280
+ ...fork === void 0 ? {} : { slug: fork.slug }
3281
+ },
3282
+ ...viaReference ? {} : { also: {
3283
+ projectId: fork?.projectId ?? null,
3284
+ environmentId: through
3285
+ } }
3286
+ };
3287
+ }
3214
3288
  /**
3215
3289
  * How a read through a reference is logged: the way in, and `also`, the
3216
3290
  * holder's place, which puts the read in the holder's project's log too.
@@ -3254,6 +3328,26 @@ function placeIn(place, environments) {
3254
3328
  * deleted place is refused before any grant is asked, so no kind of grant,
3255
3329
  * on the project, the environment or every project, can reach into one.
3256
3330
  */
3331
+ /**
3332
+ * A key of a personal config is its owner's alone (docs/design/forking.md,
3333
+ * section 4): no grant or role opens or wraps it, an Owner's included,
3334
+ * only its owner, while they read the environment it is a config of,
3335
+ * whatever the call is. Nor does any reference reach into or out of one.
3336
+ * Undefined for a key of any other environment, decided as ever.
3337
+ */
3338
+ function personalRefusal(reader, principal, permission, secret, at, environments) {
3339
+ const own = environments.get(secret.environmentId);
3340
+ const held = at.environmentId === secret.environmentId ? own : environments.get(at.environmentId);
3341
+ if (own?.owner == null && held?.owner == null) return void 0;
3342
+ if (own !== held || principal !== own?.owner || own.parentId === null) return "personal";
3343
+ const parent = placeIn({
3344
+ projectId: secret.projectId,
3345
+ environmentId: own.parentId
3346
+ }, environments);
3347
+ const refused = refuses(reader, "secret.read", parent, own.deleted || (environments.get(own.parentId)?.deleted ?? true));
3348
+ if (refused !== null) return refused;
3349
+ return withinCredential(reader.live, permission, parent) ? null : "no_grant";
3350
+ }
3257
3351
  function refuses(reader, permission, where, deleted) {
3258
3352
  if (reader.status === "tampered") return "tampered";
3259
3353
  if (reader.status === "removed") return "removed";
@@ -3286,6 +3380,89 @@ function keyEntry(action, principal, secret, decision, code, correlation, detail
3286
3380
  })
3287
3381
  };
3288
3382
  }
3383
+ /** What every secret of one read at a place shares, said once in its entry rather than in each of its `secrets`. */
3384
+ const READ_SHARED = [
3385
+ "purpose",
3386
+ "intent",
3387
+ "credentialId",
3388
+ "through"
3389
+ ];
3390
+ /**
3391
+ * A decision's `secret.read` entries, made one per secret, as the log keeps
3392
+ * them: one per place it was decided at, the environment read and, through
3393
+ * a reference, the holder's, or the fork that inherits it (`also`,
3394
+ * `through`). An entry about several secrets lists
3395
+ * them in `secrets`, each with what its own entry would have said: its
3396
+ * `secretId`, `secretVersionId`, `subject`, `version`, and its `via`, holder
3397
+ * (`also.secretId`) and intent `item` when it has them. An entry about one
3398
+ * secret is as it always was. A `coffre run` of a hundred keys is one entry,
3399
+ * not a hundred; what it decided, and for which keys, is the same. Other
3400
+ * entries pass through, in order.
3401
+ */
3402
+ function readsByPlace(entries) {
3403
+ const groups = /* @__PURE__ */ new Map();
3404
+ const out = [];
3405
+ for (const entry of entries) {
3406
+ if (entry.action !== "secret.read") {
3407
+ out.push(entry);
3408
+ continue;
3409
+ }
3410
+ const { also, through } = JSON.parse(entry.metadata ?? "{}");
3411
+ const place = JSON.stringify([
3412
+ entry.actor,
3413
+ entry.decision,
3414
+ entry.code ?? null,
3415
+ entry.projectId ?? null,
3416
+ entry.environmentId ?? null,
3417
+ entry.operationId ?? null,
3418
+ entry.requestId ?? null,
3419
+ String(entry.relatedSeq ?? null),
3420
+ also?.projectId ?? null,
3421
+ also?.environmentId ?? null,
3422
+ through ?? null
3423
+ ]);
3424
+ const group = groups.get(place);
3425
+ if (group !== void 0) {
3426
+ group.push(entry);
3427
+ continue;
3428
+ }
3429
+ const fresh = [entry];
3430
+ groups.set(place, fresh);
3431
+ out.push(fresh);
3432
+ }
3433
+ return out.map((item) => Array.isArray(item) ? oneRead(item) : item);
3434
+ }
3435
+ /** Entries of one read at one place as one entry: `readsByPlace`. */
3436
+ function oneRead(group) {
3437
+ const [first] = group;
3438
+ if (group.length === 1) return first;
3439
+ const details = group.map((entry) => JSON.parse(entry.metadata ?? "{}"));
3440
+ const shared = Object.fromEntries(READ_SHARED.flatMap((field) => details[0][field] === void 0 ? [] : [[field, details[0][field]]]));
3441
+ const beside = details[0].also;
3442
+ const secrets = group.map((entry, i) => {
3443
+ const own = Object.fromEntries(Object.entries(details[i]).filter(([field]) => !READ_SHARED.includes(field) && field !== "also"));
3444
+ const holder = details[i].also?.secretId;
3445
+ return {
3446
+ ...entry.secretId == null ? {} : { secretId: entry.secretId },
3447
+ ...entry.secretVersionId == null ? {} : { secretVersionId: entry.secretVersionId },
3448
+ ...own,
3449
+ ...holder === void 0 ? {} : { also: { secretId: holder } }
3450
+ };
3451
+ });
3452
+ return {
3453
+ ...first,
3454
+ secretId: null,
3455
+ secretVersionId: null,
3456
+ metadata: JSON.stringify({
3457
+ ...shared,
3458
+ ...beside === void 0 ? {} : { also: {
3459
+ projectId: beside.projectId,
3460
+ environmentId: beside.environmentId
3461
+ } },
3462
+ secrets
3463
+ })
3464
+ };
3465
+ }
3289
3466
  /** Whether `entry` changes a member's access: what their row's `access_seq` names. */
3290
3467
  function isAccessEntry(entry) {
3291
3468
  return entry.decision === "allow" && entry.subjectPrincipal !== void 0 && entry.subjectPrincipal !== null && ACCESS_ACTIONS.includes(entry.action);
@@ -3366,11 +3543,13 @@ function view(grant) {
3366
3543
  function iso(ms) {
3367
3544
  return new Date(ms).toISOString();
3368
3545
  }
3369
- function context(secret) {
3546
+ /** A secret's binding context: its ids, and a personal config's owner, hashed, as the decision read it. */
3547
+ function context(secret, owner) {
3370
3548
  return {
3371
3549
  projectId: secret.projectId,
3372
3550
  environmentId: secret.environmentId,
3373
- secretId: secret.secretId
3551
+ secretId: secret.secretId,
3552
+ ...owner === void 0 ? {} : { owner }
3374
3553
  };
3375
3554
  }
3376
3555
  function unwrappable(wrapped) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/vault",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "coffre's vault: keys, grants and members, in the database the app uses, as a Cloudflare Worker or on Node.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,8 +36,8 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "drizzle-orm": "0.45.2",
39
- "@coffre/core": "0.6.2",
40
- "@coffre/db": "0.6.2"
39
+ "@coffre/core": "0.7.0",
40
+ "@coffre/db": "0.7.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "26.1.1",
package/src/accounting.ts CHANGED
@@ -5,7 +5,8 @@ const KEY_ACTIONS = new Set(['secret.read', 'key.wrap', 'key.rewrap']);
5
5
 
6
6
  type Item = { item: number; secretId: string; version: number; subject: string };
7
7
  type ParsedIntent = { intentId: string; operation: string; expiresAt: number; keys: Item[] };
8
- type ParsedOutcome = Item & { intentId: string; relatedSeq: bigint };
8
+ /** An outcome entry: one item of its intent, or several, for a read logged once per place. */
9
+ type ParsedOutcome = { intentId: string; relatedSeq: bigint; items: Item[] };
9
10
  type Intent = ParsedIntent & { entry: StoredEntry; seen: Set<number> };
10
11
  type Accounting = { ok: true; pending: number } | Extract<LogVerification, { ok: false }>;
11
12
 
@@ -36,19 +37,21 @@ export class KeyAccounting {
36
37
  const parsed = parseOutcome(entry);
37
38
  if (typeof parsed === 'string') return broken(parsed);
38
39
  const intent = this.#intents.get(parsed.relatedSeq);
39
- const key = intent?.keys[parsed.item];
40
- if (intent === undefined || key === undefined || intent.seen.has(parsed.item)) {
41
- return broken('key outcome does not identify one item of its intent');
40
+ for (const outcome of parsed.items) {
41
+ const key = intent?.keys[outcome.item];
42
+ if (intent === undefined || key === undefined || intent.seen.has(outcome.item)) {
43
+ return broken('key outcome does not identify one item of its intent');
44
+ }
45
+ if (parsed.intentId !== intent.intentId || entry.actor !== intent.entry.actor || entry.action !== intent.operation) {
46
+ return broken('key outcome belongs to another operation');
47
+ }
48
+ if (outcome.secretId !== key.secretId || outcome.version !== key.version || outcome.subject !== key.subject) {
49
+ return broken('key outcome does not match its intended secret');
50
+ }
51
+ intent.seen.add(outcome.item);
42
52
  }
43
- if (parsed.intentId !== intent.intentId || entry.actor !== intent.entry.actor || entry.action !== intent.operation) {
44
- return broken('key outcome belongs to another operation');
45
- }
46
- if (parsed.secretId !== key.secretId || parsed.version !== key.version || parsed.subject !== key.subject) {
47
- return broken('key outcome does not match its intended secret');
48
- }
49
- intent.seen.add(parsed.item);
50
53
  // Completed batches need no state, and another outcome for one is a duplicate.
51
- if (intent.seen.size === intent.keys.length) this.#intents.delete(intent.entry.seq);
54
+ if (intent !== undefined && intent.seen.size === intent.keys.length) this.#intents.delete(intent.entry.seq);
52
55
  }
53
56
  }
54
57
 
@@ -91,13 +94,22 @@ function parseIntent(entry: StoredEntry): ParsedIntent | string {
91
94
  function parseOutcome(entry: StoredEntry): ParsedOutcome | string {
92
95
  const detail = payload(entry.metadata);
93
96
  if (detail === null) return 'key outcome has no valid accounting payload';
94
- const { intent, item, version, subject } = detail;
97
+ const { intent } = detail;
95
98
  if (typeof intent !== 'string' || entry.relatedSeq === null) return 'key outcome has no intent identity';
96
- if (typeof item !== 'number' || !Number.isSafeInteger(item) || item < 0) return 'key outcome has no valid item number';
97
- const secretId = entry.secretId ?? detail.secretId;
98
- if (typeof secretId !== 'string' || typeof subject !== 'string') return 'key outcome has no secret identity';
99
- if (typeof version !== 'number' || !Number.isSafeInteger(version) || version < 1) return 'key outcome has no valid version';
100
- return { intentId: intent, relatedSeq: entry.relatedSeq, item, secretId, version, subject };
99
+ // Several secrets read at one place share one entry, each its item there.
100
+ const many = detail.secrets;
101
+ if (many !== undefined && (!Array.isArray(many) || many.length === 0)) return 'key outcome has no valid item list';
102
+ const listed = many === undefined ? [{ ...detail, secretId: entry.secretId ?? detail.secretId }] : many.map(record);
103
+ const items: Item[] = [];
104
+ for (const one of listed) {
105
+ if (one === null) return 'key outcome has no valid item list';
106
+ const { item, secretId, version, subject } = one;
107
+ if (typeof item !== 'number' || !Number.isSafeInteger(item) || item < 0) return 'key outcome has no valid item number';
108
+ if (typeof secretId !== 'string' || typeof subject !== 'string') return 'key outcome has no secret identity';
109
+ if (typeof version !== 'number' || !Number.isSafeInteger(version) || version < 1) return 'key outcome has no valid version';
110
+ items.push({ item, secretId, version, subject });
111
+ }
112
+ return { intentId: intent, relatedSeq: entry.relatedSeq, items };
101
113
  }
102
114
 
103
115
  function payload(text: string): Record<string, unknown> | null {
package/src/log.ts CHANGED
@@ -38,7 +38,7 @@ export function further(a: Anchor, b: Anchor): Anchor {
38
38
  return b.nextSeq > a.nextSeq ? b : a;
39
39
  }
40
40
 
41
- /** Entries read at once: a few megabytes, well within a Worker's memory, and a fifth of the round trips of 1000. */
41
+ /** Entries read at once, at most, and no more than `BATCH_BYTES` of what they say: well within a Worker's memory, and a fifth of the round trips of 1000. */
42
42
  export const VERIFY_BATCH = 5000;
43
43
 
44
44
  type Verified = { verification: LogVerification; anchor: Anchor };
@@ -96,7 +96,6 @@ export async function verifyChain(
96
96
  vaultEntries: verified.vaultEntries + result.authenticated,
97
97
  vaultKeys: moved.keys,
98
98
  };
99
- if (batch.length < VERIFY_BATCH) break;
100
99
  }
101
100
  return { verification: { ok: true, entries: verified.vaultEntries }, anchor: verified };
102
101
  }
package/src/store.ts CHANGED
@@ -4,8 +4,8 @@ import { ACCESS_ACTIONS, type SecretRef, type WrappedKey } from '@coffre/core/va
4
4
  import { tablesOf, type Queryable, type Transaction } from '@coffre/db';
5
5
  import { clockMillis, engineOf, forUpdate, tombstone, truth } from '@coffre/db/dialect';
6
6
  import { readGrants, roleColumns, storedRole, type GrantRow } from '@coffre/db/grants';
7
- import { entryColumns } from '@coffre/db/log';
8
- import { and, asc, count, desc, eq, gt, gte, inArray, isNull, like, lt, lte, or, sql, type SQL, type SQLWrapper } from 'drizzle-orm';
7
+ import { entryColumns, withinBytes } from '@coffre/db/log';
8
+ import { and, asc, count, desc, eq, gt, gte, inArray, isNotNull, isNull, like, lt, lte, or, sql, type SQL, type SQLWrapper } from 'drizzle-orm';
9
9
 
10
10
  /**
11
11
  * Every query the vault makes, and the only code in it that holds them.
@@ -193,10 +193,20 @@ function at(db: Queryable, place: Place) {
193
193
  * deleted, alone or with its project: then no key under it opens, or is
194
194
  * wrapped, whatever grant would cover it.
195
195
  */
196
+ /** An environment as a decision reads it. */
197
+ export type EnvironmentRow = {
198
+ projectId: string;
199
+ slug: string;
200
+ owner: string | null;
201
+ parentId: string | null;
202
+ deleted: boolean;
203
+ archived: boolean;
204
+ };
205
+
196
206
  export async function environmentsById(
197
207
  db: Queryable,
198
208
  ids: readonly string[],
199
- ): Promise<Map<string, { projectId: string; slug: string; deleted: boolean }>> {
209
+ ): Promise<Map<string, EnvironmentRow>> {
200
210
  if (ids.length === 0) return new Map();
201
211
  const { environments, projects } = tablesOf(db);
202
212
  const rows = await db
@@ -204,7 +214,12 @@ export async function environmentsById(
204
214
  id: environments.id,
205
215
  projectId: environments.projectId,
206
216
  slug: environments.slug,
217
+ // A personal config's owner, and the environment it is a config of, whose read it takes.
218
+ owner: environments.owner,
219
+ parentId: environments.parentId,
207
220
  deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug))!),
221
+ // Only what names a fork asks it: nothing is decided on it.
222
+ archived: truth(or(isNotNull(environments.archivedAt), isNotNull(projects.archivedAt))!),
208
223
  })
209
224
  .from(environments)
210
225
  .innerJoin(projects, eq(projects.id, environments.projectId))
@@ -291,11 +306,18 @@ export async function everyProjectHolders(db: Queryable): Promise<string[]> {
291
306
  /** The action of an entry that releases a key, which the bulk limit counts. */
292
307
  export const RELEASE = 'secret.read';
293
308
 
294
- /** How many keys the vault has released to `principal` since `after`. */
309
+ /**
310
+ * How many keys the vault has released to `principal` since `after`: one
311
+ * for an entry about one secret, and as many as it lists for one about
312
+ * several (`secrets`), a read logged once per place.
313
+ */
295
314
  export async function releasesSince(db: Queryable, principal: string, after: number): Promise<number> {
296
315
  const { auditLog } = tablesOf(db);
316
+ const listed = engineOf(db) === 'postgres'
317
+ ? sql`jsonb_array_length((${auditLog.metadata})::jsonb -> 'secrets')`
318
+ : sql`json_array_length(${auditLog.metadata}, '$.secrets')`;
297
319
  const [{ n }] = await db
298
- .select({ n: count() })
320
+ .select({ n: sql<string | number | null>`SUM(COALESCE(${listed}, 1))` })
299
321
  .from(auditLog)
300
322
  .where(
301
323
  and(
@@ -306,7 +328,7 @@ export async function releasesSince(db: Queryable, principal: string, after: num
306
328
  gt(auditLog.occurredAt, after),
307
329
  ),
308
330
  );
309
- return Number(n);
331
+ return Number(n ?? 0);
310
332
  }
311
333
 
312
334
  /** Rows of the log as the chain's codec reads them: `author` and `decision` are checked by the table. */
@@ -317,24 +339,27 @@ function stored(rows: object[]): StoredEntry[] {
317
339
  /** Up to `limit` entries of either author from `fromSeq`, oldest first: the chain as it runs. */
318
340
  export async function entriesFrom(db: Queryable, fromSeq: bigint, limit: number): Promise<StoredEntry[]> {
319
341
  const { auditLog } = tablesOf(db);
342
+ const batch = await withinBytes(db, gte(auditLog.seq, fromSeq), limit);
343
+ if (batch === null) return [];
320
344
  const rows = await db
321
345
  .select(entryColumns(db))
322
346
  .from(auditLog)
323
- .where(gte(auditLog.seq, fromSeq))
324
- .orderBy(asc(auditLog.seq))
325
- .limit(limit);
347
+ .where(and(gte(auditLog.seq, batch.from), lte(auditLog.seq, batch.to)))
348
+ .orderBy(asc(auditLog.seq));
326
349
  return stored(rows);
327
350
  }
328
351
 
329
352
  /** Up to `limit` of the vault's entries after `afterSeq`, oldest first: every one, whatever its action or decision. */
330
353
  export async function vaultEntriesAfter(db: Queryable, afterSeq: bigint, limit: number): Promise<StoredEntry[]> {
331
354
  const { auditLog } = tablesOf(db);
355
+ const vault = eq(auditLog.author, 'vault' satisfies Author);
356
+ const batch = await withinBytes(db, and(vault, gt(auditLog.seq, afterSeq)), limit);
357
+ if (batch === null) return [];
332
358
  const rows = await db
333
359
  .select(entryColumns(db))
334
360
  .from(auditLog)
335
- .where(and(eq(auditLog.author, 'vault' satisfies Author), gt(auditLog.seq, afterSeq)))
336
- .orderBy(asc(auditLog.seq))
337
- .limit(limit);
361
+ .where(and(vault, gte(auditLog.seq, batch.from), lte(auditLog.seq, batch.to)))
362
+ .orderBy(asc(auditLog.seq));
338
363
  return stored(rows);
339
364
  }
340
365
 
package/src/vault.ts CHANGED
@@ -24,9 +24,10 @@ import {
24
24
  type Role,
25
25
  type Scope,
26
26
  type ServiceAccount,
27
+ withinCredential,
27
28
  } from '@coffre/core/access';
28
29
  import { GENESIS_HASH, verifyEntries, type LogKey, type StoredEntry } from '@coffre/core/audit';
29
- import { checkContext, type SecretContext } from '@coffre/core/envelope';
30
+ import { checkContext, ownerKey, type SecretContext } from '@coffre/core/envelope';
30
31
  import { shownMember } from '@coffre/core/schemas';
31
32
  import {
32
33
  DEK_BYTES,
@@ -279,6 +280,7 @@ const MESSAGES: Record<RefusalCode, string> = {
279
280
  bad_claim: 'the key does not belong to this secret',
280
281
  deleted: 'this project or environment was deleted',
281
282
  ended: 'the reference read through was ended',
283
+ personal: "a personal config's values are its owner's alone",
282
284
  not_allowed: 'not allowed to change this',
283
285
  last_owner: 'the last Owner of the whole instance stays one: make someone else an Owner first',
284
286
  invalid: 'not something the rules allow',
@@ -428,7 +430,8 @@ class VaultService implements Vault {
428
430
  tx, members, at: await this.#now(tx), log: [], writes: [], touched: new Set(), grants: new Map(), reports, after: [], underLock: [],
429
431
  };
430
432
  const result = await decide(d);
431
- const entries = [...reports, ...d.log];
433
+ // A read is logged once per place, whichever way it ends (`readsByPlace`).
434
+ const entries = [...reports, ...readsByPlace(d.log)];
432
435
  let at = d.at;
433
436
  // Each member's newest access entry, which their row names (rows.ts).
434
437
  const accessSeq = new Map<string, bigint>();
@@ -451,7 +454,7 @@ class VaultService implements Vault {
451
454
  return { ok: true, ...result };
452
455
  } catch (error) {
453
456
  if (!(error instanceof Refused || error instanceof Outage)) throw error;
454
- const entries = [...reports, ...error.entries];
457
+ const entries = [...reports, ...readsByPlace(error.entries)];
455
458
  if (entries.length > 0) {
456
459
  await this.#db.transaction(async (tx) => {
457
460
  await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
@@ -785,23 +788,45 @@ class VaultService implements Vault {
785
788
  const { principal } = input;
786
789
  validateText(input.purpose);
787
790
  const vias = input.items.map((item) => validateVia(item.via));
791
+ const throughs = input.items.map((item) => validateThrough(item.through));
788
792
  const loaded = await this.#versions(input, 'secret.read', { purpose: input.purpose });
789
793
  if (!loaded.ok) return loaded;
790
794
  // RPC can preserve shared objects, and two holders can read one source: each item its own secret.
791
795
  const items = loaded.versions.map((version) => ({ ...version, secret: { ...version.secret } }));
792
796
  validateItems(items, 'wrapped');
793
797
  const sealed = await this.#referencesAt(vias);
798
+ const forks = await store.environmentsById(this.#db, throughs.flatMap((through) => (through === null ? [] : [through])));
799
+ // A reference reads only the current version of the source its entry names.
800
+ const viaOk = items.map((item, i) => vias[i] === null || (sealed[i] !== null && sealed[i]!.source.secretId === item.secret.secretId && item.current));
801
+ // A fork an inherited value is read in is a live environment of the same project as the value's, and not its own,
802
+ // for a read whose reference, if any, holds. Checked once, not again under the log's head: it decides nothing, so a
803
+ // fork archived meanwhile only names a read.
804
+ const throughOk = items.map((item, i) => {
805
+ const through = throughs[i];
806
+ if (through === null) return true;
807
+ if (!viaOk[i]) return false;
808
+ const fork = forks.get(through);
809
+ const decidedAt = sealed[i]?.holder ?? item.secret;
810
+ return fork !== undefined && !fork.deleted && !fork.archived && fork.projectId === decidedAt.projectId && through !== decidedAt.environmentId;
811
+ });
794
812
  const versionIds = new Map(items.map((item) => [item.secret, item.id]));
795
- const viaOf = new Map(items.map((item, i) => [item.secret, { via: vias[i], sealed: sealed[i] }]));
813
+ const viaOf = new Map(items.map((item, i) => [item.secret, { via: vias[i], sealed: sealed[i], through: throughs[i], throughOk: throughOk[i] }]));
796
814
  const entry = (secret: SecretRef, decision: 'allow' | 'deny', code: string | null): NewEntry => {
797
- const { via, sealed: seal } = viaOf.get(secret)!;
815
+ const { via, sealed: seal, through, throughOk: ok } = viaOf.get(secret)!;
798
816
  return {
799
- ...keyEntry('secret.read', principal, secret, decision, code, input, { purpose: input.purpose, ...(via === null ? {} : viaDetail(via, seal)) }),
817
+ ...keyEntry('secret.read', principal, secret, decision, code, input, {
818
+ purpose: input.purpose,
819
+ ...(via === null ? {} : viaDetail(via, seal)),
820
+ // A fork that failed the check is named, never filed under: its project's log is no place for this read.
821
+ ...(through === null ? {} : ok ? throughDetail(through, forks.get(through), via !== null) : { through: { environmentId: through, refused: true } }),
822
+ }),
800
823
  secretVersionId: versionIds.get(secret),
801
824
  };
802
825
  };
803
- // A reference reads only the current version of the source its entry names.
804
- if (items.some((item, i) => vias[i] !== null && (sealed[i] === null || sealed[i]!.source.secretId !== item.secret.secretId || !item.current))) {
826
+ if (viaOk.includes(false)) {
827
+ return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')));
828
+ }
829
+ if (throughOk.includes(false)) {
805
830
  return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')));
806
831
  }
807
832
  const ends = sealed.flatMap((seal) => (seal === null ? [] : [seal.seq]));
@@ -822,9 +847,9 @@ class VaultService implements Vault {
822
847
  entry,
823
848
  input,
824
849
  },
825
- () =>
850
+ (contextOf) =>
826
851
  items.map(({ secret, wrapped }) => async (operation: KeyOperation) => {
827
- const key = await this.#open(wrapped, secret, operation);
852
+ const key = await this.#open(wrapped, contextOf(secret), operation);
828
853
  return key && { key, wipe: () => key.fill(0) };
829
854
  }),
830
855
  (opened) => ({
@@ -856,11 +881,11 @@ class VaultService implements Vault {
856
881
  entry,
857
882
  input,
858
883
  },
859
- () =>
884
+ (contextOf) =>
860
885
  items.map(({ secret, key }) => async (operation: KeyOperation) => {
861
886
  const dek = Buffer.from(key, 'base64');
862
887
  try {
863
- return { wrapped: serialisable(await this.#config.keks.wrap(dek, context(secret), operation)), wipe: () => {} };
888
+ return { wrapped: serialisable(await this.#config.keks.wrap(dek, contextOf(secret), operation)), wipe: () => {} };
864
889
  } finally {
865
890
  dek.fill(0);
866
891
  }
@@ -892,12 +917,12 @@ class VaultService implements Vault {
892
917
  this.#remote(this.#config.keks.primary) || items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
893
918
  return this.#keys(
894
919
  { action: 'key.rewrap', principal, permission: 'secret.write', secrets: items.map((item) => item.secret), remote, entry, input },
895
- () =>
920
+ (contextOf) =>
896
921
  items.map(({ secret, wrapped }) => async (operation: KeyOperation) => {
897
- const key = await this.#open(wrapped, secret, operation);
922
+ const key = await this.#open(wrapped, contextOf(secret), operation);
898
923
  if (key === null) return null;
899
924
  try {
900
- return { wrapped: serialisable(await this.#config.keks.wrap(key, context(secret), operation)), wipe: () => {} };
925
+ return { wrapped: serialisable(await this.#config.keks.wrap(key, contextOf(secret), operation)), wipe: () => {} };
901
926
  } finally {
902
927
  key.fill(0);
903
928
  }
@@ -951,9 +976,9 @@ class VaultService implements Vault {
951
976
  * that is not what it says. A key service that cannot answer is an outage,
952
977
  * not a verdict on the claim, and throws.
953
978
  */
954
- async #open(wrapped: WrappedKey, secret: SecretRef, operation: KeyOperation): Promise<Buffer | null> {
979
+ async #open(wrapped: WrappedKey, context: SecretContext, operation: KeyOperation): Promise<Buffer | null> {
955
980
  try {
956
- return await this.#config.keks.unwrap(unwrappable(wrapped), context(secret), operation);
981
+ return await this.#config.keks.unwrap(unwrappable(wrapped), context, operation);
957
982
  } catch (error) {
958
983
  if (error instanceof KekBadClaimError) return null;
959
984
  throw error;
@@ -989,8 +1014,12 @@ class VaultService implements Vault {
989
1014
  entry: (secret: SecretRef, decision: 'allow' | 'deny', code: string | null) => NewEntry;
990
1015
  input: Correlation & Through & { purpose?: string };
991
1016
  },
992
- /** One per secret; null for a bad claim, an error for an outage or an unexpected fault. */
993
- operations: () => ((operation: KeyOperation) => Promise<T | null>)[],
1017
+ /**
1018
+ * One per secret; null for a bad claim, an error for an outage or an
1019
+ * unexpected fault. `context` is each secret's, as the decision read its
1020
+ * environment: a personal config's binds its owner.
1021
+ */
1022
+ operations: (context: (secret: SecretRef) => SecretContext) => ((operation: KeyOperation) => Promise<T | null>)[],
994
1023
  result: (done: T[]) => R,
995
1024
  ): Promise<Outcome<R>> {
996
1025
  const { action, principal, secrets, entry } = call;
@@ -1001,6 +1030,8 @@ class VaultService implements Vault {
1001
1030
  throw new Refused(refusal('wrong_kek', wrongKek), secrets.map((secret) => entry(secret, 'deny', 'wrong_kek')));
1002
1031
  });
1003
1032
  }
1033
+ // Each personal config's owner, hashed, by environment, as the decision read them: what its keys' contexts bind.
1034
+ const owners = new Map<string, string>();
1004
1035
  const check = async (d: Decision) => {
1005
1036
  const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), call.input);
1006
1037
  // One read of the environments the decision is about, as the store has them, not as the app sent them:
@@ -1008,9 +1039,17 @@ class VaultService implements Vault {
1008
1039
  // matches the slug there, and a place deleted on either side opens for none.
1009
1040
  const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
1010
1041
  const environments = await store.environmentsById(d.tx, [...secrets, ...at].map((place) => place.environmentId));
1042
+ // A personal config's parent, whose read its owner needs.
1043
+ const parents = [...environments.values()].flatMap((environment) => (environment.owner === null || environment.parentId === null ? [] : [environment.parentId]));
1044
+ for (const [id, parent] of await store.environmentsById(d.tx, parents)) environments.set(id, parent);
1011
1045
  const gone = (place: SecretPlace) => environments.get(place.environmentId)?.deleted ?? false;
1012
- const codes: (RefusalCode | null)[] = secrets.map((secret, i) =>
1013
- refuses(reader, call.permission, placeIn(at[i]!, environments), gone(secret) || gone(at[i]!)));
1046
+ owners.clear();
1047
+ for (const [id, environment] of environments) if (environment.owner !== null) owners.set(id, ownerKey(environment.owner));
1048
+ const codes: (RefusalCode | null)[] = secrets.map((secret, i) => {
1049
+ const personal = personalRefusal(reader, principal, call.permission, secret, at[i]!, environments);
1050
+ if (personal !== undefined) return personal;
1051
+ return refuses(reader, call.permission, placeIn(at[i]!, environments), gone(secret) || gone(at[i]!));
1052
+ });
1014
1053
  if (call.vet !== undefined && !codes.some((code) => code !== null)) {
1015
1054
  (await call.vet(d.tx)).forEach((code, i) => (codes[i] = code));
1016
1055
  }
@@ -1087,7 +1126,7 @@ class VaultService implements Vault {
1087
1126
  throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, 'deny', codes[item] ?? first)));
1088
1127
  });
1089
1128
  }
1090
- const { outcomes, expired } = await settle(operations(), this.#prepared.options.keyBudgetMs);
1129
+ const { outcomes, expired } = await settle(operations((secret) => context(secret, owners.get(secret.environmentId))), this.#prepared.options.keyBudgetMs);
1091
1130
  const done = outcomes.flatMap((outcome) => (outcome.ok ? [outcome.value] : []));
1092
1131
  try {
1093
1132
  const entries = () => secrets.map((secret, i) =>
@@ -1192,6 +1231,8 @@ class VaultService implements Vault {
1192
1231
  const source = sources.get(item.source.secretId);
1193
1232
  if (source === undefined || projectOf.get(item.holder.environmentId) !== item.holder.projectId) return 'bad_claim';
1194
1233
  if (source.secretId === item.holder.secretId) return 'invalid';
1234
+ // A personal config's values are bound to its owner: no reference holds one, nor reads one.
1235
+ if (environments.get(item.holder.environmentId)?.owner != null || environments.get(source.environmentId)?.owner != null) return 'personal';
1195
1236
  return refuses(reader, 'secret.write', placeIn(item.holder, environments), gone(item.holder))
1196
1237
  ?? refuses(reader, 'secret.read', placeIn(source, environments), gone(source));
1197
1238
  });
@@ -2238,6 +2279,7 @@ class VaultService implements Vault {
2238
2279
  let keys: readonly string[] = [];
2239
2280
  for (let after = -1n; ; ) {
2240
2281
  const batch = await store.vaultEntriesAfter(tx, after, VERIFY_BATCH);
2282
+ if (batch.length === 0) return { ok: true };
2241
2283
  for (const entry of batch) {
2242
2284
  const result = verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys: logKeys });
2243
2285
  if (!result.ok) return { ok: false, failedAtSeq: Number(result.failedAtSeq), reason: withCause(result.reason) };
@@ -2246,7 +2288,6 @@ class VaultService implements Vault {
2246
2288
  if ('failedAtSeq' in moved) return { ok: false, failedAtSeq: Number(moved.failedAtSeq), reason: moved.reason };
2247
2289
  keys = moved.keys;
2248
2290
  for (const entry of batch) findings.add(entry);
2249
- if (batch.length < VERIFY_BATCH) return { ok: true };
2250
2291
  after = batch[batch.length - 1]!.seq;
2251
2292
  }
2252
2293
  }
@@ -2458,6 +2499,29 @@ function validateVia(via: Via | null | undefined): Via | null {
2458
2499
  return { reference: via.reference, seq: via.seq };
2459
2500
  }
2460
2501
 
2502
+ /** A read's fork, checked for shape: null for none. */
2503
+ function validateThrough(through: string | null | undefined): string | null {
2504
+ if (through === undefined || through === null) return null;
2505
+ if (typeof through !== 'string' || !UUID.test(through)) throw new Error("a fork's id must be a lowercase UUID");
2506
+ return through;
2507
+ }
2508
+
2509
+ /**
2510
+ * How a read of an inherited value is logged: the fork it was read in,
2511
+ * `through`, and as `also`, which puts it in the fork's log too, unless it
2512
+ * was read through the parent's reference. The fork decides nothing: the
2513
+ * reader's grants where the value is held do, as for any read.
2514
+ */
2515
+ function throughDetail(through: string, fork: { projectId: string; slug: string } | undefined, viaReference: boolean): Record<string, unknown> {
2516
+ return {
2517
+ through: { environmentId: through, ...(fork === undefined ? {} : { slug: fork.slug }) },
2518
+ // Through a reference, the entry is the source's, in another project perhaps, and stays filed where references
2519
+ // file theirs: the source and the holder, the parent. A fork's own log would show a reader of the fork alone
2520
+ // what the parent's reference reads, which is the parent's to tell.
2521
+ ...(viaReference ? {} : { also: { projectId: fork?.projectId ?? null, environmentId: through } }),
2522
+ };
2523
+ }
2524
+
2461
2525
  /**
2462
2526
  * How a read through a reference is logged: the way in, and `also`, the
2463
2527
  * holder's place, which puts the read in the holder's project's log too.
@@ -2489,6 +2553,32 @@ function placeIn(place: SecretPlace, environments: ReadonlyMap<string, { project
2489
2553
  * deleted place is refused before any grant is asked, so no kind of grant,
2490
2554
  * on the project, the environment or every project, can reach into one.
2491
2555
  */
2556
+ /**
2557
+ * A key of a personal config is its owner's alone (docs/design/forking.md,
2558
+ * section 4): no grant or role opens or wraps it, an Owner's included,
2559
+ * only its owner, while they read the environment it is a config of,
2560
+ * whatever the call is. Nor does any reference reach into or out of one.
2561
+ * Undefined for a key of any other environment, decided as ever.
2562
+ */
2563
+ function personalRefusal(
2564
+ reader: Standing,
2565
+ principal: string,
2566
+ permission: Permission,
2567
+ secret: SecretPlace,
2568
+ at: SecretPlace,
2569
+ environments: ReadonlyMap<string, store.EnvironmentRow>,
2570
+ ): RefusalCode | null | undefined {
2571
+ const own = environments.get(secret.environmentId);
2572
+ const held = at.environmentId === secret.environmentId ? own : environments.get(at.environmentId);
2573
+ if (own?.owner == null && held?.owner == null) return undefined;
2574
+ if (own !== held || principal !== own?.owner || own.parentId === null) return 'personal';
2575
+ const parent = placeIn({ projectId: secret.projectId, environmentId: own.parentId }, environments);
2576
+ const refused = refuses(reader, 'secret.read', parent, own.deleted || (environments.get(own.parentId)?.deleted ?? true));
2577
+ if (refused !== null) return refused;
2578
+ // The credential's scope caps the owner still: their viewer token on the parent reads, and wraps no key.
2579
+ return withinCredential(reader.live, permission, parent) ? null : 'no_grant';
2580
+ }
2581
+
2492
2582
  function refuses(reader: Standing, permission: Permission, where: Place, deleted: boolean): RefusalCode | null {
2493
2583
  if (reader.status === 'tampered') return 'tampered';
2494
2584
  if (reader.status === 'removed') return 'removed';
@@ -2532,6 +2622,78 @@ function keyEntry(
2532
2622
  };
2533
2623
  }
2534
2624
 
2625
+ /** What every secret of one read at a place shares, said once in its entry rather than in each of its `secrets`. */
2626
+ const READ_SHARED = ['purpose', 'intent', 'credentialId', 'through'] as const;
2627
+
2628
+ /**
2629
+ * A decision's `secret.read` entries, made one per secret, as the log keeps
2630
+ * them: one per place it was decided at, the environment read and, through
2631
+ * a reference, the holder's, or the fork that inherits it (`also`,
2632
+ * `through`). An entry about several secrets lists
2633
+ * them in `secrets`, each with what its own entry would have said: its
2634
+ * `secretId`, `secretVersionId`, `subject`, `version`, and its `via`, holder
2635
+ * (`also.secretId`) and intent `item` when it has them. An entry about one
2636
+ * secret is as it always was. A `coffre run` of a hundred keys is one entry,
2637
+ * not a hundred; what it decided, and for which keys, is the same. Other
2638
+ * entries pass through, in order.
2639
+ */
2640
+ function readsByPlace(entries: readonly NewEntry[]): NewEntry[] {
2641
+ const groups = new Map<string, NewEntry[]>();
2642
+ const out: (NewEntry | NewEntry[])[] = [];
2643
+ for (const entry of entries) {
2644
+ if (entry.action !== 'secret.read') {
2645
+ out.push(entry);
2646
+ continue;
2647
+ }
2648
+ const { also, through } = JSON.parse(entry.metadata ?? '{}') as { also?: { projectId?: string; environmentId?: string }; through?: unknown };
2649
+ const place = JSON.stringify([
2650
+ entry.actor, entry.decision, entry.code ?? null, entry.projectId ?? null, entry.environmentId ?? null,
2651
+ entry.operationId ?? null, entry.requestId ?? null, String(entry.relatedSeq ?? null), also?.projectId ?? null, also?.environmentId ?? null,
2652
+ through ?? null,
2653
+ ]);
2654
+ const group = groups.get(place);
2655
+ if (group !== undefined) {
2656
+ group.push(entry);
2657
+ continue;
2658
+ }
2659
+ const fresh = [entry];
2660
+ groups.set(place, fresh);
2661
+ out.push(fresh);
2662
+ }
2663
+ return out.map((item) => (Array.isArray(item) ? oneRead(item) : item));
2664
+ }
2665
+
2666
+ /** Entries of one read at one place as one entry: `readsByPlace`. */
2667
+ function oneRead(group: NewEntry[]): NewEntry {
2668
+ const [first] = group as [NewEntry];
2669
+ if (group.length === 1) return first;
2670
+ const details = group.map((entry) => JSON.parse(entry.metadata ?? '{}') as Record<string, unknown>);
2671
+ const shared = Object.fromEntries(READ_SHARED.flatMap((field) => (details[0]![field] === undefined ? [] : [[field, details[0]![field]]])));
2672
+ // The place on the other side, which the group shares: a reference's holder, or the fork reading.
2673
+ const beside = details[0]!.also as { projectId: string | null; environmentId: string } | undefined;
2674
+ const secrets = group.map((entry, i) => {
2675
+ const own = Object.fromEntries(Object.entries(details[i]!).filter(([field]) => !(READ_SHARED as readonly string[]).includes(field) && field !== 'also'));
2676
+ // A reference's holder is its own; a fork's `also` names only the fork, which the entry says once.
2677
+ const holder = (details[i]!.also as { secretId?: string } | undefined)?.secretId;
2678
+ return {
2679
+ ...(entry.secretId == null ? {} : { secretId: entry.secretId }),
2680
+ ...(entry.secretVersionId == null ? {} : { secretVersionId: entry.secretVersionId }),
2681
+ ...own,
2682
+ ...(holder === undefined ? {} : { also: { secretId: holder } }),
2683
+ };
2684
+ });
2685
+ return {
2686
+ ...first,
2687
+ secretId: null,
2688
+ secretVersionId: null,
2689
+ metadata: JSON.stringify({
2690
+ ...shared,
2691
+ ...(beside === undefined ? {} : { also: { projectId: beside.projectId, environmentId: beside.environmentId } }),
2692
+ secrets,
2693
+ }),
2694
+ };
2695
+ }
2696
+
2535
2697
  /** Whether `entry` changes a member's access: what their row's `access_seq` names. */
2536
2698
  function isAccessEntry(entry: NewEntry): boolean {
2537
2699
  return (
@@ -2614,8 +2776,9 @@ function iso(ms: number): string {
2614
2776
  return new Date(ms).toISOString();
2615
2777
  }
2616
2778
 
2617
- function context(secret: SecretRef): SecretContext {
2618
- return { projectId: secret.projectId, environmentId: secret.environmentId, secretId: secret.secretId };
2779
+ /** A secret's binding context: its ids, and a personal config's owner, hashed, as the decision read it. */
2780
+ function context(secret: SecretRef, owner?: string): SecretContext {
2781
+ return { projectId: secret.projectId, environmentId: secret.environmentId, secretId: secret.secretId, ...(owner === undefined ? {} : { owner }) };
2619
2782
  }
2620
2783
 
2621
2784
  function unwrappable(wrapped: WrappedKey) {