@cosmicdrift/kumiko-framework 0.116.1 → 0.119.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 +6 -2
- package/src/api/__tests__/pii-leak-guard.integration.test.ts +103 -0
- package/src/api/pii-leak-guard.ts +45 -0
- package/src/api/server.ts +5 -0
- package/src/bun-db/query.ts +27 -2
- package/src/crypto/__tests__/blind-index.test.ts +130 -0
- package/src/crypto/__tests__/kms-adapter-contract.ts +134 -0
- package/src/crypto/__tests__/kms-adapter.contract.test.ts +4 -0
- package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +117 -0
- package/src/crypto/__tests__/pii-field-encryption.test.ts +216 -0
- package/src/crypto/__tests__/request-kms-cache.test.ts +74 -0
- package/src/crypto/__tests__/subject-resolver.test.ts +108 -0
- package/src/crypto/blind-index.ts +114 -0
- package/src/crypto/in-memory-kms-adapter.ts +50 -0
- package/src/crypto/index.ts +55 -0
- package/src/crypto/kms-adapter.ts +118 -0
- package/src/crypto/pg-kms-adapter.ts +173 -0
- package/src/crypto/pii-field-encryption.ts +181 -0
- package/src/crypto/request-kms-cache.ts +38 -0
- package/src/crypto/subject-resolver.ts +91 -0
- package/src/db/__tests__/blind-index.integration.test.ts +232 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +169 -1
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +39 -0
- package/src/db/apply-entity-event.ts +8 -0
- package/src/db/blind-index-cleanup.ts +53 -0
- package/src/db/entity-table-meta.ts +47 -2
- package/src/db/event-store-executor.ts +125 -30
- package/src/db/index.ts +1 -0
- package/src/db/table-builder.ts +98 -51
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +95 -4
- package/src/engine/__tests__/unmanaged-table.test.ts +46 -1
- package/src/engine/boot-validator/pii-retention.ts +45 -9
- package/src/engine/define-feature.ts +3 -1
- package/src/engine/registry.ts +9 -0
- package/src/engine/types/feature.ts +10 -1
- package/src/engine/types/fields.ts +6 -0
- package/src/engine/types/handlers.ts +4 -0
- package/src/engine/types/index.ts +1 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// Blind-Index end-to-end (#818): create/update schreiben die bidx-Spalte
|
|
2
|
+
// (nie ins Event-Log), Equality-Lookups matchen über den OR-Rewrite sowohl
|
|
3
|
+
// verschlüsselte als auch Klartext-Alt-Rows, Rebuild recomputet bidx aus
|
|
4
|
+
// dem Ciphertext (erased → NULL), und der Forget-Sweep nullt sofort.
|
|
5
|
+
|
|
6
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import {
|
|
8
|
+
computeBlindIndex,
|
|
9
|
+
configureBlindIndexKey,
|
|
10
|
+
configurePiiSubjectKms,
|
|
11
|
+
decodeBlindIndexKey,
|
|
12
|
+
InMemoryKmsAdapter,
|
|
13
|
+
isPiiCiphertext,
|
|
14
|
+
resetBlindIndexKeyForTests,
|
|
15
|
+
resetPiiSubjectKmsForTests,
|
|
16
|
+
subjectIdToKey,
|
|
17
|
+
} from "../../crypto";
|
|
18
|
+
import { defineFeature } from "../../engine/define-feature";
|
|
19
|
+
import { createEntity, createTextField } from "../../engine/factories";
|
|
20
|
+
import { createRegistry } from "../../engine/registry";
|
|
21
|
+
import { createEventsTable } from "../../event-store";
|
|
22
|
+
import { rebuildProjection } from "../../pipeline";
|
|
23
|
+
import { createProjectionStateTable } from "../../pipeline/projection-state";
|
|
24
|
+
import { createTestDb, type TestDb, TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
25
|
+
import { nullBlindIndexesForSubject } from "../blind-index-cleanup";
|
|
26
|
+
import { createEventStoreExecutor } from "../event-store-executor";
|
|
27
|
+
import { asRawClient, fetchOne } from "../query";
|
|
28
|
+
import { buildEntityTable } from "../table-builder";
|
|
29
|
+
import { createTenantDb, type TenantDb } from "../tenant-db";
|
|
30
|
+
|
|
31
|
+
const TEST_KEY_B64 = Buffer.alloc(32, 7).toString("base64");
|
|
32
|
+
const TEST_KEY = decodeBlindIndexKey(TEST_KEY_B64);
|
|
33
|
+
|
|
34
|
+
const personEntity = createEntity({
|
|
35
|
+
table: "read_bidx_persons",
|
|
36
|
+
fields: {
|
|
37
|
+
email: createTextField({ required: true, pii: true, lookupable: true }),
|
|
38
|
+
firstName: createTextField(),
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
const personFeature = defineFeature("bidxtest", (r) => {
|
|
42
|
+
r.entity("person", personEntity);
|
|
43
|
+
});
|
|
44
|
+
const personTable = buildEntityTable("person", personEntity);
|
|
45
|
+
|
|
46
|
+
let testDb: TestDb;
|
|
47
|
+
let tdb: TenantDb;
|
|
48
|
+
let kms: InMemoryKmsAdapter;
|
|
49
|
+
const adminUser = TestUsers.admin;
|
|
50
|
+
|
|
51
|
+
beforeAll(async () => {
|
|
52
|
+
testDb = await createTestDb();
|
|
53
|
+
await unsafeCreateEntityTable(testDb.db, personEntity, "person");
|
|
54
|
+
await createEventsTable(testDb.db);
|
|
55
|
+
await createProjectionStateTable(testDb.db);
|
|
56
|
+
tdb = createTenantDb(testDb.db, adminUser.tenantId);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterAll(async () => {
|
|
60
|
+
await testDb.cleanup();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
beforeEach(async () => {
|
|
64
|
+
await asRawClient(testDb.db).unsafe(
|
|
65
|
+
`TRUNCATE kumiko_events, read_bidx_persons, kumiko_projections RESTART IDENTITY CASCADE`,
|
|
66
|
+
);
|
|
67
|
+
kms = new InMemoryKmsAdapter();
|
|
68
|
+
configurePiiSubjectKms(kms);
|
|
69
|
+
configureBlindIndexKey(TEST_KEY_B64);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
resetPiiSubjectKmsForTests();
|
|
74
|
+
resetBlindIndexKeyForTests();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const crud = createEventStoreExecutor(personTable, personEntity, { entityName: "person" });
|
|
78
|
+
|
|
79
|
+
async function rawRow(id: string): Promise<Record<string, unknown>> {
|
|
80
|
+
const rows = await asRawClient(testDb.db).unsafe<Record<string, unknown>>(
|
|
81
|
+
`SELECT * FROM read_bidx_persons WHERE id = $1`,
|
|
82
|
+
[id],
|
|
83
|
+
);
|
|
84
|
+
const row = rows[0];
|
|
85
|
+
if (!row) throw new Error(`no row for ${id}`);
|
|
86
|
+
return row;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe("blind-index write path", () => {
|
|
90
|
+
test("create: bidx in the row, ciphertext in the email column, NOTHING in the event", async () => {
|
|
91
|
+
const created = await crud.create({ email: "marc@example.com" }, adminUser, tdb);
|
|
92
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
93
|
+
|
|
94
|
+
const row = await rawRow(String(created.data.id));
|
|
95
|
+
expect(row["email_bidx"]).toBe(computeBlindIndex(TEST_KEY, "marc@example.com"));
|
|
96
|
+
expect(isPiiCiphertext(row["email"])).toBe(true);
|
|
97
|
+
|
|
98
|
+
// Executor-Response: plaintext email, kein bidx-Leak.
|
|
99
|
+
expect(created.data.data["email"]).toBe("marc@example.com");
|
|
100
|
+
expect("emailBidx" in created.data.data).toBe(false);
|
|
101
|
+
|
|
102
|
+
const events = await asRawClient(testDb.db).unsafe<{ payload: unknown }>(
|
|
103
|
+
`SELECT payload FROM kumiko_events WHERE aggregate_id = $1`,
|
|
104
|
+
[String(created.data.id)],
|
|
105
|
+
);
|
|
106
|
+
const payloadText = JSON.stringify(events.map((e) => e.payload));
|
|
107
|
+
expect(payloadText).not.toContain("bidx");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("update recomputes bidx for the changed field", async () => {
|
|
111
|
+
const created = await crud.create({ email: "old@example.com" }, adminUser, tdb);
|
|
112
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
113
|
+
await crud.update(
|
|
114
|
+
{ id: created.data.id, version: 1, changes: { email: "new@example.com" } },
|
|
115
|
+
adminUser,
|
|
116
|
+
tdb,
|
|
117
|
+
);
|
|
118
|
+
const row = await rawRow(String(created.data.id));
|
|
119
|
+
expect(row["email_bidx"]).toBe(computeBlindIndex(TEST_KEY, "new@example.com"));
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("blind-index lookup (OR-rewrite)", () => {
|
|
124
|
+
test("fetchOne by plaintext email finds the encrypted row", async () => {
|
|
125
|
+
const created = await crud.create({ email: "marc@example.com" }, adminUser, tdb);
|
|
126
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
127
|
+
|
|
128
|
+
const hit = await fetchOne<Record<string, unknown>>(tdb, personTable, {
|
|
129
|
+
email: "marc@example.com",
|
|
130
|
+
});
|
|
131
|
+
expect(hit).toBeDefined();
|
|
132
|
+
expect(hit?.["id"]).toBe(created.data.id);
|
|
133
|
+
// Read-Strip: bidx nie am Caller.
|
|
134
|
+
expect(hit && "emailBidx" in hit).toBe(false);
|
|
135
|
+
expect(hit && "email_bidx" in hit).toBe(false);
|
|
136
|
+
|
|
137
|
+
expect(await fetchOne(tdb, personTable, { email: "other@example.com" })).toBeUndefined();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("plaintext legacy row (written pre-rollout) still matches the same query", async () => {
|
|
141
|
+
// Rollout-Zustand VOR KMS+Key: Klartext-Row, bidx NULL.
|
|
142
|
+
resetPiiSubjectKmsForTests();
|
|
143
|
+
resetBlindIndexKeyForTests();
|
|
144
|
+
const legacy = await crud.create({ email: "legacy@example.com" }, adminUser, tdb);
|
|
145
|
+
if (!legacy.isSuccess) throw new Error("create failed");
|
|
146
|
+
const legacyRow = await rawRow(String(legacy.data.id));
|
|
147
|
+
expect(legacyRow["email"]).toBe("legacy@example.com");
|
|
148
|
+
expect(legacyRow["email_bidx"]).toBeNull();
|
|
149
|
+
|
|
150
|
+
// Rollout an: dieselbe Query matcht über den Plaintext-Arm.
|
|
151
|
+
configurePiiSubjectKms(kms);
|
|
152
|
+
configureBlindIndexKey(TEST_KEY_B64);
|
|
153
|
+
const hit = await fetchOne<Record<string, unknown>>(tdb, personTable, {
|
|
154
|
+
email: "legacy@example.com",
|
|
155
|
+
});
|
|
156
|
+
expect(hit?.["id"]).toBe(legacy.data.id);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("list filter eq matches the encrypted row and strips bidx", async () => {
|
|
160
|
+
const created = await crud.create(
|
|
161
|
+
{ email: "marc@example.com", firstName: "Marc" },
|
|
162
|
+
adminUser,
|
|
163
|
+
tdb,
|
|
164
|
+
);
|
|
165
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
166
|
+
|
|
167
|
+
const page = await crud.list(
|
|
168
|
+
{ filter: { field: "email", op: "eq", value: "marc@example.com" } },
|
|
169
|
+
adminUser,
|
|
170
|
+
tdb,
|
|
171
|
+
);
|
|
172
|
+
expect(page.rows).toHaveLength(1);
|
|
173
|
+
expect(page.rows[0]?.["email"]).toBe("marc@example.com");
|
|
174
|
+
expect(page.rows[0] && "emailBidx" in page.rows[0]).toBe(false);
|
|
175
|
+
|
|
176
|
+
const miss = await crud.list(
|
|
177
|
+
{ filter: { field: "email", op: "eq", value: "nobody@example.com" } },
|
|
178
|
+
adminUser,
|
|
179
|
+
tdb,
|
|
180
|
+
);
|
|
181
|
+
expect(miss.rows).toHaveLength(0);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("blind-index rebuild + forget", () => {
|
|
186
|
+
const implicitName = "bidxtest:projection:person-entity";
|
|
187
|
+
|
|
188
|
+
test("rebuild recomputes bidx from ciphertext (identical to live)", async () => {
|
|
189
|
+
const created = await crud.create({ email: "marc@example.com" }, adminUser, tdb);
|
|
190
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
191
|
+
const liveBidx = (await rawRow(String(created.data.id)))["email_bidx"];
|
|
192
|
+
|
|
193
|
+
const registry = createRegistry([personFeature]);
|
|
194
|
+
expect(registry.getAllProjections().has(implicitName)).toBe(true);
|
|
195
|
+
await rebuildProjection(implicitName, { db: testDb.db, registry });
|
|
196
|
+
|
|
197
|
+
const rebuilt = await rawRow(String(created.data.id));
|
|
198
|
+
expect(rebuilt["email_bidx"]).toBe(liveBidx);
|
|
199
|
+
expect(rebuilt["email_bidx"]).toBe(computeBlindIndex(TEST_KEY, "marc@example.com"));
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("erased subject → rebuild sets bidx NULL, lookup stops matching", async () => {
|
|
203
|
+
const created = await crud.create({ email: "marc@example.com" }, adminUser, tdb);
|
|
204
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
205
|
+
|
|
206
|
+
await kms.eraseKey({ kind: "user", userId: String(created.data.id) });
|
|
207
|
+
|
|
208
|
+
const registry = createRegistry([personFeature]);
|
|
209
|
+
await rebuildProjection(implicitName, { db: testDb.db, registry });
|
|
210
|
+
|
|
211
|
+
const rebuilt = await rawRow(String(created.data.id));
|
|
212
|
+
expect(rebuilt["email_bidx"]).toBeNull();
|
|
213
|
+
expect(await fetchOne(tdb, personTable, { email: "marc@example.com" })).toBeUndefined();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("nullBlindIndexesForSubject nulls bidx immediately (no rebuild needed)", async () => {
|
|
217
|
+
const created = await crud.create({ email: "marc@example.com" }, adminUser, tdb);
|
|
218
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
219
|
+
expect((await rawRow(String(created.data.id)))["email_bidx"]).not.toBeNull();
|
|
220
|
+
|
|
221
|
+
await kms.eraseKey({ kind: "user", userId: String(created.data.id) });
|
|
222
|
+
const registry = createRegistry([personFeature]);
|
|
223
|
+
await nullBlindIndexesForSubject(
|
|
224
|
+
testDb.db,
|
|
225
|
+
registry.features,
|
|
226
|
+
subjectIdToKey({ kind: "user", userId: String(created.data.id) }),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
expect((await rawRow(String(created.data.id)))["email_bidx"]).toBeNull();
|
|
230
|
+
expect(await fetchOne(tdb, personTable, { email: "marc@example.com" })).toBeUndefined();
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { InMemoryKmsAdapter, PII_ERASED_SENTINEL } from "../../crypto";
|
|
2
3
|
import { asRawClient } from "../../db/query";
|
|
3
4
|
import { createBooleanField, createEntity, createTextField } from "../../engine";
|
|
4
|
-
import { append, createEventsTable } from "../../event-store";
|
|
5
|
+
import { append, createEventsTable, loadEventsAfterVersion } from "../../event-store";
|
|
5
6
|
import type { EntityCache } from "../../pipeline/entity-cache";
|
|
6
7
|
import {
|
|
7
8
|
createTestDb,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
unsafeCreateEntityTable,
|
|
12
13
|
} from "../../stack";
|
|
13
14
|
import { createTestEnvelopeCipher } from "../../testing";
|
|
15
|
+
import { applyEntityEvent } from "../apply-entity-event";
|
|
14
16
|
import { resetEntityFieldEncryptionCacheForTests } from "../entity-field-encryption";
|
|
15
17
|
import { createEventStoreExecutor } from "../event-store-executor";
|
|
16
18
|
import { buildEntityTable } from "../table-builder";
|
|
@@ -553,4 +555,170 @@ describe("event-store-executor — entity cache + encrypted fields", () => {
|
|
|
553
555
|
const second = await cachedEncryptedCrud.detail({ id }, adminUser, tdb);
|
|
554
556
|
expect(second?.["secretNote"]).toBe("cache-plaintext-note");
|
|
555
557
|
});
|
|
558
|
+
|
|
559
|
+
// Regression twin pack for the list() path:
|
|
560
|
+
// 1. list decrypted BEFORE the snake→camel coercion, so any multi-word
|
|
561
|
+
// encrypted/pii field (secret_note vs secretNote) came back as raw
|
|
562
|
+
// ciphertext to the caller.
|
|
563
|
+
// 2. list's mset cached the decrypted rows — plaintext in Redis, the
|
|
564
|
+
// exact leak the detail() path re-encrypts to avoid.
|
|
565
|
+
test("list() returns plaintext for camelCase encrypted fields and caches ciphertext", async () => {
|
|
566
|
+
const created = await cachedEncryptedCrud.create(
|
|
567
|
+
{ email: "cache-list@test.de", secretNote: "list-plaintext-note" },
|
|
568
|
+
adminUser,
|
|
569
|
+
tdb,
|
|
570
|
+
);
|
|
571
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
572
|
+
const storeKey = `${adminUser.tenantId}:esExecEncrypted:${created.data.id}`;
|
|
573
|
+
store.clear();
|
|
574
|
+
|
|
575
|
+
const list = await cachedEncryptedCrud.list({}, adminUser, tdb);
|
|
576
|
+
const listed = list.rows.find((r) => r["id"] === created.data.id);
|
|
577
|
+
expect(listed?.["secretNote"]).toBe("list-plaintext-note");
|
|
578
|
+
|
|
579
|
+
const cachedRaw = store.get(storeKey);
|
|
580
|
+
expect(cachedRaw?.["secretNote"]).toBeDefined();
|
|
581
|
+
expect(cachedRaw?.["secretNote"]).not.toBe("list-plaintext-note");
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
// PII subject encryption (crypto-shredding, #724 phase C): pii/userOwned/
|
|
586
|
+
// tenantOwned fields are encrypted with the erase subject's DEK. Event
|
|
587
|
+
// payload AND projection row carry ciphertext (live == rebuild); erasing the
|
|
588
|
+
// subject key renders every value as the [[erased]] sentinel without
|
|
589
|
+
// touching the immutable log.
|
|
590
|
+
const piiEntity = createEntity({
|
|
591
|
+
table: "read_es_exec_pii",
|
|
592
|
+
fields: {
|
|
593
|
+
email: createTextField({ required: true, pii: true }),
|
|
594
|
+
note: createTextField({ userOwned: { ownerField: "authorId" } }),
|
|
595
|
+
authorId: createTextField(),
|
|
596
|
+
plain: createTextField(),
|
|
597
|
+
},
|
|
598
|
+
});
|
|
599
|
+
const piiTable = buildEntityTable("esExecPii", piiEntity);
|
|
600
|
+
|
|
601
|
+
describe("event-store-executor — pii subject encryption", () => {
|
|
602
|
+
const kms = new InMemoryKmsAdapter();
|
|
603
|
+
const crud = createEventStoreExecutor(piiTable, piiEntity, {
|
|
604
|
+
entityName: "esExecPii",
|
|
605
|
+
kms,
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
beforeAll(async () => {
|
|
609
|
+
await unsafeCreateEntityTable(testDb.db, piiEntity, "esExecPii");
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
beforeEach(async () => {
|
|
613
|
+
await asRawClient(testDb.db).unsafe(
|
|
614
|
+
`TRUNCATE kumiko_events, read_es_exec_pii RESTART IDENTITY CASCADE`,
|
|
615
|
+
);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test("create: projection row + event payload are ciphertext, API reads plaintext", async () => {
|
|
619
|
+
const result = await crud.create({ email: "pii@test.de", plain: "visible" }, adminUser, tdb);
|
|
620
|
+
if (!result.isSuccess) throw new Error("create failed");
|
|
621
|
+
expect(result.data.data["email"]).toBe("pii@test.de");
|
|
622
|
+
|
|
623
|
+
const rawRows = (await asRawClient(testDb.db).unsafe(
|
|
624
|
+
`SELECT email, plain FROM read_es_exec_pii LIMIT 1`,
|
|
625
|
+
)) as Array<{ email: string; plain: string }>;
|
|
626
|
+
expect(rawRows[0]!.email).toStartWith(`kumiko-pii:v1:user:${result.data.id}:`);
|
|
627
|
+
expect(rawRows[0]!.plain).toBe("visible");
|
|
628
|
+
|
|
629
|
+
const events = (await asRawClient(testDb.db).unsafe(
|
|
630
|
+
`SELECT payload FROM kumiko_events WHERE type = 'esExecPii.created' LIMIT 1`,
|
|
631
|
+
)) as Array<{ payload: { email?: string } }>;
|
|
632
|
+
expect(events[0]?.payload.email).toStartWith("kumiko-pii:v1:");
|
|
633
|
+
|
|
634
|
+
const detail = await crud.detail({ id: result.data.id }, adminUser, tdb);
|
|
635
|
+
expect(detail?.["email"]).toBe("pii@test.de");
|
|
636
|
+
const list = await crud.list({}, adminUser, tdb);
|
|
637
|
+
expect(list.rows[0]?.["email"]).toBe("pii@test.de");
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
test("userOwned update without ownerField in changes resolves via the merged row", async () => {
|
|
641
|
+
const created = await crud.create(
|
|
642
|
+
{ email: "owner@test.de", note: "v1", authorId: TestUsers.admin.id },
|
|
643
|
+
adminUser,
|
|
644
|
+
tdb,
|
|
645
|
+
);
|
|
646
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
647
|
+
|
|
648
|
+
const updated = await crud.update(
|
|
649
|
+
{ id: created.data.id, version: 1, changes: { note: "v2" } },
|
|
650
|
+
adminUser,
|
|
651
|
+
tdb,
|
|
652
|
+
);
|
|
653
|
+
if (!updated.isSuccess) throw new Error("update failed");
|
|
654
|
+
expect(updated.data.data["note"]).toBe("v2");
|
|
655
|
+
|
|
656
|
+
const rawRows = (await asRawClient(testDb.db).unsafe(
|
|
657
|
+
`SELECT note FROM read_es_exec_pii LIMIT 1`,
|
|
658
|
+
)) as Array<{ note: string }>;
|
|
659
|
+
expect(rawRows[0]!.note).toStartWith(`kumiko-pii:v1:user:${TestUsers.admin.id}:`);
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
test("eraseKey: detail renders the sentinel, stored events stay byte-identical", async () => {
|
|
663
|
+
const created = await crud.create({ email: "forget@test.de" }, adminUser, tdb);
|
|
664
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
665
|
+
|
|
666
|
+
const eventsBefore = (await asRawClient(testDb.db).unsafe(
|
|
667
|
+
`SELECT payload FROM kumiko_events WHERE type = 'esExecPii.created' LIMIT 1`,
|
|
668
|
+
)) as Array<{ payload: { email?: string } }>;
|
|
669
|
+
|
|
670
|
+
await kms.eraseKey({ kind: "user", userId: String(created.data.id) });
|
|
671
|
+
|
|
672
|
+
const detail = await crud.detail({ id: created.data.id }, adminUser, tdb);
|
|
673
|
+
expect(detail?.["email"]).toBe(PII_ERASED_SENTINEL);
|
|
674
|
+
|
|
675
|
+
const eventsAfter = (await asRawClient(testDb.db).unsafe(
|
|
676
|
+
`SELECT payload FROM kumiko_events WHERE type = 'esExecPii.created' LIMIT 1`,
|
|
677
|
+
)) as Array<{ payload: { email?: string } }>;
|
|
678
|
+
expect(eventsAfter[0]?.payload.email).toBe(eventsBefore[0]!.payload.email!);
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
test("rebuild after erase resurrects no plaintext (replay writes ciphertext, reads render sentinel)", async () => {
|
|
682
|
+
const created = await crud.create({ email: "rebuild@test.de" }, adminUser, tdb);
|
|
683
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
684
|
+
await kms.eraseKey({ kind: "user", userId: String(created.data.id) });
|
|
685
|
+
|
|
686
|
+
// Same code path rebuildProjection replays: applyEntityEvent on the
|
|
687
|
+
// PERSISTED event (ciphertext payload). The response echo carries the
|
|
688
|
+
// plaintext payload since #820 and must not be used as a replay source.
|
|
689
|
+
const responseEvent = created.data.event as { payload: Record<string, unknown> } | undefined;
|
|
690
|
+
expect(responseEvent?.payload["email"]).toBe("rebuild@test.de");
|
|
691
|
+
await asRawClient(testDb.db).unsafe(`TRUNCATE read_es_exec_pii RESTART IDENTITY CASCADE`);
|
|
692
|
+
const [storedEvent] = await loadEventsAfterVersion(
|
|
693
|
+
testDb.db,
|
|
694
|
+
String(created.data.id),
|
|
695
|
+
adminUser.tenantId,
|
|
696
|
+
0,
|
|
697
|
+
);
|
|
698
|
+
if (!storedEvent) throw new Error("no persisted event for the aggregate");
|
|
699
|
+
const applied = await applyEntityEvent(storedEvent, piiTable, piiEntity, tdb.raw);
|
|
700
|
+
expect(applied.kind).toBe("applied");
|
|
701
|
+
|
|
702
|
+
const rawRows = (await asRawClient(testDb.db).unsafe(
|
|
703
|
+
`SELECT email FROM read_es_exec_pii LIMIT 1`,
|
|
704
|
+
)) as Array<{ email: string }>;
|
|
705
|
+
expect(rawRows[0]!.email).toStartWith("kumiko-pii:v1:");
|
|
706
|
+
expect(rawRows[0]!.email).not.toContain("rebuild@test.de");
|
|
707
|
+
|
|
708
|
+
const detail = await crud.detail({ id: created.data.id }, adminUser, tdb);
|
|
709
|
+
expect(detail?.["email"]).toBe(PII_ERASED_SENTINEL);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
test("without a kms adapter the engine is off: plaintext row (pre-#724 behavior)", async () => {
|
|
713
|
+
const plainCrud = createEventStoreExecutor(piiTable, piiEntity, {
|
|
714
|
+
entityName: "esExecPii",
|
|
715
|
+
});
|
|
716
|
+
const created = await plainCrud.create({ email: "off@test.de" }, adminUser, tdb);
|
|
717
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
718
|
+
|
|
719
|
+
const rawRows = (await asRawClient(testDb.db).unsafe(
|
|
720
|
+
`SELECT email FROM read_es_exec_pii LIMIT 1`,
|
|
721
|
+
)) as Array<{ email: string }>;
|
|
722
|
+
expect(rawRows[0]!.email).toBe("off@test.de");
|
|
723
|
+
});
|
|
556
724
|
});
|
|
@@ -109,3 +109,42 @@ describe("lock-step — softDelete + explizite Indexes", () => {
|
|
|
109
109
|
expect((fromBuilder?.indexes ?? []).length).toBeGreaterThanOrEqual(3);
|
|
110
110
|
});
|
|
111
111
|
});
|
|
112
|
+
|
|
113
|
+
// Dritte Probe: lookupable-Feld (#818) — bidx-Spalte, bidx-Index und das
|
|
114
|
+
// partielle Unique-Pendant müssen auf beiden Pfaden identisch entstehen.
|
|
115
|
+
const entityWithLookupable = createEntity({
|
|
116
|
+
table: "read_lockstep_probe_bidx",
|
|
117
|
+
fields: {
|
|
118
|
+
email: { type: "text", required: true, pii: true, lookupable: true },
|
|
119
|
+
tenantSlug: { type: "text", required: true },
|
|
120
|
+
},
|
|
121
|
+
indexes: [{ columns: ["tenantSlug", "email"], unique: true }],
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("lock-step — lookupable / blind-index (#818)", () => {
|
|
125
|
+
const fromBuilder = asEntityTableMeta(
|
|
126
|
+
buildEntityTable("lockstepProbeBidx", entityWithLookupable),
|
|
127
|
+
);
|
|
128
|
+
const fromMeta = buildEntityTableMeta("lockstepProbeBidx", entityWithLookupable);
|
|
129
|
+
|
|
130
|
+
test("identical columns inkl. nullable bidx-Spalte", () => {
|
|
131
|
+
expect(byName<ColumnMeta>(fromBuilder?.columns ?? [])).toEqual(
|
|
132
|
+
byName<ColumnMeta>(fromMeta.columns),
|
|
133
|
+
);
|
|
134
|
+
const bidx = (fromMeta.columns ?? []).find((c) => c.name === "email_bidx");
|
|
135
|
+
expect(bidx).toEqual({ name: "email_bidx", pgType: "text", notNull: false });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("identical indexes inkl. bidx-Index + partiellem Unique-Pendant", () => {
|
|
139
|
+
expect(byName<IndexMeta>(fromBuilder?.indexes ?? [])).toEqual(
|
|
140
|
+
byName<IndexMeta>(fromMeta.indexes),
|
|
141
|
+
);
|
|
142
|
+
const names = fromMeta.indexes.map((i) => i.name);
|
|
143
|
+
expect(names).toContain("read_lockstep_probe_bidx_email_bidx_idx");
|
|
144
|
+
const partial = fromMeta.indexes.find((i) => i.name.endsWith("_tenant_slug_email_unique_bidx"));
|
|
145
|
+
expect(partial).toBeDefined();
|
|
146
|
+
expect(partial?.unique).toBe(true);
|
|
147
|
+
expect(partial?.columns).toEqual(["tenant_slug", "email_bidx"]);
|
|
148
|
+
expect(partial?.whereSql).toBe('"email_bidx" IS NOT NULL');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
// - "skipped" → Event ist kein Auto-Verb (Domain-Event auf demselben
|
|
50
50
|
// Aggregate). Caller no-op.
|
|
51
51
|
|
|
52
|
+
import { collectLookupableFields, computeBlindIndexValues } from "../crypto/blind-index";
|
|
52
53
|
import { deleteMany, insertOne, updateMany } from "../db/query";
|
|
53
54
|
import type { EntityDefinition } from "../engine/types";
|
|
54
55
|
import { InternalError } from "../errors";
|
|
@@ -116,6 +117,11 @@ export async function applyEntityEvent(
|
|
|
116
117
|
}
|
|
117
118
|
const row = await insertOne<DbRow>(tx, table, {
|
|
118
119
|
...event.payload,
|
|
120
|
+
// Blind-Index-Spalten aus dem Payload-Wert (ciphertext → decrypt →
|
|
121
|
+
// HMAC, plaintext → HMAC, erased → NULL). Hier statt im Executor,
|
|
122
|
+
// damit Live-Write und Rebuild denselben Wert produzieren und der
|
|
123
|
+
// deterministische HMAC nie im Event-Log landet.
|
|
124
|
+
...(await computeBlindIndexValues(event.payload, collectLookupableFields(entity))),
|
|
119
125
|
tenantId,
|
|
120
126
|
id: event.aggregateId,
|
|
121
127
|
version: event.version,
|
|
@@ -142,6 +148,8 @@ export async function applyEntityEvent(
|
|
|
142
148
|
table,
|
|
143
149
|
{
|
|
144
150
|
...changes,
|
|
151
|
+
// bidx nur für die geänderten lookupable-Felder (siehe created).
|
|
152
|
+
...(await computeBlindIndexValues(changes, collectLookupableFields(entity))),
|
|
145
153
|
version: event.version,
|
|
146
154
|
modifiedAt: event.createdAt,
|
|
147
155
|
modifiedById: event.createdBy,
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Sofortiges Blind-Index-Nulling nach einem Subject-Erase (#818).
|
|
2
|
+
//
|
|
3
|
+
// Nach kms.eraseKey ist der Ciphertext unlesbar, aber die deterministische
|
|
4
|
+
// bidx-Spalte bliebe bis zum nächsten Write/Rebuild matchbar — ein
|
|
5
|
+
// Linkage-Fenster ("hat irgendeine Row den Wert X"). Dieser Sweep schließt
|
|
6
|
+
// es sofort: der Ciphertext nennt sein Subject inline
|
|
7
|
+
// (kumiko-pii:v1:<subjectKey>:...), also findet ein LIKE-Prefix-Match exakt
|
|
8
|
+
// die Rows des erased Subjects — pro lookupable-Feld ein UPDATE.
|
|
9
|
+
//
|
|
10
|
+
// Rows, die der Forget-Lauf ohnehin via Executor löscht/anonymisiert,
|
|
11
|
+
// bekommen ihren bidx dort automatisch neu berechnet; dieser Sweep deckt
|
|
12
|
+
// die liegen bleibenden Rows ab (fremde Entities mit userOwned-Feldern).
|
|
13
|
+
|
|
14
|
+
import { collectLookupableFields } from "../crypto/blind-index";
|
|
15
|
+
import type { FeatureDefinition } from "../engine/types";
|
|
16
|
+
import { toSnakeCase } from "../utils/case";
|
|
17
|
+
import type { DbRunner } from "./connection";
|
|
18
|
+
import { resolveTableName } from "./entity-table-meta";
|
|
19
|
+
import { executeRawQuery } from "./queries/raw-sql";
|
|
20
|
+
|
|
21
|
+
function quoteIdent(name: string): string {
|
|
22
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function escapeLikePattern(value: string): string {
|
|
26
|
+
return value.replace(/[\\%_]/g, (m) => `\\${m}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function nullBlindIndexesForSubject(
|
|
30
|
+
db: DbRunner,
|
|
31
|
+
features: ReadonlyMap<string, FeatureDefinition>,
|
|
32
|
+
subjectKey: string,
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
const likePattern = `kumiko-pii:v1:${escapeLikePattern(subjectKey)}:%`;
|
|
35
|
+
for (const feature of features.values()) {
|
|
36
|
+
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
37
|
+
const lookupable = collectLookupableFields(entity);
|
|
38
|
+
if (lookupable.length === 0) continue;
|
|
39
|
+
// Kein featureName-Prefix — der Dispatcher baut Entity-Tables ohne
|
|
40
|
+
// (buildEntityTable ohne featureName-Option), der Sweep muss dieselben
|
|
41
|
+
// Namen treffen.
|
|
42
|
+
const tableName = resolveTableName(entityName, entity, undefined);
|
|
43
|
+
for (const fieldName of lookupable) {
|
|
44
|
+
const snake = toSnakeCase(fieldName);
|
|
45
|
+
await executeRawQuery(
|
|
46
|
+
db,
|
|
47
|
+
`UPDATE ${quoteIdent(tableName)} SET ${quoteIdent(`${snake}_bidx`)} = NULL WHERE ${quoteIdent(snake)} LIKE $1`,
|
|
48
|
+
[likePattern],
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// auf Sondercases beschränken (child-projection-tables ohne tenant,
|
|
19
19
|
// append-only-logs mit serial PK, aggregate-ID ohne DEFAULT, …).
|
|
20
20
|
|
|
21
|
+
import { collectPiiSubjectFields } from "../crypto";
|
|
21
22
|
import type {
|
|
22
23
|
EntityDefinition,
|
|
23
24
|
EntityIndexDef,
|
|
@@ -92,6 +93,10 @@ export type EntityTableMeta = {
|
|
|
92
93
|
// trägt Verantwortung. Migration-Generator + Tooling können den
|
|
93
94
|
// discriminator nutzen um Warnungen zu rendern ("X tables are unmanaged").
|
|
94
95
|
readonly source: "managed" | "unmanaged";
|
|
96
|
+
// PII-Subject-annotated field names (pii/userOwned/tenantOwned). Set by
|
|
97
|
+
// buildEntityTableMeta so the registry can reject r.unmanagedTable stores
|
|
98
|
+
// whose direct writes would skip the executor's encryption (#820).
|
|
99
|
+
readonly piiSubjectFields?: readonly string[];
|
|
95
100
|
};
|
|
96
101
|
|
|
97
102
|
// Standard base-columns für event-sourced Read-Model-Tabellen. Spiegelt
|
|
@@ -153,7 +158,7 @@ function fieldToColumnMeta(
|
|
|
153
158
|
case "text":
|
|
154
159
|
case "longText": {
|
|
155
160
|
const def = fieldDefaultLiteral(field);
|
|
156
|
-
|
|
161
|
+
const cols: ColumnMeta[] = [
|
|
157
162
|
{
|
|
158
163
|
name: snake,
|
|
159
164
|
pgType: "text",
|
|
@@ -161,6 +166,13 @@ function fieldToColumnMeta(
|
|
|
161
166
|
...(def !== undefined && { defaultSql: def }),
|
|
162
167
|
},
|
|
163
168
|
];
|
|
169
|
+
// lookupable → HMAC-Blind-Index-Pendant. Nullable ist Pflicht:
|
|
170
|
+
// managedChangeRequiresRecreate würde eine NOT-NULL-Spalte ohne
|
|
171
|
+
// Default auf Bestandstabellen als DROP+Rebuild diffen.
|
|
172
|
+
if (field.type === "text" && field.lookupable === true) {
|
|
173
|
+
cols.push({ name: `${snake}_bidx`, pgType: "text", notNull: false });
|
|
174
|
+
}
|
|
175
|
+
return cols;
|
|
164
176
|
}
|
|
165
177
|
case "boolean": {
|
|
166
178
|
const def = fieldDefaultLiteral(field);
|
|
@@ -305,10 +317,17 @@ export function buildEntityTableMeta(
|
|
|
305
317
|
for (const c of baseCols) colByName.set(c.name, c);
|
|
306
318
|
|
|
307
319
|
const fieldNameToSnake = new Map<string, string>();
|
|
320
|
+
const bidxSnakeByFieldSnake = new Map<string, string>();
|
|
308
321
|
for (const [name, field] of Object.entries(entity.fields)) {
|
|
309
322
|
const fieldCols = fieldToColumnMeta(name, field, entity);
|
|
310
323
|
for (const c of fieldCols) colByName.set(c.name, c);
|
|
311
|
-
|
|
324
|
+
// Multi-column fields map to their primary column when its name IS the
|
|
325
|
+
// field's snake (text+bidx, money+currency) — matches the toSnakeCase
|
|
326
|
+
// fallback below, so explicit indexes keep resolving.
|
|
327
|
+
const primary = fieldCols[0];
|
|
328
|
+
if (primary && primary.name === toSnakeCase(name)) fieldNameToSnake.set(name, primary.name);
|
|
329
|
+
const bidxCol = fieldCols.find((c) => c.name.endsWith("_bidx"));
|
|
330
|
+
if (primary && bidxCol) bidxSnakeByFieldSnake.set(primary.name, bidxCol.name);
|
|
312
331
|
}
|
|
313
332
|
|
|
314
333
|
// Preserve base-col order, then any new user-col-names in fields-order.
|
|
@@ -347,6 +366,12 @@ export function buildEntityTableMeta(
|
|
|
347
366
|
indexes.push({ name: `${tableName}_${snake}_idx`, columns: [snake] });
|
|
348
367
|
}
|
|
349
368
|
|
|
369
|
+
// lookupable-Felder: Index auf der bidx-Spalte — der OR-Rewrite der
|
|
370
|
+
// Query-Compiler trifft sie bei jedem Equality-Lookup.
|
|
371
|
+
for (const bidxSnake of bidxSnakeByFieldSnake.values()) {
|
|
372
|
+
indexes.push({ name: `${tableName}_${bidxSnake}_idx`, columns: [bidxSnake] });
|
|
373
|
+
}
|
|
374
|
+
|
|
350
375
|
// Explizit deklarierte indexes (EntityIndexDef). `def.where` ist ein
|
|
351
376
|
// SqlExpression (`sql\`…\`` aus @cosmicdrift/kumiko-framework/db) —
|
|
352
377
|
// renderbar via `.text`. Unbekannte where-Shapes bleiben needsManualWhere.
|
|
@@ -364,13 +389,33 @@ export function buildEntityTableMeta(
|
|
|
364
389
|
...(whereSql !== undefined && { whereSql }),
|
|
365
390
|
...(def.where !== undefined && whereSql === undefined && { needsManualWhere: true }),
|
|
366
391
|
});
|
|
392
|
+
// Unique-Index über lookupable-Spalten: partielles bidx-Pendant, damit
|
|
393
|
+
// Uniqueness auch für verschlüsselte Rows greift. Das Original bleibt
|
|
394
|
+
// für Klartext-Alt-Rows; partial (IS NOT NULL) weil bidx bei erased/
|
|
395
|
+
// key-losen Rows NULL ist.
|
|
396
|
+
if (def.unique === true && def.where === undefined) {
|
|
397
|
+
const bidxCols = cols.map((c) => bidxSnakeByFieldSnake.get(c) ?? c);
|
|
398
|
+
if (bidxCols.some((c, i) => c !== cols[i])) {
|
|
399
|
+
const notNullParts = bidxCols
|
|
400
|
+
.filter((c, i) => c !== cols[i])
|
|
401
|
+
.map((c) => `"${c}" IS NOT NULL`);
|
|
402
|
+
indexes.push({
|
|
403
|
+
name: `${indexName}_bidx`,
|
|
404
|
+
columns: bidxCols,
|
|
405
|
+
unique: true,
|
|
406
|
+
whereSql: notNullParts.join(" AND "),
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
367
410
|
}
|
|
368
411
|
|
|
412
|
+
const piiSubjectFields = collectPiiSubjectFields(entity);
|
|
369
413
|
return {
|
|
370
414
|
tableName,
|
|
371
415
|
columns,
|
|
372
416
|
indexes,
|
|
373
417
|
source: "managed",
|
|
418
|
+
...(piiSubjectFields.length > 0 && { piiSubjectFields }),
|
|
374
419
|
};
|
|
375
420
|
}
|
|
376
421
|
|