@cosmicdrift/kumiko-framework 0.145.1 → 0.146.1
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 +2 -2
- package/src/db/__tests__/event-store-executor.integration.test.ts +52 -24
- package/src/db/__tests__/feature-table-sources.test.ts +34 -0
- package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +118 -39
- package/src/db/apply-entity-event.ts +10 -14
- package/src/db/event-store-executor.ts +29 -35
- package/src/db/feature-table-sources.ts +10 -3
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +49 -0
- package/src/engine/boot-validator/pii-retention.ts +17 -0
- package/src/engine/types/fields.ts +7 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.146.1",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -189,7 +189,7 @@
|
|
|
189
189
|
"zod": "^4.4.3"
|
|
190
190
|
},
|
|
191
191
|
"devDependencies": {
|
|
192
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
192
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.146.1",
|
|
193
193
|
"bun-types": "^1.3.13",
|
|
194
194
|
"pino-pretty": "^13.1.3"
|
|
195
195
|
},
|
|
@@ -124,17 +124,18 @@ describe("event-store-executor", () => {
|
|
|
124
124
|
});
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
-
// Sensitive
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
127
|
+
// Sensitive fields (#967): passwords/tokens/IBANs must be ciphertext-at-rest
|
|
128
|
+
// (boot validates sensitive ⇒ pii | encrypted). The event log carries the
|
|
129
|
+
// table ciphertext — replay reproduces the row byte-identically. Plaintext
|
|
130
|
+
// never lands in permanent history; the caller-facing event echo strips the
|
|
131
|
+
// field entirely (#820).
|
|
132
|
+
const SENSITIVE_TEST_KEY = Buffer.from("s3nS!t1vE.kP9xQ2@wN!vL$hR5yT8eU0").toString("base64");
|
|
132
133
|
const sensitiveEntity = createEntity({
|
|
133
134
|
table: "read_es_exec_sensitive",
|
|
134
135
|
fields: {
|
|
135
136
|
email: createTextField({ required: true }),
|
|
136
|
-
passwordHash: createTextField({ sensitive: true }),
|
|
137
|
-
apiToken: createTextField({ sensitive: true }),
|
|
137
|
+
passwordHash: createTextField({ sensitive: true, encrypted: true }),
|
|
138
|
+
apiToken: createTextField({ sensitive: true, encrypted: true }),
|
|
138
139
|
},
|
|
139
140
|
softDelete: true,
|
|
140
141
|
});
|
|
@@ -143,6 +144,7 @@ const sensitiveTable = buildEntityTable("esExecSensitive", sensitiveEntity);
|
|
|
143
144
|
describe("event-store-executor — sensitive fields", () => {
|
|
144
145
|
const crud = createEventStoreExecutor(sensitiveTable, sensitiveEntity, {
|
|
145
146
|
entityName: "esExecSensitive",
|
|
147
|
+
encryption: createTestEnvelopeCipher(SENSITIVE_TEST_KEY),
|
|
146
148
|
});
|
|
147
149
|
|
|
148
150
|
beforeAll(async () => {
|
|
@@ -172,26 +174,35 @@ describe("event-store-executor — sensitive fields", () => {
|
|
|
172
174
|
};
|
|
173
175
|
}
|
|
174
176
|
|
|
175
|
-
test("create event payload
|
|
177
|
+
test("create event payload carries sensitive fields as table ciphertext; echo strips them", async () => {
|
|
176
178
|
const result = await crud.create(
|
|
177
179
|
{ email: "s@test.de", passwordHash: "pw-hash-123", apiToken: "tok-abc" },
|
|
178
180
|
adminUser,
|
|
179
181
|
tdb,
|
|
180
182
|
);
|
|
181
183
|
if (!result.isSuccess) throw new Error("create failed");
|
|
182
|
-
//
|
|
184
|
+
// Caller-facing row: plaintext preserved.
|
|
183
185
|
expect(result.data.data["passwordHash"]).toBe("pw-hash-123");
|
|
184
186
|
expect(result.data.data["apiToken"]).toBe("tok-abc");
|
|
185
187
|
|
|
186
|
-
// Event payload:
|
|
188
|
+
// Event payload: ciphertext, byte-identical to the table column (#967).
|
|
187
189
|
const event = await lastEvent();
|
|
188
190
|
expect(event.type).toBe("esExecSensitive.created");
|
|
189
191
|
expect(event.payload["email"]).toBe("s@test.de");
|
|
190
|
-
expect(event.payload["passwordHash"]).
|
|
191
|
-
expect(event.payload["
|
|
192
|
+
expect(event.payload["passwordHash"]).toBeDefined();
|
|
193
|
+
expect(event.payload["passwordHash"]).not.toBe("pw-hash-123");
|
|
194
|
+
const rawRows = (await asRawClient(testDb.db).unsafe(
|
|
195
|
+
`SELECT password_hash FROM read_es_exec_sensitive WHERE email = 's@test.de' LIMIT 1`,
|
|
196
|
+
)) as Array<{ password_hash: string }>;
|
|
197
|
+
expect(event.payload["passwordHash"]).toBe(rawRows[0]!.password_hash);
|
|
198
|
+
|
|
199
|
+
// Caller-facing event echo stays stripped (#820).
|
|
200
|
+
if (!result.data.event) throw new Error("no event echo");
|
|
201
|
+
expect(result.data.event.payload["passwordHash"]).toBeUndefined();
|
|
202
|
+
expect(result.data.event.payload["apiToken"]).toBeUndefined();
|
|
192
203
|
});
|
|
193
204
|
|
|
194
|
-
test("update event
|
|
205
|
+
test("update event carries ciphertext in BOTH changes and previous", async () => {
|
|
195
206
|
const created = await crud.create(
|
|
196
207
|
{ email: "u@test.de", passwordHash: "old-hash", apiToken: "old-tok" },
|
|
197
208
|
adminUser,
|
|
@@ -215,16 +226,29 @@ describe("event-store-executor — sensitive fields", () => {
|
|
|
215
226
|
previous: { email?: string; passwordHash?: string; apiToken?: string };
|
|
216
227
|
}>();
|
|
217
228
|
expect(event.type).toBe("esExecSensitive.updated");
|
|
218
|
-
// Changes: email
|
|
229
|
+
// Changes: email plaintext (public), passwordHash ciphertext.
|
|
219
230
|
expect(event.payload.changes.email).toBe("u2@test.de");
|
|
220
|
-
expect(event.payload.changes.passwordHash).
|
|
221
|
-
|
|
231
|
+
expect(event.payload.changes.passwordHash).toBeDefined();
|
|
232
|
+
expect(event.payload.changes.passwordHash).not.toBe("new-hash");
|
|
233
|
+
// Previous: email plaintext, passwordHash + apiToken ciphertext.
|
|
222
234
|
expect(event.payload.previous.email).toBe("u@test.de");
|
|
223
|
-
expect(event.payload.previous.passwordHash).
|
|
224
|
-
expect(event.payload.previous.
|
|
235
|
+
expect(event.payload.previous.passwordHash).toBeDefined();
|
|
236
|
+
expect(event.payload.previous.passwordHash).not.toBe("old-hash");
|
|
237
|
+
expect(event.payload.previous.apiToken).toBeDefined();
|
|
238
|
+
expect(event.payload.previous.apiToken).not.toBe("old-tok");
|
|
239
|
+
|
|
240
|
+
// Caller-facing event echo stays stripped (#820).
|
|
241
|
+
if (!result.data.event) throw new Error("no event echo");
|
|
242
|
+
const echoPayload = result.data.event.payload as {
|
|
243
|
+
changes: Record<string, unknown>;
|
|
244
|
+
previous: Record<string, unknown>;
|
|
245
|
+
};
|
|
246
|
+
expect(echoPayload.changes["passwordHash"]).toBeUndefined();
|
|
247
|
+
expect(echoPayload.previous["passwordHash"]).toBeUndefined();
|
|
248
|
+
expect(echoPayload.previous["apiToken"]).toBeUndefined();
|
|
225
249
|
});
|
|
226
250
|
|
|
227
|
-
test("delete event
|
|
251
|
+
test("delete event carries ciphertext in previous", async () => {
|
|
228
252
|
const created = await crud.create(
|
|
229
253
|
{ email: "d@test.de", passwordHash: "pw", apiToken: "tk" },
|
|
230
254
|
adminUser,
|
|
@@ -240,11 +264,13 @@ describe("event-store-executor — sensitive fields", () => {
|
|
|
240
264
|
const event = await lastEvent<SensitivePrevious>();
|
|
241
265
|
expect(event.type).toBe("esExecSensitive.deleted");
|
|
242
266
|
expect(event.payload.previous.email).toBe("d@test.de");
|
|
243
|
-
expect(event.payload.previous.passwordHash).
|
|
244
|
-
expect(event.payload.previous.
|
|
267
|
+
expect(event.payload.previous.passwordHash).toBeDefined();
|
|
268
|
+
expect(event.payload.previous.passwordHash).not.toBe("pw");
|
|
269
|
+
expect(event.payload.previous.apiToken).toBeDefined();
|
|
270
|
+
expect(event.payload.previous.apiToken).not.toBe("tk");
|
|
245
271
|
});
|
|
246
272
|
|
|
247
|
-
test("restore event
|
|
273
|
+
test("restore event carries ciphertext in previous", async () => {
|
|
248
274
|
const created = await crud.create(
|
|
249
275
|
{ email: "r@test.de", passwordHash: "pw", apiToken: "tk" },
|
|
250
276
|
adminUser,
|
|
@@ -261,8 +287,10 @@ describe("event-store-executor — sensitive fields", () => {
|
|
|
261
287
|
const event = await lastEvent<SensitivePrevious>();
|
|
262
288
|
expect(event.type).toBe("esExecSensitive.restored");
|
|
263
289
|
expect(event.payload.previous.email).toBe("r@test.de");
|
|
264
|
-
expect(event.payload.previous.passwordHash).
|
|
265
|
-
expect(event.payload.previous.
|
|
290
|
+
expect(event.payload.previous.passwordHash).toBeDefined();
|
|
291
|
+
expect(event.payload.previous.passwordHash).not.toBe("pw");
|
|
292
|
+
expect(event.payload.previous.apiToken).toBeDefined();
|
|
293
|
+
expect(event.payload.previous.apiToken).not.toBe("tk");
|
|
266
294
|
});
|
|
267
295
|
});
|
|
268
296
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Regression test for #255-class drift: unmanagedTables must appear in
|
|
2
|
+
// enumerateFeatureTableSources so setupTestStack's auto-push and
|
|
3
|
+
// collectTableMetas see the exact same table set. Before this fix,
|
|
4
|
+
// unmanagedTables were only handled by collectTableMetas directly, so any
|
|
5
|
+
// app relying on setupTestStack's ephemeral DB (Playwright/e2e) never got
|
|
6
|
+
// its unmanaged tables created — e.g. the bundled "sessions" feature's
|
|
7
|
+
// read_user_sessions, breaking every login in an ephemeral test stack.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { defineFeature } from "../../engine/define-feature";
|
|
11
|
+
import { defineUnmanagedTable } from "../entity-table-meta";
|
|
12
|
+
import { enumerateFeatureTableSources } from "../feature-table-sources";
|
|
13
|
+
|
|
14
|
+
const probeMeta = defineUnmanagedTable({
|
|
15
|
+
tableName: "ftst_probe",
|
|
16
|
+
columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("enumerateFeatureTableSources — unmanagedTables", () => {
|
|
20
|
+
test("includes a feature's unmanagedTable as a table source", () => {
|
|
21
|
+
const feature = defineFeature("probe", (r) => {
|
|
22
|
+
r.unmanagedTable(probeMeta, { reason: "direct-write store" });
|
|
23
|
+
});
|
|
24
|
+
const sources = enumerateFeatureTableSources(feature);
|
|
25
|
+
const entry = sources.find((s) => s.origin.includes("ftst_probe"));
|
|
26
|
+
expect(entry).toBeDefined();
|
|
27
|
+
expect(entry?.table).toBe(probeMeta);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("a feature with no unmanagedTable contributes nothing extra", () => {
|
|
31
|
+
const feature = defineFeature("plain", () => {});
|
|
32
|
+
expect(enumerateFeatureTableSources(feature)).toEqual([]);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// 5. Snapshot erneut nehmen
|
|
16
16
|
// 6. deep-equal: identische Rows in identischer Reihenfolge
|
|
17
17
|
|
|
18
|
-
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
18
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
19
19
|
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
|
20
20
|
import { createBooleanField, createEntity, createTextField, defineFeature } from "../../engine";
|
|
21
21
|
import { createRegistry } from "../../engine/registry";
|
|
@@ -222,26 +222,33 @@ describe("implicit-projection / Live==Rebuild equivalence", () => {
|
|
|
222
222
|
});
|
|
223
223
|
});
|
|
224
224
|
|
|
225
|
-
// Sensitive-
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
225
|
+
// Sensitive-Rebuild-Parität (#967): das Event-Log trägt für sensitive-Felder
|
|
226
|
+
// den Tabellen-Ciphertext (boot-validiert pii/encrypted) — Live==Rebuild gilt
|
|
227
|
+
// damit auch für sensitive Spalten + Blind-Index. Einzige legitime Divergenz
|
|
228
|
+
// bleibt Crypto-Shredding: DEK erased → bidx NULL, Wert unlesbar.
|
|
229
|
+
|
|
230
|
+
import {
|
|
231
|
+
computeBlindIndex,
|
|
232
|
+
configureBlindIndexKey,
|
|
233
|
+
configurePiiSubjectKms,
|
|
234
|
+
decodeBlindIndexKey,
|
|
235
|
+
decryptPiiFieldValues,
|
|
236
|
+
InMemoryKmsAdapter,
|
|
237
|
+
isPiiCiphertext,
|
|
238
|
+
resetBlindIndexKeyForTests,
|
|
239
|
+
resetPiiSubjectKmsForTests,
|
|
240
|
+
} from "../../crypto";
|
|
236
241
|
import { asRawClient, selectMany } from "../../db/query";
|
|
237
242
|
|
|
238
243
|
const sensitiveTable = "read_implicit_sensitive_users";
|
|
244
|
+
const SENSITIVE_BIDX_KEY_B64 = Buffer.alloc(32, 9).toString("base64");
|
|
245
|
+
const SENSITIVE_BIDX_KEY = decodeBlindIndexKey(SENSITIVE_BIDX_KEY_B64);
|
|
239
246
|
|
|
240
247
|
const sensitiveEntity = createEntity({
|
|
241
248
|
table: sensitiveTable,
|
|
242
249
|
fields: {
|
|
243
250
|
email: createTextField({ required: true }),
|
|
244
|
-
apiKey: createTextField({ sensitive: true }),
|
|
251
|
+
apiKey: createTextField({ sensitive: true, pii: true, lookupable: true }),
|
|
245
252
|
},
|
|
246
253
|
});
|
|
247
254
|
|
|
@@ -250,8 +257,11 @@ const sensitiveFeature = defineFeature("implicitsensitive", (r) => {
|
|
|
250
257
|
});
|
|
251
258
|
|
|
252
259
|
const sensitiveEntityTable = buildEntityTable("sensitive-user", sensitiveEntity);
|
|
260
|
+
const sensitiveProjection = "implicitsensitive:projection:sensitive-user-entity";
|
|
261
|
+
|
|
262
|
+
describe("implicit-projection / sensitive Rebuild-Parität (#967)", () => {
|
|
263
|
+
let kms: InMemoryKmsAdapter;
|
|
253
264
|
|
|
254
|
-
describe("implicit-projection / dokumentierte Sensitive-Drift", () => {
|
|
255
265
|
beforeAll(async () => {
|
|
256
266
|
await unsafeCreateEntityTable(testDb.db, sensitiveEntity, "sensitive-user");
|
|
257
267
|
});
|
|
@@ -260,29 +270,46 @@ describe("implicit-projection / dokumentierte Sensitive-Drift", () => {
|
|
|
260
270
|
await asRawClient(testDb.db).unsafe(
|
|
261
271
|
`TRUNCATE ${sensitiveTable}, kumiko_events, kumiko_projections RESTART IDENTITY CASCADE`,
|
|
262
272
|
);
|
|
273
|
+
kms = new InMemoryKmsAdapter();
|
|
274
|
+
configurePiiSubjectKms(kms);
|
|
275
|
+
configureBlindIndexKey(SENSITIVE_BIDX_KEY_B64);
|
|
263
276
|
});
|
|
264
277
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
278
|
+
afterEach(() => {
|
|
279
|
+
resetPiiSubjectKmsForTests();
|
|
280
|
+
resetBlindIndexKeyForTests();
|
|
281
|
+
});
|
|
269
282
|
|
|
270
|
-
|
|
271
|
-
|
|
283
|
+
const crud = createEventStoreExecutor(sensitiveEntityTable, sensitiveEntity, {
|
|
284
|
+
entityName: "sensitive-user",
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
async function rawSensitiveRow(id: string): Promise<Record<string, unknown>> {
|
|
288
|
+
const rows = await asRawClient(testDb.db).unsafe<Record<string, unknown>>(
|
|
289
|
+
`SELECT * FROM ${sensitiveTable} WHERE id = $1`,
|
|
290
|
+
[id],
|
|
291
|
+
);
|
|
292
|
+
const row = rows[0];
|
|
293
|
+
if (!row) throw new Error(`no row for ${id}`);
|
|
294
|
+
return row;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
test("Live==Rebuild inkl. sensitive-Ciphertext + bidx (byte-gleiche Kopie aus dem Event)", async () => {
|
|
272
298
|
const created = await crud.create(
|
|
273
299
|
{ email: "x@test.de", apiKey: "secret-token-abc" },
|
|
274
300
|
adminUser,
|
|
275
301
|
tdb,
|
|
276
302
|
);
|
|
277
303
|
if (!created.isSuccess) throw new Error("setup failed");
|
|
304
|
+
const id = String(created.data.id);
|
|
278
305
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
expect(
|
|
283
|
-
expect(liveRow
|
|
306
|
+
// Live-Row: Ciphertext + bidx, nie Klartext.
|
|
307
|
+
const liveRow = await rawSensitiveRow(id);
|
|
308
|
+
const liveCipher = liveRow["api_key"];
|
|
309
|
+
expect(isPiiCiphertext(liveCipher)).toBe(true);
|
|
310
|
+
expect(liveRow["api_key_bidx"]).toBe(computeBlindIndex(SENSITIVE_BIDX_KEY, "secret-token-abc"));
|
|
284
311
|
|
|
285
|
-
//
|
|
312
|
+
// Event-Payload trägt exakt den Tabellen-Ciphertext (byte-gleich).
|
|
286
313
|
const { eventsTable } = await import("../../event-store");
|
|
287
314
|
const [event] = await selectMany(
|
|
288
315
|
testDb.db,
|
|
@@ -290,23 +317,75 @@ describe("implicit-projection / dokumentierte Sensitive-Drift", () => {
|
|
|
290
317
|
{ aggregateId: created.data.id },
|
|
291
318
|
{ orderBy: { col: "version", direction: "asc" } },
|
|
292
319
|
);
|
|
293
|
-
expect(event?.payload?.["apiKey"]).
|
|
320
|
+
expect(event?.payload?.["apiKey"]).toBe(liveCipher);
|
|
294
321
|
expect(event?.payload?.["email"]).toBe("x@test.de");
|
|
322
|
+
expect(JSON.stringify(event?.payload)).not.toContain("secret-token-abc");
|
|
295
323
|
|
|
296
|
-
// 3. Rebuild über die ImplicitProjection. Read-Tabelle wird aus
|
|
297
|
-
// event.payload neu materialisiert — apiKey ist nicht im Log,
|
|
298
|
-
// landet also als NULL/undefined in der rebuilt Row.
|
|
299
324
|
const registry = createRegistry([sensitiveFeature]);
|
|
300
|
-
await rebuildProjection(
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
325
|
+
await rebuildProjection(sensitiveProjection, { db: testDb.db, registry });
|
|
326
|
+
|
|
327
|
+
const rebuiltRow = await rawSensitiveRow(id);
|
|
328
|
+
expect(rebuiltRow["email"]).toBe("x@test.de");
|
|
329
|
+
expect(rebuiltRow["api_key"]).toBe(liveCipher);
|
|
330
|
+
expect(rebuiltRow["api_key_bidx"]).toBe(liveRow["api_key_bidx"]);
|
|
331
|
+
});
|
|
304
332
|
|
|
305
|
-
|
|
306
|
-
|
|
333
|
+
test("DEK-Stabilität: Update re-encryptet mit demselben Subject-DEK — alter Event-Ciphertext bleibt lesbar", async () => {
|
|
334
|
+
const created = await crud.create(
|
|
335
|
+
{ email: "y@test.de", apiKey: "first-secret" },
|
|
336
|
+
adminUser,
|
|
337
|
+
tdb,
|
|
338
|
+
);
|
|
339
|
+
if (!created.isSuccess) throw new Error("setup failed");
|
|
340
|
+
const updated = await crud.update(
|
|
341
|
+
{ id: created.data.id, version: 1, changes: { apiKey: "second-secret" } },
|
|
342
|
+
adminUser,
|
|
343
|
+
tdb,
|
|
344
|
+
);
|
|
345
|
+
if (!updated.isSuccess) throw new Error("update failed");
|
|
346
|
+
|
|
347
|
+
// Created-Event (v1-Ciphertext) muss mit dem aktuellen DEK lesbar bleiben —
|
|
348
|
+
// eine DEK-Rotation beim Update würde das immutable Log unlesbar machen.
|
|
349
|
+
const { eventsTable } = await import("../../event-store");
|
|
350
|
+
const [createdEvent] = await selectMany(
|
|
351
|
+
testDb.db,
|
|
352
|
+
eventsTable,
|
|
353
|
+
{ aggregateId: created.data.id },
|
|
354
|
+
{ orderBy: { col: "version", direction: "asc" } },
|
|
355
|
+
);
|
|
356
|
+
const v1Cipher = createdEvent?.payload?.["apiKey"];
|
|
357
|
+
expect(isPiiCiphertext(v1Cipher)).toBe(true);
|
|
358
|
+
const decrypted = await decryptPiiFieldValues({ apiKey: v1Cipher }, ["apiKey"], kms, {
|
|
359
|
+
requestId: "test",
|
|
307
360
|
});
|
|
308
|
-
expect(
|
|
309
|
-
|
|
310
|
-
|
|
361
|
+
expect(decrypted["apiKey"]).toBe("first-secret");
|
|
362
|
+
|
|
363
|
+
// Rebuild spielt created+updated: bidx-Recompute entschlüsselt beide
|
|
364
|
+
// Ciphertexte — schlägt bei rotiertem DEK fehl statt still zu heilen.
|
|
365
|
+
const registry = createRegistry([sensitiveFeature]);
|
|
366
|
+
await rebuildProjection(sensitiveProjection, { db: testDb.db, registry });
|
|
367
|
+
const rebuiltRow = await rawSensitiveRow(String(created.data.id));
|
|
368
|
+
expect(rebuiltRow["api_key_bidx"]).toBe(computeBlindIndex(SENSITIVE_BIDX_KEY, "second-secret"));
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("Erase-Divergenz bleibt die einzige legitime: DEK erased → Rebuild bidx NULL, Wert unlesbar", async () => {
|
|
372
|
+
const created = await crud.create(
|
|
373
|
+
{ email: "z@test.de", apiKey: "gone-after-forget" },
|
|
374
|
+
adminUser,
|
|
375
|
+
tdb,
|
|
376
|
+
);
|
|
377
|
+
if (!created.isSuccess) throw new Error("setup failed");
|
|
378
|
+
const id = String(created.data.id);
|
|
379
|
+
|
|
380
|
+
await kms.eraseKey({ kind: "user", userId: id });
|
|
381
|
+
|
|
382
|
+
const registry = createRegistry([sensitiveFeature]);
|
|
383
|
+
await rebuildProjection(sensitiveProjection, { db: testDb.db, registry });
|
|
384
|
+
|
|
385
|
+
const rebuiltRow = await rawSensitiveRow(id);
|
|
386
|
+
expect(rebuiltRow["api_key_bidx"]).toBeNull();
|
|
387
|
+
// Ciphertext-Bytes werden kopiert, sind aber ohne DEK unlesbar.
|
|
388
|
+
expect(isPiiCiphertext(rebuiltRow["api_key"])).toBe(true);
|
|
389
|
+
expect(rebuiltRow["api_key"]).not.toBe("gone-after-forget");
|
|
311
390
|
});
|
|
312
391
|
});
|
|
@@ -1,22 +1,18 @@
|
|
|
1
1
|
// applyEntityEvent — die EINZIGE Schreib-Logik für r.entity-Tabellen aus
|
|
2
2
|
// Stored-Events. Beide Aufrufer benutzen sie:
|
|
3
3
|
//
|
|
4
|
-
// - createEventStoreExecutor (live, im Write-TX) — übergibt
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// bleibt aber stripped (siehe append-Site im Executor).
|
|
4
|
+
// - createEventStoreExecutor (live, im Write-TX) — übergibt das gerade
|
|
5
|
+
// appendete StoredEvent direkt; sensitive Felder liegen als Tabellen-
|
|
6
|
+
// Ciphertext im Payload (boot-validiert pii/encrypted, #967).
|
|
8
7
|
// - rebuildProjection via ImplicitProjection (replay, im Rebuild-TX) —
|
|
9
|
-
// übergibt
|
|
10
|
-
//
|
|
11
|
-
// ist als load-bearing Backlog-Item gepinnt — siehe
|
|
12
|
-
// docs/plans/architecture/migrations.md Sektion "Backlog (Welle 3+)"
|
|
13
|
-
// → "Sensitive-Field-Persistenz im Rebuild" für Optionen a/b/c).
|
|
8
|
+
// übergibt dasselbe StoredEvent; apply kopiert Ciphertext byte-gleich
|
|
9
|
+
// zurück, bidx wird unten neu berechnet.
|
|
14
10
|
//
|
|
15
|
-
// Live==Rebuild-Equivalence ist damit by-construction für
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// db/__tests__/implicit-projection-equivalence.integration.ts.
|
|
11
|
+
// Live==Rebuild-Equivalence ist damit by-construction für ALLE Felder —
|
|
12
|
+
// eine geänderte Schreib-Logik muss nur an EINER Stelle gepflegt werden,
|
|
13
|
+
// kein Sync-Contract mehr. Einzige legitime Divergenz: Crypto-Shredding
|
|
14
|
+
// (DEK erased → Wert unlesbar, bidx NULL). Load-bearing Test:
|
|
15
|
+
// db/__tests__/implicit-projection-equivalence.integration.test.ts.
|
|
20
16
|
//
|
|
21
17
|
// Tenant-Isolation: applyEntityEvent erwartet einen rohen DbRunner (TX
|
|
22
18
|
// oder pool), KEINEN TenantDb-Wrapper. Schutz kommt aus zwei Quellen:
|
|
@@ -304,11 +304,10 @@ export function createEventStoreExecutor(
|
|
|
304
304
|
if (def !== undefined) fieldDefaults[name] = def;
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
-
// Pre-compute the set of sensitive field names once.
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
// sensitive data, and entity rows can be deleted / re-encrypted.
|
|
307
|
+
// Pre-compute the set of sensitive field names once. The event log stores
|
|
308
|
+
// these fields as table ciphertext (boot validates sensitive ⇒ pii |
|
|
309
|
+
// encrypted, #967) — the set only strips the caller-facing event echo so
|
|
310
|
+
// responses never carry the value (#820).
|
|
312
311
|
const sensitiveFields = new Set<string>();
|
|
313
312
|
for (const [name, field] of Object.entries(entity.fields)) {
|
|
314
313
|
if ("sensitive" in field && field.sensitive === true) {
|
|
@@ -526,8 +525,9 @@ export function createEventStoreExecutor(
|
|
|
526
525
|
|
|
527
526
|
// 1. Append event (same TX as the projection write — both must succeed
|
|
528
527
|
// or both roll back; the dispatcher wraps both in one transaction).
|
|
529
|
-
//
|
|
530
|
-
//
|
|
528
|
+
// flatData is already table ciphertext for pii/encrypted fields, so
|
|
529
|
+
// the immutable log never sees plaintext and replay reproduces the
|
|
530
|
+
// row byte-identically (#967).
|
|
531
531
|
//
|
|
532
532
|
// `expectedVersion: 0` heißt: stream existiert noch nicht. Bei
|
|
533
533
|
// deterministic-aggregate-id-Patterns (z.B. uuidv5(tenantId|naturalKey))
|
|
@@ -542,7 +542,7 @@ export function createEventStoreExecutor(
|
|
|
542
542
|
tenantId: streamTenantFor(user),
|
|
543
543
|
expectedVersion: 0,
|
|
544
544
|
type: entityEventName(entityName, "created"),
|
|
545
|
-
payload:
|
|
545
|
+
payload: flatData,
|
|
546
546
|
metadata: buildEventMetadata(user),
|
|
547
547
|
});
|
|
548
548
|
} catch (e) {
|
|
@@ -573,9 +573,8 @@ export function createEventStoreExecutor(
|
|
|
573
573
|
}
|
|
574
574
|
|
|
575
575
|
// 2. Update projection via applyEntityEvent — derselbe Code-Pfad den
|
|
576
|
-
// rebuildProjection für Replay nutzt
|
|
577
|
-
//
|
|
578
|
-
// Felder in der Read-Tabelle landen, aber nicht im Event-Log).
|
|
576
|
+
// rebuildProjection für Replay nutzt, mit demselben StoredEvent →
|
|
577
|
+
// Live==Rebuild by-construction (#967).
|
|
579
578
|
//
|
|
580
579
|
// F8-Patch: app-level unique-violations (z.B. (tenantId, email)
|
|
581
580
|
// auf User-Entity, (tenantId, slug) auf Article) werfen pg-23505
|
|
@@ -583,10 +582,9 @@ export function createEventStoreExecutor(
|
|
|
583
582
|
// unhandled exception → 500 internal_error. Map auf
|
|
584
583
|
// UniqueViolationError 409 damit Designer/Frontend einen sauberen
|
|
585
584
|
// "duplicate" zeigen können statt cryptic "internal server error".
|
|
586
|
-
const liveEvent = { ...event, payload: flatData };
|
|
587
585
|
let result: Awaited<ReturnType<typeof applyEntityEvent>>;
|
|
588
586
|
try {
|
|
589
|
-
result = await applyEntityEvent(
|
|
587
|
+
result = await applyEntityEvent(event, table, entity, db.raw);
|
|
590
588
|
} catch (e) {
|
|
591
589
|
const mapped = tryMapUniqueViolation(e, entityName);
|
|
592
590
|
if (mapped) return mapped;
|
|
@@ -718,11 +716,10 @@ export function createEventStoreExecutor(
|
|
|
718
716
|
// previous value to decrement/undo when a parent-FK moves — without it
|
|
719
717
|
// you'd have to snapshot-and-diff on every apply, and replays would
|
|
720
718
|
// break. Storage cost is acceptable (rows are bounded), correctness is
|
|
721
|
-
// not negotiable.
|
|
722
|
-
//
|
|
723
|
-
//
|
|
724
|
-
//
|
|
725
|
-
// (flatChanges is already ciphertext from encryptForStorage above).
|
|
719
|
+
// not negotiable. `previous` came from loadById(), which decrypts —
|
|
720
|
+
// re-encrypt it before it's persisted so plaintext of pii/encrypted
|
|
721
|
+
// fields doesn't land in the immutable log (flatChanges is already
|
|
722
|
+
// ciphertext from encryptForStorage above).
|
|
726
723
|
const event = await append(db.raw, {
|
|
727
724
|
aggregateId: String(payload.id),
|
|
728
725
|
aggregateType: entityName,
|
|
@@ -730,26 +727,23 @@ export function createEventStoreExecutor(
|
|
|
730
727
|
expectedVersion: currentVersion,
|
|
731
728
|
type: entityEventName(entityName, "updated"),
|
|
732
729
|
payload: {
|
|
733
|
-
changes:
|
|
734
|
-
previous:
|
|
730
|
+
changes: flatChanges,
|
|
731
|
+
previous: await encryptForStorage(previous, user),
|
|
735
732
|
},
|
|
736
733
|
metadata: buildEventMetadata(user),
|
|
737
734
|
});
|
|
738
735
|
|
|
739
|
-
// Live==Rebuild via applyEntityEvent
|
|
740
|
-
//
|
|
736
|
+
// Live==Rebuild via applyEntityEvent mit demselben StoredEvent —
|
|
737
|
+
// apply liest nur `changes`, und die sind live wie im Replay
|
|
738
|
+
// identischer Ciphertext (#967).
|
|
741
739
|
//
|
|
742
740
|
// F8-Patch: dasselbe unique-violation-handling wie im create-Pfad
|
|
743
741
|
// — ein update das einen unique-Index verletzt (z.B. email-update
|
|
744
742
|
// auf einen schon-existierenden Wert) wird mit 409 unique_violation
|
|
745
743
|
// statt 500 internal_error rückgemeldet.
|
|
746
|
-
const liveEvent = {
|
|
747
|
-
...event,
|
|
748
|
-
payload: { changes: flatChanges, previous },
|
|
749
|
-
};
|
|
750
744
|
let result: Awaited<ReturnType<typeof applyEntityEvent>>;
|
|
751
745
|
try {
|
|
752
|
-
result = await applyEntityEvent(
|
|
746
|
+
result = await applyEntityEvent(event, table, entity, db.raw);
|
|
753
747
|
} catch (e) {
|
|
754
748
|
const mapped = tryMapUniqueViolation(e, entityName);
|
|
755
749
|
if (mapped) return mapped;
|
|
@@ -837,17 +831,16 @@ export function createEventStoreExecutor(
|
|
|
837
831
|
// Deletes carry the full pre-delete row as `previous`. That's what
|
|
838
832
|
// projections and downstream consumers need to reverse any aggregates —
|
|
839
833
|
// a `{}`-payload delete would make cross-aggregate projections impossible
|
|
840
|
-
// to rebuild from the event log alone.
|
|
841
|
-
//
|
|
842
|
-
//
|
|
843
|
-
// immutable log.
|
|
834
|
+
// to rebuild from the event log alone. `existing` came from loadById(),
|
|
835
|
+
// which decrypts — re-encrypt before persisting so plaintext doesn't
|
|
836
|
+
// land in the immutable log.
|
|
844
837
|
const event = await append(db.raw, {
|
|
845
838
|
aggregateId: String(payload.id),
|
|
846
839
|
aggregateType: entityName,
|
|
847
840
|
tenantId: streamTenantFor(user),
|
|
848
841
|
expectedVersion: currentVersion,
|
|
849
842
|
type: entityEventName(entityName, "deleted"),
|
|
850
|
-
payload: { previous:
|
|
843
|
+
payload: { previous: await encryptForStorage(existing, user) },
|
|
851
844
|
metadata: buildEventMetadata(user),
|
|
852
845
|
});
|
|
853
846
|
|
|
@@ -918,7 +911,7 @@ export function createEventStoreExecutor(
|
|
|
918
911
|
type: entityEventName(entityName, "forgotten"),
|
|
919
912
|
// Re-encrypt like delete(): `existing` came decrypted from loadById —
|
|
920
913
|
// plaintext must not land in the immutable log, least of all on forget.
|
|
921
|
-
payload: { previous:
|
|
914
|
+
payload: { previous: await encryptForStorage(existing, user) },
|
|
922
915
|
metadata: buildEventMetadata(user),
|
|
923
916
|
});
|
|
924
917
|
|
|
@@ -992,14 +985,15 @@ export function createEventStoreExecutor(
|
|
|
992
985
|
// Restore carries the soft-deleted snapshot as `previous` — mirror of
|
|
993
986
|
// delete for symmetry. Projections that decremented on delete use
|
|
994
987
|
// `previous` to re-increment on restore without re-querying the entity
|
|
995
|
-
// table.
|
|
988
|
+
// table. `data` is the raw stored row — pii/encrypted fields are
|
|
989
|
+
// already ciphertext, no re-encrypt needed.
|
|
996
990
|
const event = await append(db.raw, {
|
|
997
991
|
aggregateId: String(payload.id),
|
|
998
992
|
aggregateType: entityName,
|
|
999
993
|
tenantId: streamTenantFor(user),
|
|
1000
994
|
expectedVersion: currentVersion,
|
|
1001
995
|
type: entityEventName(entityName, "restored"),
|
|
1002
|
-
payload: { previous:
|
|
996
|
+
payload: { previous: data },
|
|
1003
997
|
metadata: buildEventMetadata(user),
|
|
1004
998
|
});
|
|
1005
999
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Single enumeration of every table-bearing registration on a feature
|
|
2
|
-
// (r.projection, r.multiStreamProjection with table, r.rawTable
|
|
3
|
-
// by BOTH the setupTestStack auto-push and
|
|
4
|
-
// test-DB-push and `kumiko schema generate`
|
|
2
|
+
// (r.projection, r.multiStreamProjection with table, r.rawTable,
|
|
3
|
+
// r.unmanagedTable). Consumed by BOTH the setupTestStack auto-push and
|
|
4
|
+
// collectTableMetas — one list, so test-DB-push and `kumiko schema generate`
|
|
5
|
+
// cannot drift apart again (#255).
|
|
5
6
|
// A new table-bearing registrar must be added HERE, not in the consumers.
|
|
6
7
|
|
|
7
8
|
import type { FeatureDefinition } from "../engine/types";
|
|
@@ -31,5 +32,11 @@ export function enumerateFeatureTableSources(
|
|
|
31
32
|
for (const [name, raw] of Object.entries(feature.rawTables)) {
|
|
32
33
|
sources.push({ table: raw.table, origin: `rawTable "${name}" (${feature.name})` });
|
|
33
34
|
}
|
|
35
|
+
for (const entry of Object.values(feature.unmanagedTables)) {
|
|
36
|
+
sources.push({
|
|
37
|
+
table: entry.meta,
|
|
38
|
+
origin: `unmanagedTable "${entry.name}" (${feature.name})`,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
34
41
|
return sources;
|
|
35
42
|
}
|
|
@@ -117,6 +117,55 @@ describe("validateBoot — PII annotations", () => {
|
|
|
117
117
|
expect(() => validateBoot([feature])).toThrow(/multiple subject-key annotations/);
|
|
118
118
|
});
|
|
119
119
|
|
|
120
|
+
test("naked sensitive (no pii/encrypted) throws — event log needs ciphertext-at-rest (#967)", () => {
|
|
121
|
+
const feature = defineFeature("test", (r) => {
|
|
122
|
+
r.entity(
|
|
123
|
+
"vault",
|
|
124
|
+
createEntity({
|
|
125
|
+
fields: {
|
|
126
|
+
apiToken: createTextField({ sensitive: true }),
|
|
127
|
+
},
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
expect(() => validateBoot([feature])).toThrow(/sensitive: true.*without ciphertext-at-rest/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("sensitive with pii subject annotation passes", () => {
|
|
135
|
+
const feature = defineFeature("test", (r) => {
|
|
136
|
+
r.entity(
|
|
137
|
+
"vault",
|
|
138
|
+
createEntity({
|
|
139
|
+
fields: {
|
|
140
|
+
apiToken: createTextField({ sensitive: true, pii: true }),
|
|
141
|
+
},
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("sensitive with encrypted passes", () => {
|
|
149
|
+
process.env["KUMIKO_SECRETS_MASTER_KEY_V1"] = Buffer.from(
|
|
150
|
+
"0123456789abcdef0123456789abcdef",
|
|
151
|
+
).toString("base64");
|
|
152
|
+
try {
|
|
153
|
+
const feature = defineFeature("test", (r) => {
|
|
154
|
+
r.entity(
|
|
155
|
+
"vault",
|
|
156
|
+
createEntity({
|
|
157
|
+
fields: {
|
|
158
|
+
apiToken: createTextField({ sensitive: true, encrypted: true }),
|
|
159
|
+
},
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
164
|
+
} finally {
|
|
165
|
+
delete process.env["KUMIKO_SECRETS_MASTER_KEY_V1"];
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
120
169
|
test("userOwned.ownerField pointing to non-existent field throws", () => {
|
|
121
170
|
const feature = defineFeature("test", (r) => {
|
|
122
171
|
r.entity(
|
|
@@ -80,6 +80,23 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// sensitive-Felder liegen seit #967 als Tabellen-Ciphertext im Event-
|
|
84
|
+
// Log — ohne ciphertext-at-rest würde der Append Klartext in die
|
|
85
|
+
// immutable History schreiben.
|
|
86
|
+
const sensitiveFlags = field as {
|
|
87
|
+
readonly sensitive?: boolean;
|
|
88
|
+
readonly encrypted?: boolean;
|
|
89
|
+
}; // @cast-boundary schema-walk
|
|
90
|
+
if (
|
|
91
|
+
sensitiveFlags.sensitive === true &&
|
|
92
|
+
annotCount === 0 &&
|
|
93
|
+
sensitiveFlags.encrypted !== true
|
|
94
|
+
) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares { sensitive: true } without ciphertext-at-rest. Since #967 the event log stores sensitive fields as table ciphertext — add a subject annotation (pii / userOwned / tenantOwned) or { encrypted: true }.`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
83
100
|
// Substring-Suche/Sortierung auf Ciphertext ist prinzipbedingt
|
|
84
101
|
// unmöglich — searchable würde Plaintext-Kopien in den Suchindex
|
|
85
102
|
// schieben, sortable sortiert Base64-Blobs. Equality → lookupable.
|
|
@@ -12,13 +12,13 @@ export type FieldAccess = {
|
|
|
12
12
|
readonly write?: OwnershipMap | readonly string[];
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
-
// `sensitive: true` — the field's value is excluded from event
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// docs/plans/architecture/projections.md.
|
|
15
|
+
// `sensitive: true` — the field's value is excluded from caller-facing event
|
|
16
|
+
// echoes in write responses (#820) and MUST be ciphertext-at-rest: boot
|
|
17
|
+
// validation requires a subject annotation (pii / userOwned / tenantOwned)
|
|
18
|
+
// or `encrypted: true` (#967). Event payloads carry the table ciphertext,
|
|
19
|
+
// so replay reproduces the row byte-identically; plaintext never lands in
|
|
20
|
+
// permanent history. Use for password hashes, API tokens, bank details,
|
|
21
|
+
// tax IDs. See docs/plans/architecture/projections.md.
|
|
22
22
|
|
|
23
23
|
// --- PII / Subject-Key Annotations (DSGVO Art. 17 — Crypto-Shredding) ---
|
|
24
24
|
//
|