@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.
@@ -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
- const newVersion = event.expectedVersion + 1;
112
- const eventVersion = event.eventVersion ?? 1;
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
- event.expectedVersion === 0
117
- ? await insertFirstEvent(db, event, newVersion, eventVersion)
118
- : await insertSubsequentEvent(db, event, newVersion, eventVersion);
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(event, newVersion, eventVersion, row);
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
@@ -1,3 +1,9 @@
1
+ export {
2
+ backfillEventPiiEncryption,
3
+ type PiiBackfillFailure,
4
+ type PiiBackfillOptions,
5
+ type PiiBackfillResult,
6
+ } from "../db/queries/backfill-pii";
1
7
  export {
2
8
  type ArchiveStreamArgs,
3
9
  archivedStreamsTable,