@coffre/vault 0.14.4 → 0.14.6
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.d.ts +2 -5
- package/dist/cloudflare.js +2 -2
- package/dist/{index-CIq4i_KT.d.ts → index-DQ-FKVF3.d.ts} +4 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/node.d.ts +1 -1
- package/dist/node.js +2 -2
- package/dist/{src-NM5NsYFs.js → src-Bg0ZeMJW.js} +9 -2
- package/dist/{vault-BBdXsny3.js → vault-Cbi45kLB.js} +144 -24
- package/package.json +3 -3
- package/src/config.ts +14 -3
- package/src/snapshot.ts +8 -2
- package/src/store.ts +30 -1
- package/src/vault.ts +149 -22
package/dist/cloudflare.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as KekUnavailableError, c as awsKms, d as Kek, f as VaultConfig, i as KekProvider, l as checkpointMessage, n as AwsKmsOptions, o as SecretContext, p as derivedSigningKey, r as KekBadClaimError, s as WrappedDek, t as AwsCredentials, u as verifyCheckpoint } from "./index-
|
|
1
|
+
import { a as KekUnavailableError, c as awsKms, d as Kek, f as VaultConfig, i as KekProvider, l as checkpointMessage, n as AwsKmsOptions, o as SecretContext, p as derivedSigningKey, r as KekBadClaimError, s as WrappedDek, t as AwsCredentials, u as verifyCheckpoint } from "./index-DQ-FKVF3.js";
|
|
2
2
|
import { AdmitInput, ChangePolicyInput, EndReferencesInput, ReferenceInput, RemoveInput, RewrapInput, SetAccessInput, SetSettingsInput, UnwrapInput, Vault, VerifyLogInput, WrapInput } from "@coffre/core/vault";
|
|
3
3
|
import { PostgresDatabase, PostgresDatabase as PostgresDatabase$1, postgres } from "@coffre/db/hyperdrive";
|
|
4
4
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
@@ -16,10 +16,7 @@ export type WorkersVaultConfig = VaultConfig & {
|
|
|
16
16
|
*/
|
|
17
17
|
export declare class VaultEntrypoint extends WorkerEntrypoint implements Vault {
|
|
18
18
|
#private;
|
|
19
|
-
unwrap(input: UnwrapInput): Promise<import("@coffre/core/vault").Outcome<
|
|
20
|
-
keys: string[];
|
|
21
|
-
accessRevision?: string;
|
|
22
|
-
}>>;
|
|
19
|
+
unwrap(input: UnwrapInput): Promise<import("@coffre/core/vault").Outcome<import("@coffre/core/vault").Unwrapped>>;
|
|
23
20
|
wrap(input: WrapInput): Promise<import("@coffre/core/vault").Outcome<import("@coffre/core/vault").Wrapped>>;
|
|
24
21
|
rewrap(input: RewrapInput): Promise<import("@coffre/core/vault").Outcome<import("@coffre/core/vault").Wrapped>>;
|
|
25
22
|
reference(input: ReferenceInput): Promise<import("@coffre/core/vault").Outcome<{
|
package/dist/cloudflare.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { a as verifyCheckpoint,
|
|
2
|
-
import { n as prepareVault, t as openVault } from "./vault-
|
|
1
|
+
import { a as verifyCheckpoint, c as resolveVaultConfig, i as checkpointMessage, n as KekUnavailableError, r as awsKms, s as derivedSigningKey, t as KekBadClaimError } from "./src-Bg0ZeMJW.js";
|
|
2
|
+
import { n as prepareVault, t as openVault } from "./vault-Cbi45kLB.js";
|
|
3
3
|
import { createDatabase } from "@coffre/db";
|
|
4
4
|
import { HyperdrivePool, postgres } from "@coffre/db/hyperdrive";
|
|
5
5
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
@@ -36,7 +36,10 @@ type VaultConfig = {
|
|
|
36
36
|
* service holds the KEK, as AWS KMS does: the vault never sees that key.
|
|
37
37
|
*/
|
|
38
38
|
signingKey?: string;
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* At most `count` distinct data keys unwrapped per principal in any `windowMinutes`; 1000 in 15 unless set. The
|
|
41
|
+
* window is a day at most: the releases it counts are kept a day and an hour (`prune_vault_releases`).
|
|
42
|
+
*/
|
|
40
43
|
bulkLimit?: {
|
|
41
44
|
count: number;
|
|
42
45
|
windowMinutes: number;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { a as KekUnavailableError, c as awsKms, d as Kek, f as VaultConfig, i as KekProvider, l as checkpointMessage, n as AwsKmsOptions, o as SecretContext, p as derivedSigningKey, r as KekBadClaimError, s as WrappedDek, t as AwsCredentials, u as verifyCheckpoint } from "./index-
|
|
1
|
+
import { a as KekUnavailableError, c as awsKms, d as Kek, f as VaultConfig, i as KekProvider, l as checkpointMessage, n as AwsKmsOptions, o as SecretContext, p as derivedSigningKey, r as KekBadClaimError, s as WrappedDek, t as AwsCredentials, u as verifyCheckpoint } from "./index-DQ-FKVF3.js";
|
|
2
2
|
export type * from "@coffre/core/vault";
|
|
3
3
|
export { type AwsCredentials, type AwsKmsOptions, type Kek, KekBadClaimError, type KekProvider, KekUnavailableError, type SecretContext, type VaultConfig, type WrappedDek, awsKms, checkpointMessage, derivedSigningKey, verifyCheckpoint };
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as verifyCheckpoint, i as checkpointMessage, n as KekUnavailableError,
|
|
1
|
+
import { a as verifyCheckpoint, i as checkpointMessage, n as KekUnavailableError, r as awsKms, s as derivedSigningKey, t as KekBadClaimError } from "./src-Bg0ZeMJW.js";
|
|
2
2
|
export { KekBadClaimError, KekUnavailableError, awsKms, checkpointMessage, derivedSigningKey, verifyCheckpoint };
|
package/dist/node.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as KekUnavailableError, c as awsKms, d as Kek, f as VaultConfig, i as KekProvider, l as checkpointMessage, n as AwsKmsOptions, o as SecretContext, p as derivedSigningKey, r as KekBadClaimError, s as WrappedDek, t as AwsCredentials, u as verifyCheckpoint } from "./index-
|
|
1
|
+
import { a as KekUnavailableError, c as awsKms, d as Kek, f as VaultConfig, i as KekProvider, l as checkpointMessage, n as AwsKmsOptions, o as SecretContext, p as derivedSigningKey, r as KekBadClaimError, s as WrappedDek, t as AwsCredentials, u as verifyCheckpoint } from "./index-DQ-FKVF3.js";
|
|
2
2
|
import { Vault, Vault as Vault$1 } from "@coffre/core/vault";
|
|
3
3
|
import { Database } from "@coffre/db";
|
|
4
4
|
import "@coffre/core/access";
|
package/dist/node.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { a as verifyCheckpoint,
|
|
2
|
-
import { n as prepareVault, t as openVault } from "./vault-
|
|
1
|
+
import { a as verifyCheckpoint, c as resolveVaultConfig, i as checkpointMessage, n as KekUnavailableError, r as awsKms, s as derivedSigningKey, t as KekBadClaimError } from "./src-Bg0ZeMJW.js";
|
|
2
|
+
import { n as prepareVault, t as openVault } from "./vault-Cbi45kLB.js";
|
|
3
3
|
import { isUnavailable } from "@coffre/db/dialect";
|
|
4
4
|
import { chmodSync, existsSync, lstatSync, rmSync } from "node:fs";
|
|
5
5
|
import { Agent, createServer, request } from "node:http";
|
|
@@ -11,6 +11,13 @@ const DEFAULT_BULK_LIMIT = {
|
|
|
11
11
|
count: 1e3,
|
|
12
12
|
windowMs: 9e5
|
|
13
13
|
};
|
|
14
|
+
/**
|
|
15
|
+
* The longest bulk limit window: a day. The releases a window counts are
|
|
16
|
+
* kept a day and an hour (migration 0021's `prune_vault_releases`, and on
|
|
17
|
+
* SQLite `RELEASES_KEPT_MS`), so every window up to it, and one widened
|
|
18
|
+
* since, counts exactly.
|
|
19
|
+
*/
|
|
20
|
+
const MAX_BULK_WINDOW_MINUTES = 1440;
|
|
14
21
|
function key32(value, what) {
|
|
15
22
|
const raw = Buffer.from(value, "base64");
|
|
16
23
|
if (raw.length !== 32) throw new Error(`${what} must be 32 bytes, base64; got ${raw.length} bytes`);
|
|
@@ -76,7 +83,7 @@ function signingKeys(given, keks) {
|
|
|
76
83
|
return [signing, ...derived].filter((key, i, all) => all.findIndex((other) => other.equals(key)) === i);
|
|
77
84
|
}
|
|
78
85
|
function checkBulkLimit({ count, windowMinutes }) {
|
|
79
|
-
if (!Number.isInteger(count) || count < 1 || !(windowMinutes > 0)) throw new Error(
|
|
86
|
+
if (!Number.isInteger(count) || count < 1 || !(windowMinutes > 0) || windowMinutes > 1440) throw new Error(`bulkLimit needs a whole count of at least 1 and a window above 0 minutes, a day (${MAX_BULK_WINDOW_MINUTES}) at most`);
|
|
80
87
|
return {
|
|
81
88
|
count,
|
|
82
89
|
windowMs: windowMinutes * 6e4
|
|
@@ -105,4 +112,4 @@ function checkOwners(emails) {
|
|
|
105
112
|
return [...new Set(owners.map((entry) => entry.toLowerCase()))];
|
|
106
113
|
}
|
|
107
114
|
//#endregion
|
|
108
|
-
export { verifyCheckpoint as a, checkpointMessage$1 as i, KekUnavailableError$1 as n,
|
|
115
|
+
export { verifyCheckpoint as a, resolveVaultConfig as c, checkpointMessage$1 as i, KekUnavailableError$1 as n, MAX_BULK_WINDOW_MINUTES as o, awsKms as r, derivedSigningKey as s, KekBadClaimError$1 as t };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { o as MAX_BULK_WINDOW_MINUTES } from "./src-Bg0ZeMJW.js";
|
|
2
|
+
import { ACCESS_ACTIONS, ACCESS_ACTIONS as ACCESS_ACTIONS$1, POLICY_REVISION, USE_METADATA_LENGTH, accessRevision, checkpointMessage, checkpointVerifier, describeAccessFault, handsBack, policyRevision } from "@coffre/core/vault";
|
|
2
3
|
import { DEK_BYTES, KEY_CHECK, KEY_CHECK_CONTEXT, KEY_CHECK_VALUE, KekBadClaimError, KekCancelledError, KekUnavailableError, LocalKekProvider } from "@coffre/core/kek";
|
|
3
4
|
import { prepared, tablesOf } from "@coffre/db";
|
|
4
5
|
import { createHash, createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
|
|
@@ -6,9 +7,9 @@ import { DEFAULT_POLICY, EVERYWHERE, EVERY_ENVIRONMENT, FIXED_ROLES, INSTANCE_RO
|
|
|
6
7
|
import { GENESIS_HASH, deriveLogKey, verifyEntries } from "@coffre/core/audit";
|
|
7
8
|
import { checkContext, ownerKey } from "@coffre/core/envelope";
|
|
8
9
|
import { TOMBSTONE, shownMember } from "@coffre/core/schemas";
|
|
9
|
-
import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUnavailable, isUniqueViolation, rowsOf, tombstone, truth } from "@coffre/db/dialect";
|
|
10
|
+
import { SNAPSHOT, changedRows, clockMillis, engineOf, forUpdate, isUnavailable, isUniqueViolation, rowsOf, tombstone, truth } from "@coffre/db/dialect";
|
|
10
11
|
import { accessEntriesAbout, everyProjectConversion, insertGrant, liveProjects, newestAccess, readGrants, reportStands, roleColumns, storedRole } from "@coffre/db/grants";
|
|
11
|
-
import { LogHeadMismatch, appendEntries, entryColumns, lockLogHead, missingEntry, newestAuthentic, newestVaultEntries, withinBytes } from "@coffre/db/log";
|
|
12
|
+
import { LogHeadMismatch, appendEntries, entryColumns, lockLogHead, lockLogHeadQueued, missingEntry, newestAuthentic, newestVaultEntries, withinBytes } from "@coffre/db/log";
|
|
12
13
|
import { and, asc, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm";
|
|
13
14
|
//#region src/checkpoint.ts
|
|
14
15
|
const PKCS8_ED25519 = [
|
|
@@ -189,6 +190,33 @@ async function boundLockWaits(tx, ms, statements = false) {
|
|
|
189
190
|
const bound = String(Math.trunc(ms));
|
|
190
191
|
await tx.execute(statements ? sql`SELECT set_config('lock_timeout', ${bound}, true), set_config('statement_timeout', ${bound}, true)` : sql.raw(`SET LOCAL lock_timeout = ${bound}`));
|
|
191
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* How long a release is kept: the longest bulk limit window
|
|
195
|
+
* (`MAX_BULK_WINDOW_MINUTES`, a day), and an hour, a margin for the
|
|
196
|
+
* database's clock stepping back between a prune and a later count, which
|
|
197
|
+
* is seconds. Migration 0021's `prune_vault_releases` holds the same, a day
|
|
198
|
+
* and an hour, in its own SQL: a longer window takes a migration too.
|
|
199
|
+
*/
|
|
200
|
+
const RELEASES_KEPT_MS = MAX_BULK_WINDOW_MINUTES * 6e4 + 36e5;
|
|
201
|
+
/**
|
|
202
|
+
* Delete up to `atMost` rows of `vault_releases` released over
|
|
203
|
+
* `RELEASES_KEPT_MS` before the database's clock: what no bulk count can
|
|
204
|
+
* read again. On Postgres the vault's login deletes no row itself, and asks
|
|
205
|
+
* `prune_vault_releases`, which deletes only those (migration 0021). How
|
|
206
|
+
* many went.
|
|
207
|
+
*/
|
|
208
|
+
async function pruneReleases(db, atMost) {
|
|
209
|
+
if (engineOf(db) === "postgres") {
|
|
210
|
+
const [row] = await rowsOf(db, sql`SELECT prune_vault_releases(${atMost}::int) AS pruned`);
|
|
211
|
+
return row.pruned;
|
|
212
|
+
}
|
|
213
|
+
const { vaultReleases } = tablesOf(db);
|
|
214
|
+
const old = db.select({
|
|
215
|
+
principal: vaultReleases.principal,
|
|
216
|
+
key: vaultReleases.key
|
|
217
|
+
}).from(vaultReleases).where(lt(vaultReleases.releasedAt, sql`${clockMillis(db)} - ${RELEASES_KEPT_MS}`)).limit(atMost);
|
|
218
|
+
return changedRows(await db.delete(vaultReleases).where(sql`(${vaultReleases.principal}, ${vaultReleases.key}) IN ${old}`));
|
|
219
|
+
}
|
|
192
220
|
/** The database's clock, now. */
|
|
193
221
|
async function now(db) {
|
|
194
222
|
const [{ at }] = await clock(db);
|
|
@@ -1322,7 +1350,7 @@ async function snapshot(db, asked) {
|
|
|
1322
1350
|
const read = () => readOnce(db, asked);
|
|
1323
1351
|
const first = await read();
|
|
1324
1352
|
if (asked.head !== true || first.head !== null && current(first.head)) return first;
|
|
1325
|
-
if (first.head === null) await lockLogHead(db);
|
|
1353
|
+
if (first.head === null) await (asked.waiters === void 0 ? lockLogHead(db) : lockLogHeadQueued(db, asked.waiters));
|
|
1326
1354
|
const again = await read();
|
|
1327
1355
|
if (again.head === null) throw new Error("the log's head, held by this transaction, was read as held by another");
|
|
1328
1356
|
if (!current(again.head)) throw new LogHeadMismatch(`the log's head says entry ${again.head.nextSeq} comes next, but the log ends ${again.head.last === null ? "empty" : `at entry ${again.head.last.seq}`}, or with another hash`);
|
|
@@ -1815,6 +1843,31 @@ const LOCK_TIMEOUT_MS = 15e3;
|
|
|
1815
1843
|
* again later.
|
|
1816
1844
|
*/
|
|
1817
1845
|
const READ_LOCK_TIMEOUT_MS = 3e3;
|
|
1846
|
+
/**
|
|
1847
|
+
* How long a whole read call may take, from its start to its answer, its
|
|
1848
|
+
* refusal's entries, the record of a vault key's check and, with a key
|
|
1849
|
+
* service, its intent and its own budget (added) included: each transaction's waits are bounded by what is left of it, and
|
|
1850
|
+
* past it the call fails as unavailable (`Overdue`) rather than start
|
|
1851
|
+
* another. So a caller hears within it, ok or 503, never tens of seconds later.
|
|
1852
|
+
*/
|
|
1853
|
+
const READ_DEADLINE_MS = 5e3;
|
|
1854
|
+
/** How many releases a checkpoint prunes at most: one short statement, a few each scheduled run. */
|
|
1855
|
+
const RELEASES_PRUNED = 1e4;
|
|
1856
|
+
/** A read's call past its deadline (`READ_DEADLINE_MS`): unavailable, as a statement past its bound (57014) is. */
|
|
1857
|
+
var Overdue = class extends Error {
|
|
1858
|
+
code = "57014";
|
|
1859
|
+
constructor() {
|
|
1860
|
+
super("this read took longer than it may: ask again shortly");
|
|
1861
|
+
this.name = "Overdue";
|
|
1862
|
+
}
|
|
1863
|
+
};
|
|
1864
|
+
/** The bound of a transaction of a call due at `deadline`: `ms`, or what is left of the call, if less. */
|
|
1865
|
+
function within(deadline, ms) {
|
|
1866
|
+
if (deadline === void 0) return ms;
|
|
1867
|
+
const left = deadline - Date.now();
|
|
1868
|
+
if (left <= 0) throw new Overdue();
|
|
1869
|
+
return Math.min(ms, left);
|
|
1870
|
+
}
|
|
1818
1871
|
/** A member: `user:<email>` or `token:<name>`. */
|
|
1819
1872
|
const PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
|
|
1820
1873
|
/** A person, and a service account: only a person sets one up (`givesService`). */
|
|
@@ -1995,12 +2048,13 @@ var VaultService = class {
|
|
|
1995
2048
|
return reads.at + this.#prepared.options.clockOffset();
|
|
1996
2049
|
}
|
|
1997
2050
|
/** What a decision about `principals` reads, and `asked` besides, in one statement: under the head when `head` is set. */
|
|
1998
|
-
#snapshot(tx, principals, asked, head) {
|
|
2051
|
+
#snapshot(tx, principals, asked, head, reading = false) {
|
|
1999
2052
|
const { released, ...rest } = asked;
|
|
2000
2053
|
return snapshot(tx, {
|
|
2001
2054
|
...rest,
|
|
2002
2055
|
principals,
|
|
2003
2056
|
head,
|
|
2057
|
+
...reading ? { waiters: 16 } : {},
|
|
2004
2058
|
...released === void 0 ? {} : { released: {
|
|
2005
2059
|
...released,
|
|
2006
2060
|
windowMs: this.#config.bulkLimit.windowMs,
|
|
@@ -2034,19 +2088,23 @@ var VaultService = class {
|
|
|
2034
2088
|
/** `#decide`, once the vault is settled. */
|
|
2035
2089
|
async #decideNow(principals, decide, how = {}) {
|
|
2036
2090
|
const reports = [];
|
|
2091
|
+
const due = how.reads === true ? {
|
|
2092
|
+
...how,
|
|
2093
|
+
deadline: how.deadline ?? Date.now() + 5e3
|
|
2094
|
+
} : how;
|
|
2037
2095
|
for (let attempt = 1;; attempt += 1) try {
|
|
2038
|
-
return await this.#decideOnce(principals, decide, reports,
|
|
2096
|
+
return await this.#decideOnce(principals, decide, reports, due);
|
|
2039
2097
|
} catch (error) {
|
|
2040
2098
|
if (attempt < 3 && (error instanceof Retry || isUniqueViolation(error))) continue;
|
|
2041
2099
|
throw error;
|
|
2042
2100
|
}
|
|
2043
2101
|
}
|
|
2044
|
-
async #decideOnce(principals, decide, reports, { late = false, asked = {}, reads: shared = false }) {
|
|
2102
|
+
async #decideOnce(principals, decide, reports, { late = false, asked = {}, reads: shared = false, deadline }) {
|
|
2045
2103
|
try {
|
|
2046
2104
|
const result = await this.#db.transaction(async (tx) => {
|
|
2047
|
-
await boundLockWaits(tx, shared ? READ_LOCK_TIMEOUT_MS : LOCK_TIMEOUT_MS, shared);
|
|
2105
|
+
await boundLockWaits(tx, shared ? within(deadline, READ_LOCK_TIMEOUT_MS) : LOCK_TIMEOUT_MS, shared);
|
|
2048
2106
|
const members = principals.length === 0 ? /* @__PURE__ */ new Map() : await lockMembers(tx, principals, shared);
|
|
2049
|
-
const reads = await this.#snapshot(tx, principals, asked, !late);
|
|
2107
|
+
const reads = await this.#snapshot(tx, principals, asked, !late, shared);
|
|
2050
2108
|
const d = {
|
|
2051
2109
|
tx,
|
|
2052
2110
|
members,
|
|
@@ -2089,7 +2147,8 @@ var VaultService = class {
|
|
|
2089
2147
|
if (!(error instanceof Refused || error instanceof Outage)) throw error;
|
|
2090
2148
|
const entries = [...reports, ...readsByPlace(error.entries)];
|
|
2091
2149
|
if (entries.length > 0) await this.#db.transaction(async (tx) => {
|
|
2092
|
-
await boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
2150
|
+
await boundLockWaits(tx, shared ? within(deadline, READ_LOCK_TIMEOUT_MS) : LOCK_TIMEOUT_MS, shared);
|
|
2151
|
+
if (shared) await lockLogHeadQueued(tx, 16);
|
|
2093
2152
|
await this.#append(tx, entries);
|
|
2094
2153
|
});
|
|
2095
2154
|
this.#reported(reports);
|
|
@@ -2427,6 +2486,7 @@ var VaultService = class {
|
|
|
2427
2486
|
}
|
|
2428
2487
|
async unwrap(input) {
|
|
2429
2488
|
const { principal } = input;
|
|
2489
|
+
const use = input.use === void 0 ? null : useEntry(principal, input, input.use);
|
|
2430
2490
|
validateText(input.purpose);
|
|
2431
2491
|
if (input.keepsSensitive !== void 0 && typeof input.keepsSensitive !== "boolean") throw new Error("keepsSensitive is true, false or absent");
|
|
2432
2492
|
if (input.accessRevision !== void 0 && typeof input.accessRevision !== "string") throw new Error("accessRevision names an answer of access, or is absent");
|
|
@@ -2473,6 +2533,16 @@ var VaultService = class {
|
|
|
2473
2533
|
};
|
|
2474
2534
|
if (viaOk.includes(false)) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")), { asked: input.accessRevision });
|
|
2475
2535
|
if (throughOk.includes(false)) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")), { asked: input.accessRevision });
|
|
2536
|
+
if (use !== null && (use.environmentId !== null || use.projectId !== null)) {
|
|
2537
|
+
if (!items.some((item, i) => {
|
|
2538
|
+
const fork = throughs[i] === null ? void 0 : forks.get(throughs[i]);
|
|
2539
|
+
const place = fork === void 0 ? sealed[i]?.holder ?? item.secret : {
|
|
2540
|
+
projectId: fork.projectId,
|
|
2541
|
+
environmentId: throughs[i]
|
|
2542
|
+
};
|
|
2543
|
+
return (use.environmentId === null || place.environmentId === use.environmentId) && (use.projectId === null || place.projectId === use.projectId);
|
|
2544
|
+
})) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")), { asked: input.accessRevision });
|
|
2545
|
+
}
|
|
2476
2546
|
const ends = sealed.flatMap((seal) => seal === null ? [] : [seal.seq]);
|
|
2477
2547
|
const remote = items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
|
|
2478
2548
|
return this.#keys({
|
|
@@ -2486,7 +2556,8 @@ var VaultService = class {
|
|
|
2486
2556
|
vet: ends.length === 0 ? void 0 : async (_, reads) => sealed.map((seal) => seal !== null && reads.ended.has(seal.seq) ? "ended" : null),
|
|
2487
2557
|
remote,
|
|
2488
2558
|
entry,
|
|
2489
|
-
input
|
|
2559
|
+
input,
|
|
2560
|
+
...use === null ? {} : { also: [use] }
|
|
2490
2561
|
}, (contextOf) => items.map(({ secret, wrapped }) => async (operation) => {
|
|
2491
2562
|
const key = await this.#open(wrapped, contextOf(secret), operation);
|
|
2492
2563
|
return key && {
|
|
@@ -2501,7 +2572,8 @@ var VaultService = class {
|
|
|
2501
2572
|
key.fill(0);
|
|
2502
2573
|
}
|
|
2503
2574
|
}),
|
|
2504
|
-
...input.accessRevision === void 0 ? {} : { accessRevision: input.accessRevision }
|
|
2575
|
+
...input.accessRevision === void 0 ? {} : { accessRevision: input.accessRevision },
|
|
2576
|
+
...use === null ? {} : { used: true }
|
|
2505
2577
|
}));
|
|
2506
2578
|
}
|
|
2507
2579
|
async wrap(input) {
|
|
@@ -2682,9 +2754,13 @@ var VaultService = class {
|
|
|
2682
2754
|
async #keys(call, operations, result) {
|
|
2683
2755
|
const { action, principal, secrets, entry } = call;
|
|
2684
2756
|
for (const secret of secrets) entry(secret, "allow", null);
|
|
2685
|
-
const
|
|
2757
|
+
const deadline = Date.now() + READ_DEADLINE_MS + (call.remote ? this.#prepared.options.keyBudgetMs : 0);
|
|
2758
|
+
const wrongKek = await this.#kekMismatch(deadline);
|
|
2686
2759
|
if (wrongKek !== null) return this.#decide([], async () => {
|
|
2687
2760
|
throw new Refused(refusal("wrong_kek", wrongKek), secrets.map((secret) => entry(secret, "deny", "wrong_kek")));
|
|
2761
|
+
}, {
|
|
2762
|
+
reads: true,
|
|
2763
|
+
deadline
|
|
2688
2764
|
});
|
|
2689
2765
|
const owners = /* @__PURE__ */ new Map();
|
|
2690
2766
|
const check = async (d) => {
|
|
@@ -2773,7 +2849,8 @@ var VaultService = class {
|
|
|
2773
2849
|
}], [], d.reads)).seqStart };
|
|
2774
2850
|
}, {
|
|
2775
2851
|
asked: reads,
|
|
2776
|
-
reads: true
|
|
2852
|
+
reads: true,
|
|
2853
|
+
deadline
|
|
2777
2854
|
});
|
|
2778
2855
|
if (!intent.ok) return intent;
|
|
2779
2856
|
intentSeq = intent.seq;
|
|
@@ -2796,7 +2873,7 @@ var VaultService = class {
|
|
|
2796
2873
|
if (first === void 0 || first === null) return;
|
|
2797
2874
|
throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, "deny", codes[item] ?? first)));
|
|
2798
2875
|
});
|
|
2799
|
-
const { outcomes, expired } = await settle(operations((secret) => context(secret, owners.get(secret.environmentId))), this.#prepared.options.keyBudgetMs);
|
|
2876
|
+
const { outcomes, expired } = await settle(operations((secret) => context(secret, owners.get(secret.environmentId))), within(deadline, this.#prepared.options.keyBudgetMs));
|
|
2800
2877
|
const done = outcomes.flatMap((outcome) => outcome.ok ? [outcome.value] : []);
|
|
2801
2878
|
try {
|
|
2802
2879
|
const entries = () => secrets.map((secret, i) => outcomeEntry(secret, i, "deny", outcomes[i].ok ? "withheld" : outcomes[i].code));
|
|
@@ -2807,13 +2884,13 @@ var VaultService = class {
|
|
|
2807
2884
|
if (expired) throw new Outage(new KekUnavailableError("key operation exceeded its deadline"), entries());
|
|
2808
2885
|
if (done.length < outcomes.length) throw new Refused(refusal("bad_claim", MESSAGES.bad_claim), entries());
|
|
2809
2886
|
if (late) {
|
|
2810
|
-
d.reads = await this.#snapshot(d.tx, [principal], reads, true);
|
|
2887
|
+
d.reads = await this.#snapshot(d.tx, [principal], reads, true, true);
|
|
2811
2888
|
d.at = this.#at(d.reads);
|
|
2812
2889
|
await decided(d);
|
|
2813
2890
|
if (secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]).some((place) => d.reads.environments.get(place.environmentId)?.deleted ?? false)) throw new Refused(refusal("deleted", MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, "deny", "deleted")));
|
|
2814
2891
|
}
|
|
2815
2892
|
const released = secrets.map((secret, i) => outcomeEntry(secret, i, "allow", null));
|
|
2816
|
-
d.log.push(...released);
|
|
2893
|
+
d.log.push(...released, ...call.also ?? []);
|
|
2817
2894
|
const answer = result(done);
|
|
2818
2895
|
if (action !== "secret.read") d.after.push((seqOf) => Object.assign(answer, { seqs: released.map(seqOf) }));
|
|
2819
2896
|
return answer;
|
|
@@ -2823,7 +2900,8 @@ var VaultService = class {
|
|
|
2823
2900
|
}, {
|
|
2824
2901
|
late,
|
|
2825
2902
|
asked: reads,
|
|
2826
|
-
reads: true
|
|
2903
|
+
reads: true,
|
|
2904
|
+
deadline
|
|
2827
2905
|
});
|
|
2828
2906
|
}
|
|
2829
2907
|
/**
|
|
@@ -3851,10 +3929,10 @@ var VaultService = class {
|
|
|
3851
3929
|
* service that cannot answer leaves it undecided: the error is thrown,
|
|
3852
3930
|
* and the next call asks again.
|
|
3853
3931
|
*/
|
|
3854
|
-
async #kekMismatch() {
|
|
3932
|
+
async #kekMismatch(deadline) {
|
|
3855
3933
|
const prepared = this.#prepared;
|
|
3856
3934
|
if (!prepared.kekChecked) {
|
|
3857
|
-
const wrong = await this.#checkKeks();
|
|
3935
|
+
const wrong = await this.#checkKeks(deadline);
|
|
3858
3936
|
if (!prepared.kekChecked) {
|
|
3859
3937
|
prepared.wrongKek = wrong;
|
|
3860
3938
|
prepared.kekChecked = true;
|
|
@@ -3868,15 +3946,16 @@ var VaultService = class {
|
|
|
3868
3946
|
* keys it wrapped, if there are any; then its check value is recorded.
|
|
3869
3947
|
* A KEK that opens neither is not the one that wrapped the data.
|
|
3870
3948
|
*/
|
|
3871
|
-
async #checkKeks() {
|
|
3949
|
+
async #checkKeks(deadline) {
|
|
3872
3950
|
const superseded = await this.#settled();
|
|
3873
3951
|
if (superseded !== null) return superseded;
|
|
3874
3952
|
const checks = await this.#checkValues(this.#db);
|
|
3875
3953
|
const budget = this.#prepared.options.keyBudgetMs;
|
|
3876
3954
|
for (const kek of this.#config.keks.all) {
|
|
3955
|
+
const spend = within(deadline, budget);
|
|
3877
3956
|
const operation = {
|
|
3878
|
-
deadline: Date.now() +
|
|
3879
|
-
signal: AbortSignal.timeout(
|
|
3957
|
+
deadline: Date.now() + spend,
|
|
3958
|
+
signal: AbortSignal.timeout(spend)
|
|
3880
3959
|
};
|
|
3881
3960
|
const opens = async (wrapped, context, expected) => {
|
|
3882
3961
|
try {
|
|
@@ -3911,6 +3990,7 @@ var VaultService = class {
|
|
|
3911
3990
|
if (samples.length > 0 && proof === null) return mismatch;
|
|
3912
3991
|
const wrapped = await kek.wrap(Buffer.from(KEY_CHECK_VALUE), KEY_CHECK_CONTEXT, operation);
|
|
3913
3992
|
await this.#db.transaction(async (tx) => {
|
|
3993
|
+
await boundLockWaits(tx, within(deadline, READ_LOCK_TIMEOUT_MS), true);
|
|
3914
3994
|
await lockLogHead(tx);
|
|
3915
3995
|
if ((await this.#checkValues(tx)).has(`${kek.provider}:${kek.keyId}`)) return;
|
|
3916
3996
|
await this.#append(tx, [{
|
|
@@ -4086,7 +4166,7 @@ var VaultService = class {
|
|
|
4086
4166
|
if (!isUnavailable(error)) throw error;
|
|
4087
4167
|
stalled = `the rehash of the past did not run, and goes on where it was next time: ${error.message}`;
|
|
4088
4168
|
}
|
|
4089
|
-
|
|
4169
|
+
const decided = await this.#decide([], async (d) => {
|
|
4090
4170
|
for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
|
|
4091
4171
|
const head = await lockLogHead(d.tx);
|
|
4092
4172
|
const latest = await latestVaultEntry(d.tx, [CHECKPOINT]);
|
|
@@ -4186,6 +4266,13 @@ var VaultService = class {
|
|
|
4186
4266
|
warning: stalled
|
|
4187
4267
|
};
|
|
4188
4268
|
});
|
|
4269
|
+
if (!decided.ok) return decided;
|
|
4270
|
+
const pruned = await pruneReleases(this.#db, RELEASES_PRUNED).then(() => null, (error) => `the releases the bulk limit counts were not pruned, and the next checkpoint tries again: ${error.message}`);
|
|
4271
|
+
if (pruned === null) return decided;
|
|
4272
|
+
return {
|
|
4273
|
+
...decided,
|
|
4274
|
+
warning: decided.warning === void 0 ? pruned : `${decided.warning}; ${pruned}`
|
|
4275
|
+
};
|
|
4189
4276
|
}
|
|
4190
4277
|
/**
|
|
4191
4278
|
* The walk on from where a checkpoint resumes, no further than `limit`
|
|
@@ -4659,6 +4746,39 @@ function validateText(value) {
|
|
|
4659
4746
|
if (typeof value !== "string" || /[\uD800-\uDFFF]/u.test(value)) throw new Error("key request strings must be well-formed Unicode");
|
|
4660
4747
|
}
|
|
4661
4748
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
4749
|
+
/**
|
|
4750
|
+
* The app's `credential.use` entry, as the vault appends it beside a read
|
|
4751
|
+
* (`UnwrapInput.use`): what the app wrote, checked to be what an entry
|
|
4752
|
+
* holds, in its reader's name. The app wrote it under the head itself
|
|
4753
|
+
* before 0.14.5; it describes the read, and decides nothing.
|
|
4754
|
+
*/
|
|
4755
|
+
function useEntry(principal, correlation, use) {
|
|
4756
|
+
const id = (value, name) => {
|
|
4757
|
+
if (value !== null && (typeof value !== "string" || !UUID.test(value))) throw new Error(`a use's ${name} must be a lowercase UUID or null`);
|
|
4758
|
+
return value;
|
|
4759
|
+
};
|
|
4760
|
+
if (typeof use.metadata !== "string" || use.metadata.length > USE_METADATA_LENGTH) throw new Error(`a use's metadata must be a JSON object of at most ${USE_METADATA_LENGTH} characters`);
|
|
4761
|
+
if (use.sourceIp !== null) validateText(use.sourceIp);
|
|
4762
|
+
validateText(use.metadata);
|
|
4763
|
+
let metadata;
|
|
4764
|
+
try {
|
|
4765
|
+
metadata = JSON.parse(use.metadata);
|
|
4766
|
+
} catch {
|
|
4767
|
+
metadata = void 0;
|
|
4768
|
+
}
|
|
4769
|
+
if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) throw new Error(`a use's metadata must be a JSON object of at most ${USE_METADATA_LENGTH} characters`);
|
|
4770
|
+
return {
|
|
4771
|
+
actor: principal,
|
|
4772
|
+
action: "credential.use",
|
|
4773
|
+
decision: "allow",
|
|
4774
|
+
projectId: id(use.projectId, "projectId"),
|
|
4775
|
+
environmentId: id(use.environmentId, "environmentId"),
|
|
4776
|
+
operationId: correlation.operationId ?? null,
|
|
4777
|
+
requestId: correlation.requestId ?? null,
|
|
4778
|
+
sourceIp: use.sourceIp,
|
|
4779
|
+
metadata: use.metadata
|
|
4780
|
+
};
|
|
4781
|
+
}
|
|
4662
4782
|
/** The caller's ids go into the log as they are: a request id is text, an operation id a lowercase UUID. */
|
|
4663
4783
|
function validateCorrelation(input) {
|
|
4664
4784
|
if (input.requestId != null) validateText(input.requestId);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffre/vault",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.6",
|
|
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.14.
|
|
40
|
-
"@coffre/db": "0.14.
|
|
39
|
+
"@coffre/core": "0.14.6",
|
|
40
|
+
"@coffre/db": "0.14.6"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "26.1.1",
|
package/src/config.ts
CHANGED
|
@@ -11,6 +11,14 @@ export type BulkLimit = { count: number; windowMs: number };
|
|
|
11
11
|
*/
|
|
12
12
|
export const DEFAULT_BULK_LIMIT: BulkLimit = { count: 1000, windowMs: 15 * 60_000 };
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The longest bulk limit window: a day. The releases a window counts are
|
|
16
|
+
* kept a day and an hour (migration 0021's `prune_vault_releases`, and on
|
|
17
|
+
* SQLite `RELEASES_KEPT_MS`), so every window up to it, and one widened
|
|
18
|
+
* since, counts exactly.
|
|
19
|
+
*/
|
|
20
|
+
export const MAX_BULK_WINDOW_MINUTES = 24 * 60;
|
|
21
|
+
|
|
14
22
|
/** The vault as it runs: keys decoded, emails checked. */
|
|
15
23
|
export type ResolvedVaultConfig = {
|
|
16
24
|
keks: KekRegistry;
|
|
@@ -61,7 +69,10 @@ export type VaultConfig = {
|
|
|
61
69
|
* service holds the KEK, as AWS KMS does: the vault never sees that key.
|
|
62
70
|
*/
|
|
63
71
|
signingKey?: string;
|
|
64
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* At most `count` distinct data keys unwrapped per principal in any `windowMinutes`; 1000 in 15 unless set. The
|
|
74
|
+
* window is a day at most: the releases it counts are kept a day and an hour (`prune_vault_releases`).
|
|
75
|
+
*/
|
|
65
76
|
bulkLimit?: { count: number; windowMinutes: number };
|
|
66
77
|
/**
|
|
67
78
|
* @deprecated Gone in 0.10, and refused: each key says who sees it now. Mark
|
|
@@ -176,8 +187,8 @@ function signingKeys(given: string | undefined, keks: { provider: KekProvider; s
|
|
|
176
187
|
|
|
177
188
|
|
|
178
189
|
function checkBulkLimit({ count, windowMinutes }: { count: number; windowMinutes: number }): BulkLimit {
|
|
179
|
-
if (!Number.isInteger(count) || count < 1 || !(windowMinutes > 0)) {
|
|
180
|
-
throw new Error(
|
|
190
|
+
if (!Number.isInteger(count) || count < 1 || !(windowMinutes > 0) || windowMinutes > MAX_BULK_WINDOW_MINUTES) {
|
|
191
|
+
throw new Error(`bulkLimit needs a whole count of at least 1 and a window above 0 minutes, a day (${MAX_BULK_WINDOW_MINUTES}) at most`);
|
|
181
192
|
}
|
|
182
193
|
return { count, windowMs: windowMinutes * 60_000 };
|
|
183
194
|
}
|
package/src/snapshot.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { StoredEntry } from '@coffre/core/audit';
|
|
2
2
|
import type { Queryable, Transaction } from '@coffre/db';
|
|
3
|
-
import { LogHeadMismatch, lockLogHead } from '@coffre/db/log';
|
|
3
|
+
import { LogHeadMismatch, lockLogHead, lockLogHeadQueued } from '@coffre/db/log';
|
|
4
4
|
import { engineOf, rowsOf } from '@coffre/db/dialect';
|
|
5
5
|
import type { GrantRow } from '@coffre/db/grants';
|
|
6
6
|
|
|
@@ -83,6 +83,12 @@ export type Asked = {
|
|
|
83
83
|
released?: { principal: string; keys: readonly string[]; windowMs: number; clockOffsetMs: number; cap: number };
|
|
84
84
|
/** Lock the log's head first. */
|
|
85
85
|
head?: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Held by another, wait for the head only in one of this many places in
|
|
88
|
+
* line, and refuse at once when none is free (`lockLogHeadQueued`); with
|
|
89
|
+
* none, wait as long as the transaction's bound lets.
|
|
90
|
+
*/
|
|
91
|
+
waiters?: number;
|
|
86
92
|
};
|
|
87
93
|
|
|
88
94
|
/** One statement's read, as `Snapshot` says; with the head, two more when another holds it. */
|
|
@@ -94,7 +100,7 @@ export async function snapshot(db: Queryable, asked: Asked): Promise<Snapshot> {
|
|
|
94
100
|
if (asked.head !== true || (first.head !== null && current(first.head))) return first;
|
|
95
101
|
// Held by another, or moved in the instant before the lock: everything again, once the head is this transaction's.
|
|
96
102
|
// Asked for the head, `db` is a transaction (the overloads above).
|
|
97
|
-
if (first.head === null) await lockLogHead(db as Transaction);
|
|
103
|
+
if (first.head === null) await (asked.waiters === undefined ? lockLogHead(db as Transaction) : lockLogHeadQueued(db as Transaction, asked.waiters));
|
|
98
104
|
const again = await read();
|
|
99
105
|
if (again.head === null) throw new Error("the log's head, held by this transaction, was read as held by another");
|
|
100
106
|
// Held, nothing appends: a head that names another last entry was moved, or the log written, around it (`appendEntries`).
|
package/src/store.ts
CHANGED
|
@@ -2,9 +2,11 @@ import { EVERYWHERE, ownsInstance, type InstanceRole, type Scope } from '@coffre
|
|
|
2
2
|
import type { Author, StoredEntry } from '@coffre/core/audit';
|
|
3
3
|
import { ACCESS_ACTIONS, type SecretRef, type WrappedKey } from '@coffre/core/vault';
|
|
4
4
|
import { prepared, tablesOf, type Queryable, type Transaction } from '@coffre/db';
|
|
5
|
-
import { clockMillis, engineOf, forUpdate, tombstone, truth } from '@coffre/db/dialect';
|
|
5
|
+
import { changedRows, clockMillis, engineOf, forUpdate, rowsOf, tombstone, truth } from '@coffre/db/dialect';
|
|
6
6
|
import { readGrants, roleColumns, storedRole, type GrantRow } from '@coffre/db/grants';
|
|
7
7
|
import { entryColumns, withinBytes } from '@coffre/db/log';
|
|
8
|
+
|
|
9
|
+
import { MAX_BULK_WINDOW_MINUTES } from './config.ts';
|
|
8
10
|
import { and, asc, count, desc, eq, gt, gte, inArray, isNotNull, isNull, like, lt, lte, or, sql, type SQL, type SQLWrapper } from 'drizzle-orm';
|
|
9
11
|
|
|
10
12
|
/**
|
|
@@ -67,6 +69,33 @@ export async function boundLockWaits(tx: Transaction, ms: number, statements = f
|
|
|
67
69
|
: sql.raw(`SET LOCAL lock_timeout = ${bound}`));
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
/**
|
|
73
|
+
* How long a release is kept: the longest bulk limit window
|
|
74
|
+
* (`MAX_BULK_WINDOW_MINUTES`, a day), and an hour, a margin for the
|
|
75
|
+
* database's clock stepping back between a prune and a later count, which
|
|
76
|
+
* is seconds. Migration 0021's `prune_vault_releases` holds the same, a day
|
|
77
|
+
* and an hour, in its own SQL: a longer window takes a migration too.
|
|
78
|
+
*/
|
|
79
|
+
export const RELEASES_KEPT_MS = MAX_BULK_WINDOW_MINUTES * 60_000 + 3_600_000;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Delete up to `atMost` rows of `vault_releases` released over
|
|
83
|
+
* `RELEASES_KEPT_MS` before the database's clock: what no bulk count can
|
|
84
|
+
* read again. On Postgres the vault's login deletes no row itself, and asks
|
|
85
|
+
* `prune_vault_releases`, which deletes only those (migration 0021). How
|
|
86
|
+
* many went.
|
|
87
|
+
*/
|
|
88
|
+
export async function pruneReleases(db: Queryable, atMost: number): Promise<number> {
|
|
89
|
+
if (engineOf(db) === 'postgres') {
|
|
90
|
+
const [row] = await rowsOf<{ pruned: number }>(db, sql`SELECT prune_vault_releases(${atMost}::int) AS pruned`);
|
|
91
|
+
return row!.pruned;
|
|
92
|
+
}
|
|
93
|
+
const { vaultReleases } = tablesOf(db);
|
|
94
|
+
const old = db.select({ principal: vaultReleases.principal, key: vaultReleases.key }).from(vaultReleases)
|
|
95
|
+
.where(lt(vaultReleases.releasedAt, sql`${clockMillis(db)} - ${RELEASES_KEPT_MS}`)).limit(atMost);
|
|
96
|
+
return changedRows(await db.delete(vaultReleases).where(sql`(${vaultReleases.principal}, ${vaultReleases.key}) IN ${old}`));
|
|
97
|
+
}
|
|
98
|
+
|
|
70
99
|
/** The database's clock, now. */
|
|
71
100
|
export async function now(db: Queryable): Promise<number> {
|
|
72
101
|
const [{ at }] = await clock(db);
|
package/src/vault.ts
CHANGED
|
@@ -91,6 +91,9 @@ import {
|
|
|
91
91
|
type Settings,
|
|
92
92
|
type Through,
|
|
93
93
|
type UnwrapInput,
|
|
94
|
+
USE_METADATA_LENGTH,
|
|
95
|
+
type Unwrapped,
|
|
96
|
+
type UseRecord,
|
|
94
97
|
type Wrapped,
|
|
95
98
|
type Vault,
|
|
96
99
|
type Via,
|
|
@@ -101,7 +104,7 @@ import {
|
|
|
101
104
|
import type { Database, Queryable, Transaction } from '@coffre/db';
|
|
102
105
|
import { isUnavailable, isUniqueViolation, SNAPSHOT } from '@coffre/db/dialect';
|
|
103
106
|
import { everyProjectConversion, newestAccess, reportStands } from '@coffre/db/grants';
|
|
104
|
-
import { appendEntries, lockLogHead, missingEntry, newestAuthentic, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
|
|
107
|
+
import { appendEntries, lockLogHead, lockLogHeadQueued, missingEntry, newestAuthentic, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
|
|
105
108
|
|
|
106
109
|
import { signer, type Signer } from './checkpoint.ts';
|
|
107
110
|
import { keyRefusals, placeIn, referenceRefusal, refuses, sensitiveRefusals, useOnlyRefusals, type SecretPlace } from './decide.ts';
|
|
@@ -212,6 +215,46 @@ const LOCK_TIMEOUT_MS = 15_000;
|
|
|
212
215
|
*/
|
|
213
216
|
const READ_LOCK_TIMEOUT_MS = 3_000;
|
|
214
217
|
|
|
218
|
+
/**
|
|
219
|
+
* How long a whole read call may take, from its start to its answer, its
|
|
220
|
+
* refusal's entries, the record of a vault key's check and, with a key
|
|
221
|
+
* service, its intent and its own budget (added) included: each transaction's waits are bounded by what is left of it, and
|
|
222
|
+
* past it the call fails as unavailable (`Overdue`) rather than start
|
|
223
|
+
* another. So a caller hears within it, ok or 503, never tens of seconds later.
|
|
224
|
+
*/
|
|
225
|
+
export const READ_DEADLINE_MS = 5_000;
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* How many reads may wait for the log's head at once, through all of the
|
|
229
|
+
* vault's connections; the next is refused at once, as unavailable, holding
|
|
230
|
+
* no connection while it waits (`lockLogHeadQueued`). A read holds the head
|
|
231
|
+
* for a few round trips, about 150 ms through Hyperdrive, so this many
|
|
232
|
+
* clear within a read's bound (2.4 s of its 3), and no more than this many
|
|
233
|
+
* of the pool's connections wait. A burst of 50 reads at a 2-vCPU database
|
|
234
|
+
* a 30 ms round trip away: 17 read within 2.2 s, 33 refused at once.
|
|
235
|
+
*/
|
|
236
|
+
export const READ_HEAD_WAITERS = 16;
|
|
237
|
+
|
|
238
|
+
/** How many releases a checkpoint prunes at most: one short statement, a few each scheduled run. */
|
|
239
|
+
const RELEASES_PRUNED = 10_000;
|
|
240
|
+
|
|
241
|
+
/** A read's call past its deadline (`READ_DEADLINE_MS`): unavailable, as a statement past its bound (57014) is. */
|
|
242
|
+
class Overdue extends Error {
|
|
243
|
+
readonly code = '57014';
|
|
244
|
+
constructor() {
|
|
245
|
+
super('this read took longer than it may: ask again shortly');
|
|
246
|
+
this.name = 'Overdue';
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The bound of a transaction of a call due at `deadline`: `ms`, or what is left of the call, if less. */
|
|
251
|
+
function within(deadline: number | undefined, ms: number): number {
|
|
252
|
+
if (deadline === undefined) return ms;
|
|
253
|
+
const left = deadline - Date.now();
|
|
254
|
+
if (left <= 0) throw new Overdue();
|
|
255
|
+
return Math.min(ms, left);
|
|
256
|
+
}
|
|
257
|
+
|
|
215
258
|
/** A member: `user:<email>` or `token:<name>`. */
|
|
216
259
|
const PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
|
|
217
260
|
|
|
@@ -406,7 +449,12 @@ type Reads = Pick<Asked, 'environments' | 'references'> & { released?: { princip
|
|
|
406
449
|
* is what it reads besides its members, and `reads` says it changes none
|
|
407
450
|
* of them (`#decide`).
|
|
408
451
|
*/
|
|
409
|
-
|
|
452
|
+
/**
|
|
453
|
+
* How a decision is made. `deadline`, a read's (`reads`): when its whole
|
|
454
|
+
* call must have answered, every wait in every transaction it takes
|
|
455
|
+
* included (`READ_DEADLINE_MS`).
|
|
456
|
+
*/
|
|
457
|
+
type How = { late?: boolean; asked?: Reads; reads?: boolean; deadline?: number };
|
|
410
458
|
|
|
411
459
|
/** The actions of the entries the vault writes about keys. */
|
|
412
460
|
type KeyAction = 'secret.read' | 'key.wrap' | 'key.rewrap';
|
|
@@ -492,12 +540,14 @@ class VaultService implements Vault {
|
|
|
492
540
|
}
|
|
493
541
|
|
|
494
542
|
/** What a decision about `principals` reads, and `asked` besides, in one statement: under the head when `head` is set. */
|
|
495
|
-
#snapshot(tx: Transaction, principals: readonly string[], asked: Reads, head: boolean): Promise<Snapshot> {
|
|
543
|
+
#snapshot(tx: Transaction, principals: readonly string[], asked: Reads, head: boolean, reading = false): Promise<Snapshot> {
|
|
496
544
|
const { released, ...rest } = asked;
|
|
497
545
|
return snapshot(tx, {
|
|
498
546
|
...rest,
|
|
499
547
|
principals,
|
|
500
548
|
head,
|
|
549
|
+
// A read waits for the head in one of a few places in line, or is refused at once.
|
|
550
|
+
...(reading ? { waiters: READ_HEAD_WAITERS } : {}),
|
|
501
551
|
...(released === undefined ? {} : { released: { ...released, windowMs: this.#config.bulkLimit.windowMs, clockOffsetMs: this.#prepared.options.clockOffset(), cap: this.#config.bulkLimit.count } }),
|
|
502
552
|
});
|
|
503
553
|
}
|
|
@@ -525,9 +575,11 @@ class VaultService implements Vault {
|
|
|
525
575
|
/** `#decide`, once the vault is settled. */
|
|
526
576
|
async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, how: How = {}): Promise<Outcome<T>> {
|
|
527
577
|
const reports: NewEntry[] = [];
|
|
578
|
+
// A read's call is due from its first decision, unless its caller set when.
|
|
579
|
+
const due = how.reads === true ? { ...how, deadline: how.deadline ?? Date.now() + READ_DEADLINE_MS } : how;
|
|
528
580
|
for (let attempt = 1; ; attempt += 1) {
|
|
529
581
|
try {
|
|
530
|
-
return await this.#decideOnce(principals, decide, reports,
|
|
582
|
+
return await this.#decideOnce(principals, decide, reports, due);
|
|
531
583
|
} catch (error) {
|
|
532
584
|
// A member who had no row when this decision locked theirs, and has
|
|
533
585
|
// one now: another decision admitted them meanwhile. Again, with it.
|
|
@@ -537,13 +589,13 @@ class VaultService implements Vault {
|
|
|
537
589
|
}
|
|
538
590
|
}
|
|
539
591
|
|
|
540
|
-
async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], { late = false, asked = {}, reads: shared = false }: How): Promise<Outcome<T>> {
|
|
592
|
+
async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], { late = false, asked = {}, reads: shared = false, deadline }: How): Promise<Outcome<T>> {
|
|
541
593
|
try {
|
|
542
594
|
const result = await this.#db.transaction(async (tx) => {
|
|
543
|
-
await store.boundLockWaits(tx, shared ? READ_LOCK_TIMEOUT_MS : LOCK_TIMEOUT_MS, shared);
|
|
595
|
+
await store.boundLockWaits(tx, shared ? within(deadline, READ_LOCK_TIMEOUT_MS) : LOCK_TIMEOUT_MS, shared);
|
|
544
596
|
const members = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals, shared);
|
|
545
597
|
// Then the head, and everything the decision reads, in one statement (`snapshot` says why it holds).
|
|
546
|
-
const reads = await this.#snapshot(tx, principals, asked, !late);
|
|
598
|
+
const reads = await this.#snapshot(tx, principals, asked, !late, shared);
|
|
547
599
|
const d: Decision = {
|
|
548
600
|
tx, members, at: this.#at(reads), reads, log: [], writes: [], touched: new Set(), grants: new Map(), reports, after: [], underLock: [],
|
|
549
601
|
};
|
|
@@ -577,9 +629,12 @@ class VaultService implements Vault {
|
|
|
577
629
|
if (!(error instanceof Refused || error instanceof Outage)) throw error;
|
|
578
630
|
const entries = [...reports, ...readsByPlace(error.entries)];
|
|
579
631
|
if (entries.length > 0) {
|
|
580
|
-
// A refusal's entries are written whatever the decision's bound: it answers only once they are.
|
|
632
|
+
// A refusal's entries are written whatever the decision's bound: it answers only once they are. A read's, within
|
|
633
|
+
// its call's deadline: past it, the call fails as unavailable, having released nothing.
|
|
581
634
|
await this.#db.transaction(async (tx) => {
|
|
582
|
-
await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
635
|
+
await store.boundLockWaits(tx, shared ? within(deadline, READ_LOCK_TIMEOUT_MS) : LOCK_TIMEOUT_MS, shared);
|
|
636
|
+
// In one of the head's places in line, as the read's decision waited, or refused at once.
|
|
637
|
+
if (shared) await lockLogHeadQueued(tx, READ_HEAD_WAITERS);
|
|
583
638
|
await this.#append(tx, entries);
|
|
584
639
|
});
|
|
585
640
|
}
|
|
@@ -928,8 +983,9 @@ class VaultService implements Vault {
|
|
|
928
983
|
|
|
929
984
|
// Raw DEKs are cleared on every path. JSON and base64 leave strings that
|
|
930
985
|
// cannot be wiped, so this is best-effort memory hygiene.
|
|
931
|
-
async unwrap(input: UnwrapInput): Promise<Outcome<
|
|
986
|
+
async unwrap(input: UnwrapInput): Promise<Outcome<Unwrapped>> {
|
|
932
987
|
const { principal } = input;
|
|
988
|
+
const use = input.use === undefined ? null : useEntry(principal, input, input.use);
|
|
933
989
|
validateText(input.purpose);
|
|
934
990
|
if (input.keepsSensitive !== undefined && typeof input.keepsSensitive !== 'boolean') throw new Error('keepsSensitive is true, false or absent');
|
|
935
991
|
if (input.accessRevision !== undefined && typeof input.accessRevision !== 'string') throw new Error('accessRevision names an answer of access, or is absent');
|
|
@@ -975,6 +1031,17 @@ class VaultService implements Vault {
|
|
|
975
1031
|
if (throughOk.includes(false)) {
|
|
976
1032
|
return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')), { asked: input.accessRevision });
|
|
977
1033
|
}
|
|
1034
|
+
// The app's record of a use names where the read was, which the vault vouches for once it appends it: the place an
|
|
1035
|
+
// item was read at, the fork it is read in, else a reference's holder, else its own, with its project. Another is
|
|
1036
|
+
// a claim the read does not bear out, refused and logged as one; no use is appended.
|
|
1037
|
+
if (use !== null && (use.environmentId !== null || use.projectId !== null)) {
|
|
1038
|
+
const named = items.some((item, i) => {
|
|
1039
|
+
const fork = throughs[i] === null ? undefined : forks.get(throughs[i]!);
|
|
1040
|
+
const place = fork === undefined ? sealed[i]?.holder ?? item.secret : { projectId: fork.projectId, environmentId: throughs[i]! };
|
|
1041
|
+
return (use.environmentId === null || place.environmentId === use.environmentId) && (use.projectId === null || place.projectId === use.projectId);
|
|
1042
|
+
});
|
|
1043
|
+
if (!named) return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')), { asked: input.accessRevision });
|
|
1044
|
+
}
|
|
978
1045
|
const ends = sealed.flatMap((seal) => (seal === null ? [] : [seal.seq]));
|
|
979
1046
|
const remote = items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
|
|
980
1047
|
return this.#keys(
|
|
@@ -993,6 +1060,7 @@ class VaultService implements Vault {
|
|
|
993
1060
|
remote,
|
|
994
1061
|
entry,
|
|
995
1062
|
input,
|
|
1063
|
+
...(use === null ? {} : { also: [use] }),
|
|
996
1064
|
},
|
|
997
1065
|
(contextOf) =>
|
|
998
1066
|
items.map(({ secret, wrapped }) => async (operation: KeyOperation) => {
|
|
@@ -1009,6 +1077,8 @@ class VaultService implements Vault {
|
|
|
1009
1077
|
}),
|
|
1010
1078
|
// Named back: the read was decided on that answer, which this vault checked.
|
|
1011
1079
|
...(input.accessRevision === undefined ? {} : { accessRevision: input.accessRevision }),
|
|
1080
|
+
// And the use, appended with the read: the app has nothing left to append.
|
|
1081
|
+
...(use === null ? {} : { used: true as const }),
|
|
1012
1082
|
}),
|
|
1013
1083
|
);
|
|
1014
1084
|
}
|
|
@@ -1224,6 +1294,8 @@ class VaultService implements Vault {
|
|
|
1224
1294
|
viaReference?: readonly boolean[];
|
|
1225
1295
|
remote: boolean;
|
|
1226
1296
|
entry: (secret: SecretRef, decision: 'allow' | 'deny', code: string | null) => NewEntry;
|
|
1297
|
+
/** Entries appended with the release, after its own, only once the keys are released: the app's record of a use. */
|
|
1298
|
+
also?: readonly NewEntry[];
|
|
1227
1299
|
input: Correlation & Through & { purpose?: string; keepsSensitive?: boolean; accessRevision?: string };
|
|
1228
1300
|
},
|
|
1229
1301
|
/**
|
|
@@ -1236,11 +1308,14 @@ class VaultService implements Vault {
|
|
|
1236
1308
|
): Promise<Outcome<R>> {
|
|
1237
1309
|
const { action, principal, secrets, entry } = call;
|
|
1238
1310
|
for (const secret of secrets) entry(secret, 'allow', null);
|
|
1239
|
-
|
|
1311
|
+
// One deadline for the call: the record of the KEK's check, the decisions, the intent's and the keys', and, with a
|
|
1312
|
+
// key service, its own budget besides, spent from it too.
|
|
1313
|
+
const deadline = Date.now() + READ_DEADLINE_MS + (call.remote ? this.#prepared.options.keyBudgetMs : 0);
|
|
1314
|
+
const wrongKek = await this.#kekMismatch(deadline);
|
|
1240
1315
|
if (wrongKek !== null) {
|
|
1241
1316
|
return this.#decide([], async () => {
|
|
1242
1317
|
throw new Refused(refusal('wrong_kek', wrongKek), secrets.map((secret) => entry(secret, 'deny', 'wrong_kek')));
|
|
1243
|
-
});
|
|
1318
|
+
}, { reads: true, deadline });
|
|
1244
1319
|
}
|
|
1245
1320
|
// Each personal config's owner, hashed, by environment, as the decision read them: what its keys' contexts bind.
|
|
1246
1321
|
const owners = new Map<string, string>();
|
|
@@ -1319,7 +1394,7 @@ class VaultService implements Vault {
|
|
|
1319
1394
|
}),
|
|
1320
1395
|
}], [], d.reads);
|
|
1321
1396
|
return { seq: appended.seqStart };
|
|
1322
|
-
}, { asked: reads, reads: true });
|
|
1397
|
+
}, { asked: reads, reads: true, deadline });
|
|
1323
1398
|
if (!intent.ok) return intent;
|
|
1324
1399
|
intentSeq = intent.seq;
|
|
1325
1400
|
}
|
|
@@ -1355,7 +1430,7 @@ class VaultService implements Vault {
|
|
|
1355
1430
|
throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, 'deny', codes[item] ?? first)));
|
|
1356
1431
|
});
|
|
1357
1432
|
}
|
|
1358
|
-
const { outcomes, expired } = await settle(operations((secret) => context(secret, owners.get(secret.environmentId))), this.#prepared.options.keyBudgetMs);
|
|
1433
|
+
const { outcomes, expired } = await settle(operations((secret) => context(secret, owners.get(secret.environmentId))), within(deadline, this.#prepared.options.keyBudgetMs));
|
|
1359
1434
|
const done = outcomes.flatMap((outcome) => (outcome.ok ? [outcome.value] : []));
|
|
1360
1435
|
try {
|
|
1361
1436
|
const entries = () => secrets.map((secret, i) =>
|
|
@@ -1381,7 +1456,7 @@ class VaultService implements Vault {
|
|
|
1381
1456
|
// A read through a reference: its holder's place too, which a deletion may have taken.
|
|
1382
1457
|
// And as the policy is now, under the head every change to it takes: an environment protected, or a role narrowed,
|
|
1383
1458
|
// since the check above is seen here, and nothing it refuses now is released. All of it read again, holding the head.
|
|
1384
|
-
d.reads = await this.#snapshot(d.tx, [principal], reads, true);
|
|
1459
|
+
d.reads = await this.#snapshot(d.tx, [principal], reads, true, true);
|
|
1385
1460
|
d.at = this.#at(d.reads);
|
|
1386
1461
|
await decided(d);
|
|
1387
1462
|
const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
|
|
@@ -1390,7 +1465,7 @@ class VaultService implements Vault {
|
|
|
1390
1465
|
}
|
|
1391
1466
|
}
|
|
1392
1467
|
const released = secrets.map((secret, i) => outcomeEntry(secret, i, 'allow', null));
|
|
1393
|
-
d.log.push(...released);
|
|
1468
|
+
d.log.push(...released, ...(call.also ?? []));
|
|
1394
1469
|
const answer = result(done);
|
|
1395
1470
|
// A wrap's answer names its entries, which the app's writes refer to.
|
|
1396
1471
|
if (action !== 'secret.read') d.after.push((seqOf) => Object.assign(answer as object, { seqs: released.map(seqOf) }));
|
|
@@ -1398,7 +1473,7 @@ class VaultService implements Vault {
|
|
|
1398
1473
|
} finally {
|
|
1399
1474
|
for (const value of done) value.wipe();
|
|
1400
1475
|
}
|
|
1401
|
-
}, { late, asked: reads, reads: true });
|
|
1476
|
+
}, { late, asked: reads, reads: true, deadline });
|
|
1402
1477
|
}
|
|
1403
1478
|
|
|
1404
1479
|
/**
|
|
@@ -2404,10 +2479,10 @@ class VaultService implements Vault {
|
|
|
2404
2479
|
* service that cannot answer leaves it undecided: the error is thrown,
|
|
2405
2480
|
* and the next call asks again.
|
|
2406
2481
|
*/
|
|
2407
|
-
async #kekMismatch(): Promise<string | null> {
|
|
2482
|
+
async #kekMismatch(deadline?: number): Promise<string | null> {
|
|
2408
2483
|
const prepared = this.#prepared;
|
|
2409
2484
|
if (!prepared.kekChecked) {
|
|
2410
|
-
const wrong = await this.#checkKeks();
|
|
2485
|
+
const wrong = await this.#checkKeks(deadline);
|
|
2411
2486
|
if (!prepared.kekChecked) {
|
|
2412
2487
|
prepared.wrongKek = wrong;
|
|
2413
2488
|
prepared.kekChecked = true;
|
|
@@ -2422,14 +2497,16 @@ class VaultService implements Vault {
|
|
|
2422
2497
|
* keys it wrapped, if there are any; then its check value is recorded.
|
|
2423
2498
|
* A KEK that opens neither is not the one that wrapped the data.
|
|
2424
2499
|
*/
|
|
2425
|
-
async #checkKeks(): Promise<string | null> {
|
|
2500
|
+
async #checkKeks(deadline?: number): Promise<string | null> {
|
|
2426
2501
|
// A vault that may not write cannot record a check either; why it may not is the answer.
|
|
2427
2502
|
const superseded = await this.#settled();
|
|
2428
2503
|
if (superseded !== null) return superseded;
|
|
2429
2504
|
const checks = await this.#checkValues(this.#db);
|
|
2430
2505
|
const budget = this.#prepared.options.keyBudgetMs;
|
|
2431
2506
|
for (const kek of this.#config.keks.all) {
|
|
2432
|
-
|
|
2507
|
+
// A read's, within what is left of its deadline.
|
|
2508
|
+
const spend = within(deadline, budget);
|
|
2509
|
+
const operation = { deadline: Date.now() + spend, signal: AbortSignal.timeout(spend) };
|
|
2433
2510
|
const opens = async (wrapped: WrappedDek, context: SecretContext, expected?: Buffer) => {
|
|
2434
2511
|
try {
|
|
2435
2512
|
const key = await kek.unwrap(wrapped, context, operation);
|
|
@@ -2460,6 +2537,8 @@ class VaultService implements Vault {
|
|
|
2460
2537
|
// The key it opened, if any, is in the log, as every key the vault opens is. Another call that
|
|
2461
2538
|
// checked at the same time may have recorded one first: under the log's lock, the first stays.
|
|
2462
2539
|
await this.#db.transaction(async (tx) => {
|
|
2540
|
+
// Once a process, by its first key operation: a read's waits as the read's, within its deadline.
|
|
2541
|
+
await store.boundLockWaits(tx, within(deadline, READ_LOCK_TIMEOUT_MS), true);
|
|
2463
2542
|
await lockLogHead(tx);
|
|
2464
2543
|
if ((await this.#checkValues(tx)).has(`${kek.provider}:${kek.keyId}`)) return;
|
|
2465
2544
|
await this.#append(tx, [
|
|
@@ -2607,7 +2686,7 @@ class VaultService implements Vault {
|
|
|
2607
2686
|
stalled = `the rehash of the past did not run, and goes on where it was next time: ${(error as Error).message}`;
|
|
2608
2687
|
}
|
|
2609
2688
|
}
|
|
2610
|
-
|
|
2689
|
+
const decided = await this.#decide([], async (d) => {
|
|
2611
2690
|
for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
|
|
2612
2691
|
// Checkpoints one at a time, each against the one before.
|
|
2613
2692
|
const head = await lockLogHead(d.tx);
|
|
@@ -2683,6 +2762,15 @@ class VaultService implements Vault {
|
|
|
2683
2762
|
d.log.push({ actor: SCHEDULER, action: CHECKPOINT, decision: 'allow', relatedSeq: at.seq, metadata: JSON.stringify(kept) });
|
|
2684
2763
|
return stalled === null ? { checkpoint } : { checkpoint, warning: stalled };
|
|
2685
2764
|
});
|
|
2765
|
+
// Then the bulk count's upkeep, which no read waits on, after a checkpoint signed, never instead of a refusal: the
|
|
2766
|
+
// releases no window can count again, kept a day and an hour (`RELEASES_KEPT_MS`), longer than any window, up to
|
|
2767
|
+
// `RELEASES_PRUNED` of them, in one statement of its own, holding no lock any read takes but the rows it deletes,
|
|
2768
|
+
// and none an append holds. What is kept is the database's to say, not the caller's: no login forgets a release a
|
|
2769
|
+
// window counts. A failure leaves it to the next checkpoint, and says so.
|
|
2770
|
+
if (!decided.ok) return decided;
|
|
2771
|
+
const pruned = await store.pruneReleases(this.#db, RELEASES_PRUNED).then(() => null, (error: Error) => `the releases the bulk limit counts were not pruned, and the next checkpoint tries again: ${error.message}`);
|
|
2772
|
+
if (pruned === null) return decided;
|
|
2773
|
+
return { ...decided, warning: decided.warning === undefined ? pruned : `${decided.warning}; ${pruned}` };
|
|
2686
2774
|
}
|
|
2687
2775
|
|
|
2688
2776
|
/**
|
|
@@ -3088,6 +3176,45 @@ function validateText(value: string): void {
|
|
|
3088
3176
|
|
|
3089
3177
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
3090
3178
|
|
|
3179
|
+
/**
|
|
3180
|
+
* The app's `credential.use` entry, as the vault appends it beside a read
|
|
3181
|
+
* (`UnwrapInput.use`): what the app wrote, checked to be what an entry
|
|
3182
|
+
* holds, in its reader's name. The app wrote it under the head itself
|
|
3183
|
+
* before 0.14.5; it describes the read, and decides nothing.
|
|
3184
|
+
*/
|
|
3185
|
+
function useEntry(principal: string, correlation: Correlation, use: UseRecord): NewEntry {
|
|
3186
|
+
const id = (value: unknown, name: string) => {
|
|
3187
|
+
if (value !== null && (typeof value !== 'string' || !UUID.test(value))) throw new Error(`a use's ${name} must be a lowercase UUID or null`);
|
|
3188
|
+
return value;
|
|
3189
|
+
};
|
|
3190
|
+
// The bound first: nothing past it is read.
|
|
3191
|
+
if (typeof use.metadata !== 'string' || use.metadata.length > USE_METADATA_LENGTH) {
|
|
3192
|
+
throw new Error(`a use's metadata must be a JSON object of at most ${USE_METADATA_LENGTH} characters`);
|
|
3193
|
+
}
|
|
3194
|
+
if (use.sourceIp !== null) validateText(use.sourceIp);
|
|
3195
|
+
validateText(use.metadata);
|
|
3196
|
+
let metadata: unknown;
|
|
3197
|
+
try {
|
|
3198
|
+
metadata = JSON.parse(use.metadata);
|
|
3199
|
+
} catch {
|
|
3200
|
+
metadata = undefined;
|
|
3201
|
+
}
|
|
3202
|
+
if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
|
|
3203
|
+
throw new Error(`a use's metadata must be a JSON object of at most ${USE_METADATA_LENGTH} characters`);
|
|
3204
|
+
}
|
|
3205
|
+
return {
|
|
3206
|
+
actor: principal,
|
|
3207
|
+
action: 'credential.use',
|
|
3208
|
+
decision: 'allow',
|
|
3209
|
+
projectId: id(use.projectId, 'projectId'),
|
|
3210
|
+
environmentId: id(use.environmentId, 'environmentId'),
|
|
3211
|
+
operationId: correlation.operationId ?? null,
|
|
3212
|
+
requestId: correlation.requestId ?? null,
|
|
3213
|
+
sourceIp: use.sourceIp,
|
|
3214
|
+
metadata: use.metadata,
|
|
3215
|
+
};
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3091
3218
|
/** The caller's ids go into the log as they are: a request id is text, an operation id a lowercase UUID. */
|
|
3092
3219
|
function validateCorrelation(input: Correlation): void {
|
|
3093
3220
|
if (input.requestId != null) validateText(input.requestId);
|