@cosmicdrift/kumiko-framework 0.288.0 → 0.290.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/package.json +4 -4
- package/src/__tests__/field-access.integration.test.ts +7 -3
- package/src/__tests__/ownership-where-write-path.integration.test.ts +1 -1
- package/src/__tests__/ownership.integration.test.ts +1 -1
- package/src/api/__tests__/server-boot-guards.test.ts +42 -0
- package/src/api/request-context.ts +28 -0
- package/src/api/server.ts +30 -4
- package/src/changes.json +75 -0
- package/src/compliance/__tests__/sub-processors.test.ts +10 -0
- package/src/compliance/sub-processors.ts +14 -1
- package/src/crypto/__tests__/blind-index.test.ts +1 -1
- package/src/crypto/__tests__/kek-source.test.ts +298 -0
- package/src/crypto/__tests__/pii-field-encryption.test.ts +2 -2
- package/src/crypto/__tests__/subject-resolver.test.ts +2 -2
- package/src/crypto/index.ts +3 -0
- package/src/crypto/kek-source.ts +189 -0
- package/src/crypto/kms-wiring.ts +20 -0
- package/src/db/__tests__/blind-index.integration.test.ts +1 -1
- package/src/db/__tests__/eagerload.integration.test.ts +12 -2
- package/src/db/__tests__/entity-field-encryption.test.ts +2 -2
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +1 -1
- package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +7 -2
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +194 -1
- package/src/db/__tests__/event-store-executor.integration.test.ts +15 -5
- package/src/db/__tests__/list-filter-field-access.integration.test.ts +6 -2
- package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +60 -1
- package/src/db/event-store-executor-context.ts +79 -0
- package/src/db/event-store-executor-write.ts +42 -5
- package/src/db/index.ts +1 -0
- package/src/db/tenant-db.ts +8 -1
- package/src/engine/__tests__/boot-validator-action-wiring.test.ts +32 -0
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +56 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +26 -15
- package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +6 -2
- package/src/engine/__tests__/factories-long-text.test.ts +6 -1
- package/src/engine/__tests__/required-surface-keys.test.ts +196 -0
- package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +63 -0
- package/src/engine/boot-validator/__tests__/record-owned.test.ts +12 -2
- package/src/engine/boot-validator/action-wiring.ts +2 -1
- package/src/engine/boot-validator/entity-handler.ts +10 -4
- package/src/engine/boot-validator/screens.ts +4 -12
- package/src/engine/extensions/storage-provider.ts +17 -2
- package/src/engine/extensions/tenant-data.ts +9 -0
- package/src/engine/factories.ts +2 -0
- package/src/engine/index.ts +1 -0
- package/src/engine/qualified-name.ts +9 -0
- package/src/engine/screen-helpers.ts +17 -0
- package/src/errors/classes.ts +24 -0
- package/src/errors/index.ts +3 -0
- package/src/errors/member-resolution.ts +12 -0
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +2 -2
- package/src/event-store/__tests__/event-attribution.integration.test.ts +186 -0
- package/src/event-store/__tests__/provenance-append.integration.test.ts +33 -1
- package/src/event-store/event-store.ts +23 -2
- package/src/event-store/provenance-append.ts +9 -1
- package/src/files/__tests__/file-handle.test.ts +28 -1
- package/src/files/file-handle.ts +26 -7
- package/src/files/index.ts +1 -1
- package/src/i18n/required-surface-keys.ts +24 -12
- package/src/jobs/job-runner.ts +10 -2
- package/src/pipeline/__tests__/member-resolution-read-only.test.ts +79 -0
- package/src/pipeline/active-membership.ts +10 -4
- package/src/pipeline/append-event-core.ts +2 -11
- package/src/pipeline/dispatch-shared.ts +31 -18
- package/src/pipeline/dispatch-stream.ts +8 -3
- package/src/pipeline/dispatch-write.ts +3 -2
- package/src/pipeline/event-dispatcher-delivery.ts +15 -3
- package/src/pipeline/event-dispatcher.ts +18 -5
- package/src/pipeline/member-read-only-transaction.ts +2 -6
- package/src/stack/__tests__/ownership-boot-guard.integration.test.ts +1 -1
- package/src/stack/table-helpers.ts +6 -0
- package/src/testing/__tests__/e2e-generator.test.ts +50 -0
- package/src/testing/e2e-generator.ts +4 -3
- package/src/ui-types/index.ts +2 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { type KekSourceEnv, resolvePlatformKeks } from "../kek-source";
|
|
3
|
+
import { buildPgKmsOptions } from "../kms-wiring";
|
|
4
|
+
|
|
5
|
+
const TOKEN = "scw-secret-token";
|
|
6
|
+
const CIPHERTEXT_A = Buffer.from("ciphertext-a").toString("base64");
|
|
7
|
+
const CIPHERTEXT_B = Buffer.from("ciphertext-b").toString("base64");
|
|
8
|
+
const PLAINTEXT_A = Buffer.alloc(32, 1).toString("base64");
|
|
9
|
+
|
|
10
|
+
type FetchCall = { readonly url: string; readonly init: RequestInit };
|
|
11
|
+
|
|
12
|
+
function jsonResponse(status: number, body: unknown): Response {
|
|
13
|
+
return new Response(JSON.stringify(body), {
|
|
14
|
+
status,
|
|
15
|
+
headers: { "content-type": "application/json" },
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function trackedFetch(responses: ReadonlyArray<Response>): {
|
|
20
|
+
readonly fetch: typeof globalThis.fetch;
|
|
21
|
+
readonly calls: FetchCall[];
|
|
22
|
+
} {
|
|
23
|
+
const calls: FetchCall[] = [];
|
|
24
|
+
let index = 0;
|
|
25
|
+
const fetch = (async (url: string | URL, init?: RequestInit) => {
|
|
26
|
+
calls.push({ url: String(url), init: init ?? {} });
|
|
27
|
+
const response = responses[index];
|
|
28
|
+
index++;
|
|
29
|
+
if (!response) throw new Error("trackedFetch: no more responses queued");
|
|
30
|
+
return response;
|
|
31
|
+
}) as typeof globalThis.fetch;
|
|
32
|
+
return { fetch, calls };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("resolvePlatformKeks", () => {
|
|
36
|
+
test("passes through unchanged when PLATFORM_KEK is set, without ever calling fetch", async () => {
|
|
37
|
+
const { fetch, calls } = trackedFetch([]);
|
|
38
|
+
const env: KekSourceEnv = { PLATFORM_KEK: PLAINTEXT_A };
|
|
39
|
+
|
|
40
|
+
const result = await resolvePlatformKeks(env, { fetch });
|
|
41
|
+
|
|
42
|
+
expect(result).toBe(env);
|
|
43
|
+
expect(calls.length).toBe(0);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("decrypts a lone ciphertext, posting the key id in the path and the token in X-Auth-Token", async () => {
|
|
47
|
+
const { fetch, calls } = trackedFetch([jsonResponse(200, { plaintext: PLAINTEXT_A })]);
|
|
48
|
+
const env: KekSourceEnv = {
|
|
49
|
+
PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
|
|
50
|
+
PLATFORM_KEK_KMS_KEY_ID: "key-1",
|
|
51
|
+
PLATFORM_KEK_KMS_TOKEN: TOKEN,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const result = await resolvePlatformKeks(env, { fetch });
|
|
55
|
+
|
|
56
|
+
expect(result.PLATFORM_KEK).toBe(PLAINTEXT_A);
|
|
57
|
+
expect(calls.length).toBe(1);
|
|
58
|
+
expect(calls[0]?.url).toContain("/keys/key-1/decrypt");
|
|
59
|
+
expect(new Headers(calls[0]?.init.headers).get("X-Auth-Token")).toBe(TOKEN);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("decrypts both ciphertexts with two distinct requests", async () => {
|
|
63
|
+
const { fetch, calls } = trackedFetch([
|
|
64
|
+
jsonResponse(200, { plaintext: "active-plaintext" }),
|
|
65
|
+
jsonResponse(200, { plaintext: "previous-plaintext" }),
|
|
66
|
+
]);
|
|
67
|
+
const env: KekSourceEnv = {
|
|
68
|
+
PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
|
|
69
|
+
PLATFORM_KEK_PREVIOUS_CIPHERTEXT: CIPHERTEXT_B,
|
|
70
|
+
PLATFORM_KEK_PREVIOUS_VERSION: "1",
|
|
71
|
+
PLATFORM_KEK_KMS_KEY_ID: "key-1",
|
|
72
|
+
PLATFORM_KEK_KMS_TOKEN: TOKEN,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const result = await resolvePlatformKeks(env, { fetch });
|
|
76
|
+
|
|
77
|
+
expect(result.PLATFORM_KEK).toBe("active-plaintext");
|
|
78
|
+
expect(result.PLATFORM_KEK_PREVIOUS).toBe("previous-plaintext");
|
|
79
|
+
expect(calls.length).toBe(2);
|
|
80
|
+
const bodies = calls.map((call) => JSON.parse(String(call.init.body)).ciphertext);
|
|
81
|
+
expect(bodies).toEqual([CIPHERTEXT_A, CIPHERTEXT_B]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("throws without the token in the message when a ciphertext has no key id", async () => {
|
|
85
|
+
const { fetch } = trackedFetch([]);
|
|
86
|
+
const env: KekSourceEnv = {
|
|
87
|
+
PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
|
|
88
|
+
PLATFORM_KEK_KMS_TOKEN: TOKEN,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
await resolvePlatformKeks(env, { fetch });
|
|
93
|
+
throw new Error("unreachable");
|
|
94
|
+
} catch (error) {
|
|
95
|
+
expect(error).toBeInstanceOf(Error);
|
|
96
|
+
const message = error instanceof Error ? error.message : "";
|
|
97
|
+
expect(message).toMatch(/all-or-none/);
|
|
98
|
+
expect(message).not.toContain(TOKEN);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("throws without the token in the message when a ciphertext has no token", async () => {
|
|
103
|
+
const { fetch } = trackedFetch([]);
|
|
104
|
+
const env: KekSourceEnv = {
|
|
105
|
+
PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
|
|
106
|
+
PLATFORM_KEK_KMS_KEY_ID: "key-1",
|
|
107
|
+
PLATFORM_KEK_KMS_TOKEN: undefined,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
await expect(resolvePlatformKeks(env, { fetch })).rejects.toThrow(/all-or-none/);
|
|
111
|
+
await expect(resolvePlatformKeks(env, { fetch })).rejects.not.toThrow(new RegExp(TOKEN));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("retries a network failure and succeeds on the next attempt", async () => {
|
|
115
|
+
const calls: string[] = [];
|
|
116
|
+
let attempt = 0;
|
|
117
|
+
const fetch = (async (url: string | URL) => {
|
|
118
|
+
calls.push(String(url));
|
|
119
|
+
attempt++;
|
|
120
|
+
if (attempt === 1) throw new TypeError("fetch failed");
|
|
121
|
+
return jsonResponse(200, { plaintext: PLAINTEXT_A });
|
|
122
|
+
}) as typeof globalThis.fetch;
|
|
123
|
+
const env: KekSourceEnv = {
|
|
124
|
+
PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
|
|
125
|
+
PLATFORM_KEK_KMS_KEY_ID: "key-1",
|
|
126
|
+
PLATFORM_KEK_KMS_TOKEN: TOKEN,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const result = await resolvePlatformKeks(env, { fetch });
|
|
130
|
+
|
|
131
|
+
expect(result.PLATFORM_KEK).toBe(PLAINTEXT_A);
|
|
132
|
+
expect(calls.length).toBe(2);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("gives up after the last attempt when every call fails at the network level", async () => {
|
|
136
|
+
const calls: string[] = [];
|
|
137
|
+
const fetch = (async (url: string | URL): Promise<Response> => {
|
|
138
|
+
calls.push(String(url));
|
|
139
|
+
throw new TypeError("fetch failed");
|
|
140
|
+
}) as typeof globalThis.fetch;
|
|
141
|
+
const env: KekSourceEnv = {
|
|
142
|
+
PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
|
|
143
|
+
PLATFORM_KEK_KMS_KEY_ID: "key-1",
|
|
144
|
+
PLATFORM_KEK_KMS_TOKEN: TOKEN,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
await expect(resolvePlatformKeks(env, { fetch })).rejects.toThrow(/network error or timeout/);
|
|
148
|
+
expect(calls.length).toBe(3);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("rejects a previous ciphertext with no previous version", async () => {
|
|
152
|
+
const { fetch } = trackedFetch([jsonResponse(200, { plaintext: "previous-plaintext" })]);
|
|
153
|
+
const env: KekSourceEnv = {
|
|
154
|
+
PLATFORM_KEK_PREVIOUS_CIPHERTEXT: CIPHERTEXT_B,
|
|
155
|
+
PLATFORM_KEK_KMS_KEY_ID: "key-1",
|
|
156
|
+
PLATFORM_KEK_KMS_TOKEN: TOKEN,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
await expect(resolvePlatformKeks(env, { fetch })).rejects.toThrow(
|
|
160
|
+
/PLATFORM_KEK_PREVIOUS_VERSION must be set/,
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("returns env unchanged and calls fetch zero times when nothing is set", async () => {
|
|
165
|
+
const { fetch, calls } = trackedFetch([]);
|
|
166
|
+
const env: KekSourceEnv = { SUBJECT_KEYS_DATABASE_URL: "postgres://localhost/x" };
|
|
167
|
+
|
|
168
|
+
const result = await resolvePlatformKeks(env, { fetch });
|
|
169
|
+
|
|
170
|
+
expect(result).toBe(env);
|
|
171
|
+
expect(calls.length).toBe(0);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("retries once on HTTP 500 and succeeds on the second attempt", async () => {
|
|
175
|
+
const { fetch, calls } = trackedFetch([
|
|
176
|
+
jsonResponse(500, {}),
|
|
177
|
+
jsonResponse(200, { plaintext: PLAINTEXT_A }),
|
|
178
|
+
]);
|
|
179
|
+
const env: KekSourceEnv = {
|
|
180
|
+
PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
|
|
181
|
+
PLATFORM_KEK_KMS_KEY_ID: "key-1",
|
|
182
|
+
PLATFORM_KEK_KMS_TOKEN: TOKEN,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const result = await resolvePlatformKeks(env, { fetch });
|
|
186
|
+
|
|
187
|
+
expect(result.PLATFORM_KEK).toBe(PLAINTEXT_A);
|
|
188
|
+
expect(calls.length).toBe(2);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("gives up after exactly one attempt on HTTP 403, no retry", async () => {
|
|
192
|
+
const { fetch, calls } = trackedFetch([jsonResponse(403, {})]);
|
|
193
|
+
const env: KekSourceEnv = {
|
|
194
|
+
PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
|
|
195
|
+
PLATFORM_KEK_KMS_KEY_ID: "key-1",
|
|
196
|
+
PLATFORM_KEK_KMS_TOKEN: TOKEN,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
await expect(resolvePlatformKeks(env, { fetch })).rejects.toThrow(/HTTP 403/);
|
|
200
|
+
expect(calls.length).toBe(1);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("throws when a 2xx response carries no plaintext field", async () => {
|
|
204
|
+
const { fetch } = trackedFetch([jsonResponse(200, { notPlaintext: "x" })]);
|
|
205
|
+
const env: KekSourceEnv = {
|
|
206
|
+
PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
|
|
207
|
+
PLATFORM_KEK_KMS_KEY_ID: "key-1",
|
|
208
|
+
PLATFORM_KEK_KMS_TOKEN: TOKEN,
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
await expect(resolvePlatformKeks(env, { fetch })).rejects.toThrow(/no plaintext field/);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// The slot-to-version mapping is the part that "silently corrupts data when
|
|
215
|
+
// it is wrong" (kms-wiring.ts) — this is the one test that would catch a
|
|
216
|
+
// swapped active/previous assignment all the way through the adapter options.
|
|
217
|
+
test("maps a decrypted active slot and a plaintext previous slot into the correct buildPgKmsOptions fields", async () => {
|
|
218
|
+
const { fetch } = trackedFetch([jsonResponse(200, { plaintext: "decrypted-active" })]);
|
|
219
|
+
const env: KekSourceEnv = {
|
|
220
|
+
PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
|
|
221
|
+
PLATFORM_KEK_KMS_KEY_ID: "key-1",
|
|
222
|
+
PLATFORM_KEK_KMS_TOKEN: TOKEN,
|
|
223
|
+
PLATFORM_KEK_VERSION: "2",
|
|
224
|
+
PLATFORM_KEK_PREVIOUS: "plaintext-previous",
|
|
225
|
+
PLATFORM_KEK_PREVIOUS_VERSION: "1",
|
|
226
|
+
SUBJECT_KEYS_DATABASE_URL: "postgres://localhost/x",
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const resolved = await resolvePlatformKeks(env, { fetch });
|
|
230
|
+
const options = buildPgKmsOptions({
|
|
231
|
+
...resolved,
|
|
232
|
+
PLATFORM_KEK: resolved.PLATFORM_KEK ?? "",
|
|
233
|
+
SUBJECT_KEYS_DATABASE_URL: resolved["SUBJECT_KEYS_DATABASE_URL"] ?? "",
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(options.platformKek).toBe("decrypted-active");
|
|
237
|
+
expect(options.previousKeks).toEqual({ 1: "plaintext-previous" });
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("rollback state: both plaintexts win over a leftover ciphertext, no fetch, no error", async () => {
|
|
241
|
+
const { fetch, calls } = trackedFetch([]);
|
|
242
|
+
const env: KekSourceEnv = {
|
|
243
|
+
PLATFORM_KEK: "current-plaintext",
|
|
244
|
+
PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
|
|
245
|
+
PLATFORM_KEK_PREVIOUS: "rolled-back-plaintext",
|
|
246
|
+
PLATFORM_KEK_PREVIOUS_CIPHERTEXT: CIPHERTEXT_B,
|
|
247
|
+
PLATFORM_KEK_PREVIOUS_VERSION: "1",
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const result = await resolvePlatformKeks(env, { fetch });
|
|
251
|
+
|
|
252
|
+
expect(result.PLATFORM_KEK).toBe("current-plaintext");
|
|
253
|
+
expect(result.PLATFORM_KEK_PREVIOUS).toBe("rolled-back-plaintext");
|
|
254
|
+
expect(calls.length).toBe(0);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("names the ignored ciphertext when a leftover plaintext wins", async () => {
|
|
258
|
+
const { fetch } = trackedFetch([]);
|
|
259
|
+
const lines: string[] = [];
|
|
260
|
+
const env: KekSourceEnv = {
|
|
261
|
+
PLATFORM_KEK: PLAINTEXT_A,
|
|
262
|
+
PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
|
|
263
|
+
PLATFORM_KEK_KMS_KEY_ID: "key-1",
|
|
264
|
+
PLATFORM_KEK_KMS_TOKEN: TOKEN,
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
await resolvePlatformKeks(env, { fetch, log: (line) => lines.push(line), logPrefix: "[ps]" });
|
|
268
|
+
|
|
269
|
+
expect(lines).toEqual([
|
|
270
|
+
"[ps] PLATFORM_KEK source=plaintext-env (ciphertext present and ignored)",
|
|
271
|
+
]);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("names the key id and region when the ciphertext is used, without the token or the key", async () => {
|
|
275
|
+
const { fetch } = trackedFetch([jsonResponse(200, { plaintext: PLAINTEXT_A })]);
|
|
276
|
+
const lines: string[] = [];
|
|
277
|
+
const env: KekSourceEnv = {
|
|
278
|
+
PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
|
|
279
|
+
PLATFORM_KEK_KMS_KEY_ID: "key-1",
|
|
280
|
+
PLATFORM_KEK_KMS_TOKEN: TOKEN,
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
await resolvePlatformKeks(env, { fetch, log: (line) => lines.push(line) });
|
|
284
|
+
|
|
285
|
+
expect(lines).toEqual(["PLATFORM_KEK source=key-manager keyId=key-1 region=fr-par"]);
|
|
286
|
+
expect(lines.join("\n")).not.toContain(TOKEN);
|
|
287
|
+
expect(lines.join("\n")).not.toContain(PLAINTEXT_A);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("says nothing when neither slot is configured", async () => {
|
|
291
|
+
const { fetch } = trackedFetch([]);
|
|
292
|
+
const lines: string[] = [];
|
|
293
|
+
|
|
294
|
+
await resolvePlatformKeks({}, { fetch, log: (line) => lines.push(line) });
|
|
295
|
+
|
|
296
|
+
expect(lines).toEqual([]);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
@@ -28,7 +28,7 @@ const ENTITY_NAME = "pii-entity";
|
|
|
28
28
|
const userLikeEntity = createEntity({
|
|
29
29
|
fields: {
|
|
30
30
|
email: createTextField({ required: true, personal: "self", find: "none" }),
|
|
31
|
-
role: createTextField(),
|
|
31
|
+
role: createTextField({ personal: false, reason: "test_fixture" }),
|
|
32
32
|
},
|
|
33
33
|
table: "pii_users",
|
|
34
34
|
});
|
|
@@ -39,7 +39,7 @@ const commentEntity = createEntity({
|
|
|
39
39
|
personal: { of: "authorId" },
|
|
40
40
|
find: "none",
|
|
41
41
|
}),
|
|
42
|
-
authorId: createTextField({ required: true }),
|
|
42
|
+
authorId: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
43
43
|
},
|
|
44
44
|
table: "pii_comments",
|
|
45
45
|
});
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
const userLikeEntity = createEntity({
|
|
12
12
|
fields: {
|
|
13
13
|
email: createTextField({ required: true, personal: "self", find: "none" }),
|
|
14
|
-
role: createTextField(),
|
|
14
|
+
role: createTextField({ personal: false, reason: "test_fixture" }),
|
|
15
15
|
},
|
|
16
16
|
table: "resolver_users",
|
|
17
17
|
idType: "uuid",
|
|
@@ -23,7 +23,7 @@ const commentEntity = createEntity({
|
|
|
23
23
|
personal: { of: "authorId" },
|
|
24
24
|
find: "none",
|
|
25
25
|
}),
|
|
26
|
-
authorId: createTextField({ required: true }),
|
|
26
|
+
authorId: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
27
27
|
},
|
|
28
28
|
table: "resolver_comments",
|
|
29
29
|
});
|
package/src/crypto/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ export {
|
|
|
15
15
|
} from "./event-pii";
|
|
16
16
|
export { InMemoryKmsAdapter } from "./in-memory-kms-adapter";
|
|
17
17
|
export { isSelfPiiField } from "./is-self-pii-field";
|
|
18
|
+
export { type KekSourceEnv, type KekSourceOptions, resolvePlatformKeks } from "./kek-source";
|
|
18
19
|
export {
|
|
19
20
|
isLocalKeyKmsAdapter,
|
|
20
21
|
KeyAlreadyExistsError,
|
|
@@ -46,7 +47,9 @@ export {
|
|
|
46
47
|
type PgKmsRotationEnv,
|
|
47
48
|
type PlaintextPiiWiring,
|
|
48
49
|
requireKmsWiring,
|
|
50
|
+
requireKmsWiringAsync,
|
|
49
51
|
resolveKmsWiring,
|
|
52
|
+
resolveKmsWiringAsync,
|
|
50
53
|
} from "./kms-wiring";
|
|
51
54
|
export {
|
|
52
55
|
createPgKmsAdapter,
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Resolves PLATFORM_KEK / PLATFORM_KEK_PREVIOUS from a Key Manager ciphertext
|
|
2
|
+
// when no plaintext is set, so the KEK need not sit in the pod env in the
|
|
3
|
+
// clear. `PLATFORM_KEK` stays the source of truth: a plaintext value always
|
|
4
|
+
// wins over its ciphertext sibling, with no request made at all.
|
|
5
|
+
|
|
6
|
+
const SCALEWAY_KEY_MANAGER_API_VERSION = "v1alpha1";
|
|
7
|
+
const DEFAULT_REGION = "fr-par";
|
|
8
|
+
const DECRYPT_TIMEOUT_MS = 5_000;
|
|
9
|
+
const RETRY_DELAYS_MS = [200, 800];
|
|
10
|
+
|
|
11
|
+
export type KekSourceEnv = {
|
|
12
|
+
readonly PLATFORM_KEK?: string | undefined;
|
|
13
|
+
readonly PLATFORM_KEK_CIPHERTEXT?: string | undefined;
|
|
14
|
+
readonly PLATFORM_KEK_PREVIOUS?: string | undefined;
|
|
15
|
+
readonly PLATFORM_KEK_PREVIOUS_CIPHERTEXT?: string | undefined;
|
|
16
|
+
readonly PLATFORM_KEK_PREVIOUS_VERSION?: string | undefined;
|
|
17
|
+
readonly PLATFORM_KEK_KMS_KEY_ID?: string | undefined;
|
|
18
|
+
readonly PLATFORM_KEK_KMS_TOKEN?: string | undefined;
|
|
19
|
+
readonly PLATFORM_KEK_KMS_REGION?: string | undefined;
|
|
20
|
+
readonly [key: string]: string | undefined;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type KekSourceOptions = {
|
|
24
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
25
|
+
readonly logPrefix?: string;
|
|
26
|
+
/** Where the boot line naming the KEK source goes. Defaults to `console.info`. */
|
|
27
|
+
readonly log?: (message: string) => void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function isRetryableStatus(status: number): boolean {
|
|
31
|
+
return status >= 500;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function sleep(ms: number): Promise<void> {
|
|
35
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Scaleway's decrypt SLA is 99.5% — a bootpath that gives up on the first
|
|
39
|
+
// transient 5xx or timeout trades a rare retry for a routine boot failure.
|
|
40
|
+
// 4xx means the request itself is wrong (bad token, bad key id) and no
|
|
41
|
+
// amount of retrying fixes that, so it fails fast instead of stalling boot.
|
|
42
|
+
async function decryptCiphertext(
|
|
43
|
+
ciphertext: string,
|
|
44
|
+
keyId: string,
|
|
45
|
+
token: string,
|
|
46
|
+
region: string,
|
|
47
|
+
fetchImpl: typeof globalThis.fetch,
|
|
48
|
+
logPrefix: string | undefined,
|
|
49
|
+
): Promise<string> {
|
|
50
|
+
const url = `https://api.scaleway.com/key-manager/${SCALEWAY_KEY_MANAGER_API_VERSION}/regions/${region}/keys/${keyId}/decrypt`;
|
|
51
|
+
const prefix = logPrefix ? `${logPrefix} ` : "";
|
|
52
|
+
const maxAttempts = RETRY_DELAYS_MS.length + 1;
|
|
53
|
+
|
|
54
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
55
|
+
let response: Response;
|
|
56
|
+
try {
|
|
57
|
+
response = await fetchImpl(url, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: { "X-Auth-Token": token, "Content-Type": "application/json" },
|
|
60
|
+
body: JSON.stringify({ ciphertext }),
|
|
61
|
+
signal: AbortSignal.timeout(DECRYPT_TIMEOUT_MS),
|
|
62
|
+
});
|
|
63
|
+
} catch {
|
|
64
|
+
if (attempt < maxAttempts) {
|
|
65
|
+
await sleep(RETRY_DELAYS_MS[attempt - 1] ?? 0);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
throw new Error(
|
|
69
|
+
`${prefix}Key Manager decrypt failed for key ${keyId}: network error or timeout`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
if (isRetryableStatus(response.status) && attempt < maxAttempts) {
|
|
75
|
+
await sleep(RETRY_DELAYS_MS[attempt - 1] ?? 0);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
throw new Error(
|
|
79
|
+
`${prefix}Key Manager decrypt failed for key ${keyId}: HTTP ${response.status}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let body: { plaintext?: unknown };
|
|
84
|
+
try {
|
|
85
|
+
body = (await response.json()) as { plaintext?: unknown };
|
|
86
|
+
} catch {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`${prefix}Key Manager decrypt for key ${keyId} returned an unparseable response`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (typeof body.plaintext !== "string") {
|
|
92
|
+
throw new Error(`${prefix}Key Manager decrypt for key ${keyId} returned no plaintext field`);
|
|
93
|
+
}
|
|
94
|
+
return body.plaintext;
|
|
95
|
+
}
|
|
96
|
+
throw new Error(`${prefix}Key Manager decrypt failed for key ${keyId}: retries exhausted`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function resolveSlot(
|
|
100
|
+
plaintext: string | undefined,
|
|
101
|
+
ciphertext: string | undefined,
|
|
102
|
+
env: KekSourceEnv,
|
|
103
|
+
options: KekSourceOptions,
|
|
104
|
+
fetchImpl: typeof globalThis.fetch,
|
|
105
|
+
): Promise<string | undefined> {
|
|
106
|
+
if (plaintext) return plaintext;
|
|
107
|
+
if (!ciphertext) return undefined;
|
|
108
|
+
|
|
109
|
+
const keyId = env.PLATFORM_KEK_KMS_KEY_ID;
|
|
110
|
+
const token = env.PLATFORM_KEK_KMS_TOKEN;
|
|
111
|
+
if (!keyId || !token) {
|
|
112
|
+
const prefix = options.logPrefix ? `${options.logPrefix} ` : "";
|
|
113
|
+
throw new Error(
|
|
114
|
+
`${prefix}PLATFORM_KEK_KMS_KEY_ID / PLATFORM_KEK_KMS_TOKEN are all-or-none with a KEK ciphertext — a partial set means the KMS wiring is broken.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const region = env.PLATFORM_KEK_KMS_REGION ?? DEFAULT_REGION;
|
|
118
|
+
return decryptCiphertext(ciphertext, keyId, token, region, fetchImpl, options.logPrefix);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// A leftover plaintext beside a ciphertext boots green while nothing was
|
|
122
|
+
// migrated, which is indistinguishable from a finished cutover unless the
|
|
123
|
+
// boot says which source won. Never carries a key value, only its origin.
|
|
124
|
+
function describeKekSource(
|
|
125
|
+
name: string,
|
|
126
|
+
plaintext: string | undefined,
|
|
127
|
+
ciphertext: string | undefined,
|
|
128
|
+
env: KekSourceEnv,
|
|
129
|
+
): string | undefined {
|
|
130
|
+
if (plaintext) {
|
|
131
|
+
return ciphertext
|
|
132
|
+
? `${name} source=plaintext-env (ciphertext present and ignored)`
|
|
133
|
+
: `${name} source=plaintext-env`;
|
|
134
|
+
}
|
|
135
|
+
if (!ciphertext) return undefined;
|
|
136
|
+
const region = env.PLATFORM_KEK_KMS_REGION ?? DEFAULT_REGION;
|
|
137
|
+
return `${name} source=key-manager keyId=${env.PLATFORM_KEK_KMS_KEY_ID} region=${region}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Each slot resolves independently so a rollback that clears one slot's
|
|
141
|
+
// plaintext (leaving its ciphertext/_VERSION behind or gone) never blocks the
|
|
142
|
+
// other slot's fallback path — the trio check downstream still applies.
|
|
143
|
+
export async function resolvePlatformKeks(
|
|
144
|
+
env: KekSourceEnv,
|
|
145
|
+
options: KekSourceOptions = {},
|
|
146
|
+
): Promise<KekSourceEnv> {
|
|
147
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
148
|
+
|
|
149
|
+
const active = await resolveSlot(
|
|
150
|
+
env.PLATFORM_KEK,
|
|
151
|
+
env.PLATFORM_KEK_CIPHERTEXT,
|
|
152
|
+
env,
|
|
153
|
+
options,
|
|
154
|
+
fetchImpl,
|
|
155
|
+
);
|
|
156
|
+
const previous = await resolveSlot(
|
|
157
|
+
env.PLATFORM_KEK_PREVIOUS,
|
|
158
|
+
env.PLATFORM_KEK_PREVIOUS_CIPHERTEXT,
|
|
159
|
+
env,
|
|
160
|
+
options,
|
|
161
|
+
fetchImpl,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const prefix = options.logPrefix ? `${options.logPrefix} ` : "";
|
|
165
|
+
// biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
|
|
166
|
+
const log = options.log ?? console.info;
|
|
167
|
+
for (const line of [
|
|
168
|
+
describeKekSource("PLATFORM_KEK", env.PLATFORM_KEK, env.PLATFORM_KEK_CIPHERTEXT, env),
|
|
169
|
+
describeKekSource(
|
|
170
|
+
"PLATFORM_KEK_PREVIOUS",
|
|
171
|
+
env.PLATFORM_KEK_PREVIOUS,
|
|
172
|
+
env.PLATFORM_KEK_PREVIOUS_CIPHERTEXT,
|
|
173
|
+
env,
|
|
174
|
+
),
|
|
175
|
+
]) {
|
|
176
|
+
if (line) log(`${prefix}${line}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (previous && !env.PLATFORM_KEK_PREVIOUS_VERSION) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`${prefix}PLATFORM_KEK_PREVIOUS_VERSION must be set when PLATFORM_KEK_PREVIOUS is set.`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (active === env.PLATFORM_KEK && previous === env.PLATFORM_KEK_PREVIOUS) {
|
|
186
|
+
return env;
|
|
187
|
+
}
|
|
188
|
+
return { ...env, PLATFORM_KEK: active, PLATFORM_KEK_PREVIOUS: previous };
|
|
189
|
+
}
|
package/src/crypto/kms-wiring.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// is wrong), `resolveKmsWiring` is the boot entry point that also constructs
|
|
9
9
|
// the adapter.
|
|
10
10
|
|
|
11
|
+
import { type KekSourceOptions, resolvePlatformKeks } from "./kek-source";
|
|
11
12
|
import { createPgKmsAdapter, type PgKmsAdapter, type PgKmsAdapterOptions } from "./pg-kms-adapter";
|
|
12
13
|
|
|
13
14
|
// The index signature is what lets callers pass `process.env` directly. Without
|
|
@@ -186,3 +187,22 @@ export function requireKmsWiring(
|
|
|
186
187
|
}
|
|
187
188
|
return wiring;
|
|
188
189
|
}
|
|
190
|
+
|
|
191
|
+
/** Same as `resolveKmsWiring`, but resolves `PLATFORM_KEK`/`PLATFORM_KEK_PREVIOUS`
|
|
192
|
+
* from a Key Manager ciphertext first when no plaintext is set — see
|
|
193
|
+
* `resolvePlatformKeks`. The validation stays in the sync function; this only
|
|
194
|
+
* adds the KEK-fetching step in front of it. */
|
|
195
|
+
export async function resolveKmsWiringAsync(
|
|
196
|
+
env: KmsWiringEnv,
|
|
197
|
+
options: KmsWiringOptions & KekSourceOptions = {},
|
|
198
|
+
): Promise<KmsWiring> {
|
|
199
|
+
return resolveKmsWiring(await resolvePlatformKeks(env, options), options);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Async counterpart to `requireKmsWiring`, KEK-resolving like `resolveKmsWiringAsync`. */
|
|
203
|
+
export async function requireKmsWiringAsync(
|
|
204
|
+
env: KmsWiringEnv,
|
|
205
|
+
options: KmsWiringOptions & KekSourceOptions = {},
|
|
206
|
+
): Promise<ActiveKmsWiring> {
|
|
207
|
+
return requireKmsWiring(await resolvePlatformKeks(env, options), options);
|
|
208
|
+
}
|
|
@@ -37,7 +37,7 @@ const personEntity = createEntity({
|
|
|
37
37
|
table: "read_bidx_persons",
|
|
38
38
|
fields: {
|
|
39
39
|
email: createTextField({ required: true, personal: "self", find: "exact" }),
|
|
40
|
-
firstName: createTextField(),
|
|
40
|
+
firstName: createTextField({ personal: false, reason: "test_fixture" }),
|
|
41
41
|
},
|
|
42
42
|
});
|
|
43
43
|
const personFeature = defineFeature("bidxtest", (r) => {
|
|
@@ -50,7 +50,12 @@ const contactEntity = createEntity({
|
|
|
50
50
|
fields: {
|
|
51
51
|
name: createTextField({ required: true, personal: false, reason: "test_fixture" }),
|
|
52
52
|
email: createTextField({ required: true, personal: "tenant", find: "none" }),
|
|
53
|
-
iban: createTextField({
|
|
53
|
+
iban: createTextField({
|
|
54
|
+
personal: false,
|
|
55
|
+
reason: "test_fixture",
|
|
56
|
+
required: true,
|
|
57
|
+
encrypted: true,
|
|
58
|
+
}),
|
|
54
59
|
},
|
|
55
60
|
});
|
|
56
61
|
const leadEntity = createEntity({
|
|
@@ -72,7 +77,12 @@ const ownedContactEntity = createEntity({
|
|
|
72
77
|
fields: {
|
|
73
78
|
name: createTextField({ required: true, personal: false, reason: "test_fixture" }),
|
|
74
79
|
email: createTextField({ required: true, personal: "tenant", find: "none" }),
|
|
75
|
-
iban: createTextField({
|
|
80
|
+
iban: createTextField({
|
|
81
|
+
personal: false,
|
|
82
|
+
reason: "test_fixture",
|
|
83
|
+
required: true,
|
|
84
|
+
encrypted: true,
|
|
85
|
+
}),
|
|
76
86
|
},
|
|
77
87
|
access: { read: { admin: from("user:id", "ownerId") } },
|
|
78
88
|
});
|
|
@@ -13,8 +13,8 @@ describe("entity-field-encryption", () => {
|
|
|
13
13
|
const entity = createEntity({
|
|
14
14
|
table: "read_enc_test",
|
|
15
15
|
fields: {
|
|
16
|
-
email: createTextField({ required: true }),
|
|
17
|
-
secretNote: createTextField({ encrypted: true }),
|
|
16
|
+
email: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
17
|
+
secretNote: createTextField({ personal: false, reason: "test_fixture", encrypted: true }),
|
|
18
18
|
},
|
|
19
19
|
});
|
|
20
20
|
const encryptedFields = collectEncryptedFieldNames(entity);
|
|
@@ -16,7 +16,7 @@ describe("event-store-executor-context — encryptForStorage/decryptForRead laye
|
|
|
16
16
|
const entity = createEntity({
|
|
17
17
|
table: "pii_roundtrip_test",
|
|
18
18
|
fields: {
|
|
19
|
-
userId: createTextField({ required: true }),
|
|
19
|
+
userId: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
20
20
|
// Both markers at once — the auth-mfa.totpSecret/recoveryCodes shape
|
|
21
21
|
// that first surfaced the ordering bug (pii-subject-encryption
|
|
22
22
|
// integration test).
|
|
@@ -34,9 +34,14 @@ const cipher = createTestEnvelopeCipher(TEST_KEY);
|
|
|
34
34
|
const entity = createEntity({
|
|
35
35
|
table: "read_money_orders",
|
|
36
36
|
fields: {
|
|
37
|
-
ownerId: createTextField({ required: true }),
|
|
37
|
+
ownerId: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
38
38
|
grossTotal: createMoneyField(),
|
|
39
|
-
billingIban: createTextField({
|
|
39
|
+
billingIban: createTextField({
|
|
40
|
+
personal: false,
|
|
41
|
+
reason: "test_fixture",
|
|
42
|
+
required: true,
|
|
43
|
+
encrypted: true,
|
|
44
|
+
}),
|
|
40
45
|
},
|
|
41
46
|
// A non-"all" read rule forces buildOwnershipClause into the
|
|
42
47
|
// ownership.kind==="sql" raw-SQL branch that list()/detail() read through.
|