@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.
- package/dist/cloudflare.js +1 -1
- package/dist/node.js +1 -1
- package/dist/{vault-ihE3Yc4a.js → vault-jbn0I41Z.js} +232 -53
- package/package.json +3 -3
- package/src/accounting.ts +30 -18
- package/src/log.ts +1 -2
- package/src/store.ts +37 -12
- package/src/vault.ts +188 -25
package/dist/cloudflare.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-
|
|
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-
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
114
|
+
const { intent } = detail;
|
|
113
115
|
if (typeof intent !== "string" || entry.relatedSeq === null) return "key outcome has no intent identity";
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
355
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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,
|
|
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,
|
|
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,
|
|
1722
|
+
async #open(wrapped, context, operation) {
|
|
1683
1723
|
try {
|
|
1684
|
-
return await this.#config.keks.unwrap(unwrappable(wrapped), context
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
40
|
-
"@coffre/db": "0.
|
|
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
|
-
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
97
|
+
const { intent } = detail;
|
|
95
98
|
if (typeof intent !== 'string' || entry.relatedSeq === null) return 'key outcome has no intent identity';
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
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,
|
|
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
|
-
/**
|
|
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:
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
804
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
/**
|
|
993
|
-
|
|
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
|
-
|
|
1013
|
-
|
|
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
|
-
|
|
2618
|
-
|
|
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) {
|