@cosmicdrift/kumiko-framework 0.119.0 → 0.121.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 +2 -2
- package/src/crypto/__tests__/event-pii.test.ts +161 -0
- package/src/crypto/__tests__/kek-rotation.integration.test.ts +180 -0
- package/src/crypto/event-pii.ts +69 -0
- package/src/crypto/index.ts +11 -0
- package/src/crypto/pg-kms-adapter.ts +126 -4
- package/src/crypto/pii-field-encryption.ts +14 -0
- package/src/db/queries/backfill-pii.ts +276 -0
- package/src/engine/define-feature.ts +25 -2
- package/src/engine/registry.ts +16 -0
- package/src/engine/types/feature.ts +7 -1
- package/src/engine/types/handlers.ts +7 -0
- package/src/engine/types/index.ts +1 -0
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +279 -0
- package/src/event-store/event-store.ts +11 -6
- package/src/event-store/index.ts +6 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// backfillEventPiiEncryption (#799): pre-KMS plaintext events get re-
|
|
2
|
+
// encrypted in place — entity lifecycle payloads (created / updated
|
|
3
|
+
// changes+previous / forgotten previous) AND catalogued custom events.
|
|
4
|
+
// Pre-KMS-forgotten subjects (detectable only via their *.forgotten event)
|
|
5
|
+
// get [[erased]] instead of a freshly minted key. After the backfill,
|
|
6
|
+
// applyEntityEvent (the rebuild primitive) materializes ciphertext AND the
|
|
7
|
+
// blind-index column, so equality lookups keep working.
|
|
8
|
+
|
|
9
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import {
|
|
12
|
+
configureBlindIndexKey,
|
|
13
|
+
configurePiiSubjectKms,
|
|
14
|
+
InMemoryKmsAdapter,
|
|
15
|
+
isPiiCiphertext,
|
|
16
|
+
PII_ERASED_SENTINEL,
|
|
17
|
+
resetBlindIndexKeyForTests,
|
|
18
|
+
resetPiiSubjectKmsForTests,
|
|
19
|
+
} from "../../crypto";
|
|
20
|
+
import { applyEntityEvent } from "../../db/apply-entity-event";
|
|
21
|
+
import { backfillEventPiiEncryption } from "../../db/queries/backfill-pii";
|
|
22
|
+
import { asRawClient, fetchOne } from "../../db/query";
|
|
23
|
+
import { buildEntityTable } from "../../db/table-builder";
|
|
24
|
+
import { defineFeature } from "../../engine/define-feature";
|
|
25
|
+
import { createEntity, createTextField } from "../../engine/factories";
|
|
26
|
+
import { createRegistry } from "../../engine/registry";
|
|
27
|
+
import type { Registry, TenantId } from "../../engine/types";
|
|
28
|
+
import { createTestDb, type TestDb, unsafeCreateEntityTable } from "../../stack";
|
|
29
|
+
import { generateId } from "../../utils";
|
|
30
|
+
import { append, loadAggregate } from "../event-store";
|
|
31
|
+
import { createEventsTable } from "../events-schema";
|
|
32
|
+
|
|
33
|
+
const TENANT = "00000000-0000-4000-8000-000000000001" as TenantId;
|
|
34
|
+
const BIDX_KEY = Buffer.alloc(32, 5).toString("base64");
|
|
35
|
+
|
|
36
|
+
const contactEntity = createEntity({
|
|
37
|
+
fields: {
|
|
38
|
+
email: createTextField({ required: true, pii: true, lookupable: true }),
|
|
39
|
+
displayName: createTextField(),
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
const contactTable = buildEntityTable("contact", contactEntity);
|
|
43
|
+
|
|
44
|
+
const crmFeature = defineFeature("crm", (r) => {
|
|
45
|
+
r.entity("contact", contactEntity);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const mailerFeature = defineFeature("mailer", (r) => {
|
|
49
|
+
r.defineEvent(
|
|
50
|
+
"ping",
|
|
51
|
+
z.object({ targetId: z.string().nullable(), address: z.string().nullable() }),
|
|
52
|
+
{ piiFields: { address: { subjectField: "targetId" } } },
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
let testDb: TestDb;
|
|
57
|
+
let registry: Registry;
|
|
58
|
+
let kms: InMemoryKmsAdapter;
|
|
59
|
+
|
|
60
|
+
async function appendPlain(
|
|
61
|
+
aggregateId: string,
|
|
62
|
+
aggregateType: string,
|
|
63
|
+
type: string,
|
|
64
|
+
payload: Record<string, unknown>,
|
|
65
|
+
expectedVersion = 0,
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
await append(testDb.db, {
|
|
68
|
+
aggregateId,
|
|
69
|
+
aggregateType,
|
|
70
|
+
tenantId: TENANT,
|
|
71
|
+
expectedVersion,
|
|
72
|
+
type,
|
|
73
|
+
payload,
|
|
74
|
+
metadata: { userId: "system" },
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
beforeAll(async () => {
|
|
79
|
+
testDb = await createTestDb();
|
|
80
|
+
registry = createRegistry([crmFeature, mailerFeature]);
|
|
81
|
+
await createEventsTable(testDb.db);
|
|
82
|
+
await unsafeCreateEntityTable(testDb.db, contactEntity, "contact");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterAll(async () => {
|
|
86
|
+
await testDb.cleanup();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
beforeEach(async () => {
|
|
90
|
+
const raw = asRawClient(testDb.db);
|
|
91
|
+
await raw.unsafe(`TRUNCATE "kumiko_events" RESTART IDENTITY`);
|
|
92
|
+
await raw.unsafe(`TRUNCATE "${contactTable.tableName}"`);
|
|
93
|
+
// Plaintext era: NO KMS while the legacy events are appended.
|
|
94
|
+
resetPiiSubjectKmsForTests();
|
|
95
|
+
resetBlindIndexKeyForTests();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
afterEach(() => {
|
|
99
|
+
resetPiiSubjectKmsForTests();
|
|
100
|
+
resetBlindIndexKeyForTests();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
function armKms(): void {
|
|
104
|
+
kms = new InMemoryKmsAdapter();
|
|
105
|
+
configurePiiSubjectKms(kms);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe("backfillEventPiiEncryption", () => {
|
|
109
|
+
test("encrypts entity lifecycle payloads: created flat, updated changes+previous", async () => {
|
|
110
|
+
const c1 = generateId();
|
|
111
|
+
await appendPlain(c1, "contact", "contact.created", {
|
|
112
|
+
id: c1,
|
|
113
|
+
email: "old@x.com",
|
|
114
|
+
displayName: "Alice",
|
|
115
|
+
});
|
|
116
|
+
await appendPlain(
|
|
117
|
+
c1,
|
|
118
|
+
"contact",
|
|
119
|
+
"contact.updated",
|
|
120
|
+
{ changes: { email: "new@x.com" }, previous: { id: c1, email: "old@x.com" } },
|
|
121
|
+
1,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
armKms();
|
|
125
|
+
const result = await backfillEventPiiEncryption(testDb.db, registry);
|
|
126
|
+
|
|
127
|
+
expect(result.failures).toEqual([]);
|
|
128
|
+
expect(result.updatedEvents).toBe(2);
|
|
129
|
+
expect(result.encryptedFields).toBe(3);
|
|
130
|
+
|
|
131
|
+
const events = await loadAggregate(testDb.db, c1, TENANT);
|
|
132
|
+
const created = events[0]?.payload as Record<string, unknown>;
|
|
133
|
+
expect(isPiiCiphertext(created["email"])).toBe(true);
|
|
134
|
+
expect(String(created["email"])).toContain(`user:${c1}`);
|
|
135
|
+
expect(created["displayName"]).toBe("Alice");
|
|
136
|
+
|
|
137
|
+
const updated = events[1]?.payload as {
|
|
138
|
+
changes: Record<string, unknown>;
|
|
139
|
+
previous: Record<string, unknown>;
|
|
140
|
+
};
|
|
141
|
+
expect(isPiiCiphertext(updated.changes["email"])).toBe(true);
|
|
142
|
+
expect(isPiiCiphertext(updated.previous["email"])).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("pre-KMS-forgotten aggregate gets [[erased]], not a fresh key", async () => {
|
|
146
|
+
const c2 = generateId();
|
|
147
|
+
await appendPlain(c2, "contact", "contact.created", { id: c2, email: "gone@x.com" });
|
|
148
|
+
await appendPlain(
|
|
149
|
+
c2,
|
|
150
|
+
"contact",
|
|
151
|
+
"contact.forgotten",
|
|
152
|
+
{ previous: { id: c2, email: "gone@x.com" } },
|
|
153
|
+
1,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
armKms();
|
|
157
|
+
const result = await backfillEventPiiEncryption(testDb.db, registry);
|
|
158
|
+
|
|
159
|
+
expect(result.failures).toEqual([]);
|
|
160
|
+
expect(result.erasedFields).toBe(2);
|
|
161
|
+
const events = await loadAggregate(testDb.db, c2, TENANT);
|
|
162
|
+
const created = events[0]?.payload as Record<string, unknown>;
|
|
163
|
+
expect(created["email"]).toBe(PII_ERASED_SENTINEL);
|
|
164
|
+
const forgotten = events[1]?.payload as { previous: Record<string, unknown> };
|
|
165
|
+
expect(forgotten.previous["email"]).toBe(PII_ERASED_SENTINEL);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("catalogued custom events: encrypt under target's DEK; forgotten target → [[erased]]", async () => {
|
|
169
|
+
const forgottenUser = generateId();
|
|
170
|
+
await appendPlain(forgottenUser, "contact", "contact.created", {
|
|
171
|
+
id: forgottenUser,
|
|
172
|
+
email: "f@x.com",
|
|
173
|
+
});
|
|
174
|
+
await appendPlain(
|
|
175
|
+
forgottenUser,
|
|
176
|
+
"contact",
|
|
177
|
+
"contact.forgotten",
|
|
178
|
+
{ previous: { id: forgottenUser, email: "f@x.com" } },
|
|
179
|
+
1,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const p1 = generateId();
|
|
183
|
+
const p2 = generateId();
|
|
184
|
+
const p3 = generateId();
|
|
185
|
+
await appendPlain(p1, "ping", "mailer:event:ping", {
|
|
186
|
+
targetId: "u-7",
|
|
187
|
+
address: "u7@x.com",
|
|
188
|
+
});
|
|
189
|
+
await appendPlain(p2, "ping", "mailer:event:ping", {
|
|
190
|
+
targetId: forgottenUser,
|
|
191
|
+
address: "f@x.com",
|
|
192
|
+
});
|
|
193
|
+
await appendPlain(p3, "ping", "mailer:event:ping", { targetId: null, address: "ops@x.com" });
|
|
194
|
+
|
|
195
|
+
armKms();
|
|
196
|
+
const result = await backfillEventPiiEncryption(testDb.db, registry);
|
|
197
|
+
|
|
198
|
+
expect(result.failures).toEqual([]);
|
|
199
|
+
const alive = (await loadAggregate(testDb.db, p1, TENANT))[0]?.payload as Record<
|
|
200
|
+
string,
|
|
201
|
+
unknown
|
|
202
|
+
>;
|
|
203
|
+
expect(isPiiCiphertext(alive["address"])).toBe(true);
|
|
204
|
+
expect(String(alive["address"])).toContain("user:u-7");
|
|
205
|
+
|
|
206
|
+
const erased = (await loadAggregate(testDb.db, p2, TENANT))[0]?.payload as Record<
|
|
207
|
+
string,
|
|
208
|
+
unknown
|
|
209
|
+
>;
|
|
210
|
+
expect(erased["address"]).toBe(PII_ERASED_SENTINEL);
|
|
211
|
+
|
|
212
|
+
// No subject → no key to shred; stays plaintext (documented rollout gap).
|
|
213
|
+
const system = (await loadAggregate(testDb.db, p3, TENANT))[0]?.payload as Record<
|
|
214
|
+
string,
|
|
215
|
+
unknown
|
|
216
|
+
>;
|
|
217
|
+
expect(system["address"]).toBe("ops@x.com");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("idempotent: second run updates nothing; dryRun writes nothing", async () => {
|
|
221
|
+
const c1 = generateId();
|
|
222
|
+
await appendPlain(c1, "contact", "contact.created", { id: c1, email: "a@x.com" });
|
|
223
|
+
|
|
224
|
+
armKms();
|
|
225
|
+
const dry = await backfillEventPiiEncryption(testDb.db, registry, { dryRun: true });
|
|
226
|
+
expect(dry.updatedEvents).toBe(1);
|
|
227
|
+
const untouched = (await loadAggregate(testDb.db, c1, TENANT))[0]?.payload as Record<
|
|
228
|
+
string,
|
|
229
|
+
unknown
|
|
230
|
+
>;
|
|
231
|
+
expect(untouched["email"]).toBe("a@x.com");
|
|
232
|
+
|
|
233
|
+
const first = await backfillEventPiiEncryption(testDb.db, registry);
|
|
234
|
+
expect(first.updatedEvents).toBe(1);
|
|
235
|
+
const second = await backfillEventPiiEncryption(testDb.db, registry);
|
|
236
|
+
expect(second.updatedEvents).toBe(0);
|
|
237
|
+
expect(second.failures).toEqual([]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("small batchSize pages through the estate completely", async () => {
|
|
241
|
+
const ids = [generateId(), generateId(), generateId(), generateId(), generateId()];
|
|
242
|
+
for (const id of ids) {
|
|
243
|
+
await appendPlain(id, "contact", "contact.created", { id, email: `${id}@x.com` });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
armKms();
|
|
247
|
+
const result = await backfillEventPiiEncryption(testDb.db, registry, { batchSize: 2 });
|
|
248
|
+
expect(result.scannedEvents).toBe(5);
|
|
249
|
+
expect(result.updatedEvents).toBe(5);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("after backfill, applyEntityEvent (rebuild) materializes ciphertext + blind index", async () => {
|
|
253
|
+
const c1 = generateId();
|
|
254
|
+
await appendPlain(c1, "contact", "contact.created", { id: c1, email: "login@x.com" });
|
|
255
|
+
|
|
256
|
+
armKms();
|
|
257
|
+
configureBlindIndexKey(BIDX_KEY);
|
|
258
|
+
await backfillEventPiiEncryption(testDb.db, registry);
|
|
259
|
+
|
|
260
|
+
const events = await loadAggregate(testDb.db, c1, TENANT);
|
|
261
|
+
const created = events[0];
|
|
262
|
+
if (!created) throw new Error("missing created event");
|
|
263
|
+
await applyEntityEvent(created, contactTable, contactEntity, testDb.db);
|
|
264
|
+
|
|
265
|
+
const row = await fetchOne(testDb.db, contactTable, { id: c1 });
|
|
266
|
+
expect(isPiiCiphertext(row?.["email"])).toBe(true);
|
|
267
|
+
const rawRows = (await asRawClient(testDb.db).unsafe(
|
|
268
|
+
`SELECT "email_bidx" FROM "${contactTable.tableName}" WHERE "id" = $1`,
|
|
269
|
+
[c1],
|
|
270
|
+
)) as ReadonlyArray<Record<string, unknown>>;
|
|
271
|
+
expect(String(rawRows[0]?.["email_bidx"])).toStartWith("kumiko-bidx:v1:");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("throws without a configured KMS", async () => {
|
|
275
|
+
expect(backfillEventPiiEncryption(testDb.db, registry)).rejects.toThrow(
|
|
276
|
+
/requires a configured subject KMS/,
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { encryptEventPayloadPii } from "../crypto/event-pii";
|
|
1
2
|
import type { DbRunner } from "../db";
|
|
2
3
|
import { isUniqueViolation } from "../db/pg-error";
|
|
3
4
|
import {
|
|
@@ -108,21 +109,25 @@ type SelectedEvent = {
|
|
|
108
109
|
export const EVENTS_PUBSUB_CHANNEL = "kumiko_events_new";
|
|
109
110
|
|
|
110
111
|
export async function append(db: DbRunner, event: EventToAppend): Promise<StoredEvent> {
|
|
111
|
-
|
|
112
|
-
|
|
112
|
+
// Event-PII (#799): stored payload AND returned echo carry ciphertext, so
|
|
113
|
+
// inline projections and rebuilds materialize identical rows.
|
|
114
|
+
const payload = await encryptEventPayloadPii(event.type, event.payload);
|
|
115
|
+
const toStore = payload === event.payload ? event : { ...event, payload };
|
|
116
|
+
const newVersion = toStore.expectedVersion + 1;
|
|
117
|
+
const eventVersion = toStore.eventVersion ?? 1;
|
|
113
118
|
|
|
114
119
|
try {
|
|
115
120
|
const row =
|
|
116
|
-
|
|
117
|
-
? await insertFirstEvent(db,
|
|
118
|
-
: await insertSubsequentEvent(db,
|
|
121
|
+
toStore.expectedVersion === 0
|
|
122
|
+
? await insertFirstEvent(db, toStore, newVersion, eventVersion)
|
|
123
|
+
: await insertSubsequentEvent(db, toStore, newVersion, eventVersion);
|
|
119
124
|
|
|
120
125
|
// NOTIFY fires on commit (PG buffers NOTIFY per TX), so subscribers never
|
|
121
126
|
// see a wake-up for an event that later rolled back. Harmless no-op when
|
|
122
127
|
// no LISTENer is attached.
|
|
123
128
|
await notifyPgChannel(db, EVENTS_PUBSUB_CHANNEL);
|
|
124
129
|
|
|
125
|
-
return buildStoredEvent(
|
|
130
|
+
return buildStoredEvent(toStore, newVersion, eventVersion, row);
|
|
126
131
|
} catch (e) {
|
|
127
132
|
if (isUniqueViolation(e)) {
|
|
128
133
|
// Only constraint left on the events table: events_aggregate_version_uq
|