@cosmicdrift/kumiko-framework 0.119.0 → 0.120.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/event-pii.ts +69 -0
- package/src/crypto/index.ts +8 -0
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.120.0",
|
|
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>",
|
|
@@ -185,7 +185,7 @@
|
|
|
185
185
|
"zod": "^4.4.3"
|
|
186
186
|
},
|
|
187
187
|
"devDependencies": {
|
|
188
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
188
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.120.0",
|
|
189
189
|
"bun-types": "^1.3.13",
|
|
190
190
|
"pino-pretty": "^13.1.3"
|
|
191
191
|
},
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Event-PII catalog (#799): defineEvent({ piiFields }) → createRegistry
|
|
2
|
+
// publishes the catalog → encryptEventPayloadPii encrypts under the owning
|
|
3
|
+
// user's DEK. append() applies this on every write path; the pure pieces
|
|
4
|
+
// are testable without a database.
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { createRegistry, defineFeature } from "../../engine";
|
|
9
|
+
import {
|
|
10
|
+
configuredEventPiiCatalog,
|
|
11
|
+
configureEventPiiCatalog,
|
|
12
|
+
encryptEventPayloadPii,
|
|
13
|
+
resetEventPiiCatalogForTests,
|
|
14
|
+
} from "../event-pii";
|
|
15
|
+
import { InMemoryKmsAdapter } from "../in-memory-kms-adapter";
|
|
16
|
+
import {
|
|
17
|
+
configurePiiSubjectKms,
|
|
18
|
+
decryptPiiFieldValues,
|
|
19
|
+
isPiiCiphertext,
|
|
20
|
+
PII_ERASED_SENTINEL,
|
|
21
|
+
resetPiiSubjectKmsForTests,
|
|
22
|
+
} from "../pii-field-encryption";
|
|
23
|
+
|
|
24
|
+
const attemptSchema = z.object({
|
|
25
|
+
recipientId: z.string().nullable(),
|
|
26
|
+
recipientAddress: z.string().nullable(),
|
|
27
|
+
status: z.string(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const EVENT_TYPE = "mailer:event:attempt";
|
|
31
|
+
|
|
32
|
+
function catalogWithAttempt(): void {
|
|
33
|
+
configureEventPiiCatalog(
|
|
34
|
+
new Map([[EVENT_TYPE, { recipientAddress: { subjectField: "recipientId" } }]]),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
resetEventPiiCatalogForTests();
|
|
40
|
+
resetPiiSubjectKmsForTests();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("defineEvent piiFields validation", () => {
|
|
44
|
+
test("valid piiFields land on the EventDef and in the registry catalog", () => {
|
|
45
|
+
const feature = defineFeature("mailer", (r) => {
|
|
46
|
+
r.defineEvent("attempt", attemptSchema, {
|
|
47
|
+
piiFields: { recipientAddress: { subjectField: "recipientId" } },
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
createRegistry([feature]);
|
|
51
|
+
expect(configuredEventPiiCatalog().get(EVENT_TYPE)).toEqual({
|
|
52
|
+
recipientAddress: { subjectField: "recipientId" },
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("pii field not on the payload schema throws at definition time", () => {
|
|
57
|
+
expect(() =>
|
|
58
|
+
defineFeature("mailer", (r) => {
|
|
59
|
+
r.defineEvent("attempt", attemptSchema, {
|
|
60
|
+
piiFields: { nope: { subjectField: "recipientId" } },
|
|
61
|
+
});
|
|
62
|
+
}),
|
|
63
|
+
).toThrow(/piiFields references "nope"/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("unknown subjectField throws at definition time", () => {
|
|
67
|
+
expect(() =>
|
|
68
|
+
defineFeature("mailer", (r) => {
|
|
69
|
+
r.defineEvent("attempt", attemptSchema, {
|
|
70
|
+
piiFields: { recipientAddress: { subjectField: "ownerId" } },
|
|
71
|
+
});
|
|
72
|
+
}),
|
|
73
|
+
).toThrow(/piiFields references "ownerId"/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("field cannot be its own subjectField", () => {
|
|
77
|
+
expect(() =>
|
|
78
|
+
defineFeature("mailer", (r) => {
|
|
79
|
+
r.defineEvent("attempt", attemptSchema, {
|
|
80
|
+
piiFields: { recipientAddress: { subjectField: "recipientAddress" } },
|
|
81
|
+
});
|
|
82
|
+
}),
|
|
83
|
+
).toThrow(/cannot use itself as subjectField/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("events without piiFields do not enter the catalog", () => {
|
|
87
|
+
const feature = defineFeature("mailer", (r) => {
|
|
88
|
+
r.defineEvent("attempt", attemptSchema);
|
|
89
|
+
});
|
|
90
|
+
createRegistry([feature]);
|
|
91
|
+
expect(configuredEventPiiCatalog().size).toBe(0);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("encryptEventPayloadPii", () => {
|
|
96
|
+
const payload = { recipientId: "u-1", recipientAddress: "u1@example.com", status: "sent" };
|
|
97
|
+
|
|
98
|
+
test("uncatalogued event type returns the payload untouched (same reference)", async () => {
|
|
99
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
100
|
+
expect(await encryptEventPayloadPii("other:event:x", payload)).toBe(payload);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("no KMS configured → plaintext passthrough (rollout mode)", async () => {
|
|
104
|
+
catalogWithAttempt();
|
|
105
|
+
expect(await encryptEventPayloadPii(EVENT_TYPE, payload)).toBe(payload);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("encrypts under the subject's DEK; subject fk stays plaintext", async () => {
|
|
109
|
+
catalogWithAttempt();
|
|
110
|
+
const kms = new InMemoryKmsAdapter();
|
|
111
|
+
configurePiiSubjectKms(kms);
|
|
112
|
+
|
|
113
|
+
const out = await encryptEventPayloadPii(EVENT_TYPE, payload);
|
|
114
|
+
expect(isPiiCiphertext(out["recipientAddress"])).toBe(true);
|
|
115
|
+
expect(String(out["recipientAddress"])).toContain("user:u-1");
|
|
116
|
+
expect(out["recipientId"]).toBe("u-1");
|
|
117
|
+
expect(out["status"]).toBe("sent");
|
|
118
|
+
|
|
119
|
+
const back = await decryptPiiFieldValues(out, ["recipientAddress"], kms, {
|
|
120
|
+
requestId: "test",
|
|
121
|
+
});
|
|
122
|
+
expect(back["recipientAddress"]).toBe("u1@example.com");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("null subject field → value stays plaintext (no user key to shred)", async () => {
|
|
126
|
+
catalogWithAttempt();
|
|
127
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
128
|
+
const systemPayload = {
|
|
129
|
+
recipientId: null,
|
|
130
|
+
recipientAddress: "ops@example.com",
|
|
131
|
+
status: "sent",
|
|
132
|
+
};
|
|
133
|
+
expect(await encryptEventPayloadPii(EVENT_TYPE, systemPayload)).toBe(systemPayload);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("null pii value passes through", async () => {
|
|
137
|
+
catalogWithAttempt();
|
|
138
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
139
|
+
const skipped = { recipientId: "u-1", recipientAddress: null, status: "skipped" };
|
|
140
|
+
expect(await encryptEventPayloadPii(EVENT_TYPE, skipped)).toBe(skipped);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("idempotent: ciphertext and erased sentinel stay as-is", async () => {
|
|
144
|
+
catalogWithAttempt();
|
|
145
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
146
|
+
const once = await encryptEventPayloadPii(EVENT_TYPE, payload);
|
|
147
|
+
const twice = await encryptEventPayloadPii(EVENT_TYPE, once);
|
|
148
|
+
expect(twice["recipientAddress"]).toBe(once["recipientAddress"]);
|
|
149
|
+
|
|
150
|
+
const erased = { ...payload, recipientAddress: PII_ERASED_SENTINEL };
|
|
151
|
+
const out = await encryptEventPayloadPii(EVENT_TYPE, erased);
|
|
152
|
+
expect(out["recipientAddress"]).toBe(PII_ERASED_SENTINEL);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("non-string pii value is a loud error, not a silent skip", async () => {
|
|
156
|
+
catalogWithAttempt();
|
|
157
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
158
|
+
const broken = { recipientId: "u-1", recipientAddress: 42, status: "sent" };
|
|
159
|
+
expect(encryptEventPayloadPii(EVENT_TYPE, broken)).rejects.toThrow(/must be a string/);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// PII on custom-event payloads (#799). Entity CRUD events get their PII
|
|
2
|
+
// encrypted by the executor; events written via ctx.appendEvent / MSP-apply /
|
|
3
|
+
// low-level append() (delivery attempt-log, jobs run-logger) had no encrypt
|
|
4
|
+
// path at all. `r.defineEvent(name, schema, { piiFields })` declares which
|
|
5
|
+
// payload fields are PII and which payload field names the owning user;
|
|
6
|
+
// createRegistry publishes the catalog and append() — the single write funnel
|
|
7
|
+
// into kumiko_events — encrypts every catalogued field. No caller can forget.
|
|
8
|
+
|
|
9
|
+
import { requestContext } from "../api/request-context";
|
|
10
|
+
import type { EventPiiFields } from "../engine/types/handlers";
|
|
11
|
+
import { configuredPiiSubjectKms, encryptPiiValueForSubject } from "./pii-field-encryption";
|
|
12
|
+
|
|
13
|
+
export type EventPiiCatalog = ReadonlyMap<string, EventPiiFields>;
|
|
14
|
+
|
|
15
|
+
// Boot-injected like configurePiiSubjectKms — createRegistry calls this with
|
|
16
|
+
// the catalog collected from all defineEvent registrations.
|
|
17
|
+
let catalog: EventPiiCatalog = new Map();
|
|
18
|
+
|
|
19
|
+
export function configureEventPiiCatalog(next: EventPiiCatalog): void {
|
|
20
|
+
catalog = next;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function configuredEventPiiCatalog(): EventPiiCatalog {
|
|
24
|
+
return catalog;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @internal test-only */
|
|
28
|
+
export function resetEventPiiCatalogForTests(): void {
|
|
29
|
+
catalog = new Map();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Encrypts catalogued payload fields under the owning user's DEK. No-op when
|
|
33
|
+
// the event type is uncatalogued or no subject KMS is configured (plaintext
|
|
34
|
+
// rollout mode — the hard boot gate governs whether that is acceptable).
|
|
35
|
+
// A null/absent subject field (system cron runs, recipient-less skip
|
|
36
|
+
// attempts) leaves the value plaintext: there is no user key to shred.
|
|
37
|
+
export async function encryptEventPayloadPii(
|
|
38
|
+
eventType: string,
|
|
39
|
+
payload: Record<string, unknown>,
|
|
40
|
+
): Promise<Record<string, unknown>> {
|
|
41
|
+
const piiFields = catalog.get(eventType);
|
|
42
|
+
if (!piiFields) return payload;
|
|
43
|
+
const kms = configuredPiiSubjectKms();
|
|
44
|
+
if (!kms) return payload;
|
|
45
|
+
|
|
46
|
+
let out: Record<string, unknown> | undefined;
|
|
47
|
+
for (const [field, spec] of Object.entries(piiFields)) {
|
|
48
|
+
const value = payload[field];
|
|
49
|
+
if (value === null || value === undefined) continue;
|
|
50
|
+
if (typeof value !== "string") {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Event "${eventType}" piiFields."${field}" must be a string payload field, got ${typeof value}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
const subjectId = payload[spec.subjectField];
|
|
56
|
+
if (typeof subjectId !== "string" || subjectId.length === 0) continue;
|
|
57
|
+
const encrypted = await encryptPiiValueForSubject(
|
|
58
|
+
kms,
|
|
59
|
+
{ kind: "user", userId: subjectId },
|
|
60
|
+
value,
|
|
61
|
+
{ requestId: requestContext.get()?.requestId ?? "append-event" },
|
|
62
|
+
);
|
|
63
|
+
if (encrypted !== value) {
|
|
64
|
+
out ??= { ...payload };
|
|
65
|
+
out[field] = encrypted;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out ?? payload;
|
|
69
|
+
}
|
package/src/crypto/index.ts
CHANGED
|
@@ -8,6 +8,13 @@ export {
|
|
|
8
8
|
decodeBlindIndexKey,
|
|
9
9
|
resetBlindIndexKeyForTests,
|
|
10
10
|
} from "./blind-index";
|
|
11
|
+
export {
|
|
12
|
+
configuredEventPiiCatalog,
|
|
13
|
+
configureEventPiiCatalog,
|
|
14
|
+
type EventPiiCatalog,
|
|
15
|
+
encryptEventPayloadPii,
|
|
16
|
+
resetEventPiiCatalogForTests,
|
|
17
|
+
} from "./event-pii";
|
|
11
18
|
export { InMemoryKmsAdapter } from "./in-memory-kms-adapter";
|
|
12
19
|
export {
|
|
13
20
|
isLocalKeyKmsAdapter,
|
|
@@ -38,6 +45,7 @@ export {
|
|
|
38
45
|
decryptPiiFieldValues,
|
|
39
46
|
type EncryptPiiOptions,
|
|
40
47
|
encryptPiiFieldValues,
|
|
48
|
+
encryptPiiValueForSubject,
|
|
41
49
|
isPiiCiphertext,
|
|
42
50
|
PII_CIPHERTEXT_PREFIX,
|
|
43
51
|
PII_ERASED_SENTINEL,
|
|
@@ -85,6 +85,20 @@ async function getOrCreateDek(
|
|
|
85
85
|
return kms.getKey(subject, ctx);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// Single-value encrypt for callers that resolve the subject themselves
|
|
89
|
+
// (event-pii catalog, backfill). Ciphertext/sentinel inputs pass through —
|
|
90
|
+
// idempotent like the field-map variant.
|
|
91
|
+
export async function encryptPiiValueForSubject(
|
|
92
|
+
kms: LocalKeyKmsAdapter,
|
|
93
|
+
subject: SubjectId,
|
|
94
|
+
value: string,
|
|
95
|
+
kmsCtx: KmsContext,
|
|
96
|
+
): Promise<string> {
|
|
97
|
+
if (isPiiCiphertext(value) || value === PII_ERASED_SENTINEL) return value;
|
|
98
|
+
const dek = await getOrCreateDek(kms, subject, kmsCtx);
|
|
99
|
+
return encryptValue(subject, dek, value);
|
|
100
|
+
}
|
|
101
|
+
|
|
88
102
|
export interface EncryptPiiOptions {
|
|
89
103
|
readonly onlyKeys?: Iterable<string>;
|
|
90
104
|
// Write-time tenant for tenantOwned fields on rows without a tenantId column.
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// One-time backfill for pre-KMS plaintext PII in kumiko_events (#799).
|
|
2
|
+
//
|
|
3
|
+
// Crypto-shredding (#724/#818) only covers NEW writes — events appended
|
|
4
|
+
// before a KMS was configured still carry plaintext (user.created email,
|
|
5
|
+
// delivery attempt recipientAddress, job payloads). This tool re-encrypts
|
|
6
|
+
// them in place, per field, under the owning subject's DEK:
|
|
7
|
+
//
|
|
8
|
+
// - entity lifecycle events (<entity>.created/updated/deleted/forgotten/
|
|
9
|
+
// restored) for every entity with PII subject annotations
|
|
10
|
+
// - custom events from the event-PII catalog (r.defineEvent piiFields)
|
|
11
|
+
//
|
|
12
|
+
// Already-forgotten subjects must NOT get a fresh key minted for their old
|
|
13
|
+
// plaintext — three erased-detection layers write [[erased]] instead:
|
|
14
|
+
// 1. KeyErasedError from the KMS (subject forgotten in the KMS era)
|
|
15
|
+
// 2. the event's own aggregate has a *.forgotten event (pre-KMS forget)
|
|
16
|
+
// 3. the resolved user subject's id has a *.forgotten event (custom
|
|
17
|
+
// events referencing a pre-KMS-forgotten user)
|
|
18
|
+
//
|
|
19
|
+
// Idempotent: ciphertext and sentinel values pass through untouched — a
|
|
20
|
+
// second run reports 0 updates. One failing event does not abort the run;
|
|
21
|
+
// failures are collected and reported (fail-loud at the caller).
|
|
22
|
+
//
|
|
23
|
+
// Snapshots of touched aggregates are dropped (they may cache plaintext);
|
|
24
|
+
// the next snapshotting load recreates them. AFTER a run, rebuild the
|
|
25
|
+
// affected projections — applyEntityEvent materializes ciphertext AND the
|
|
26
|
+
// blind-index columns, which keeps equality lookups (login by email) alive.
|
|
27
|
+
|
|
28
|
+
import { asRawClient } from "../../bun-db";
|
|
29
|
+
import { configuredEventPiiCatalog } from "../../crypto/event-pii";
|
|
30
|
+
import type { KmsContext, LocalKeyKmsAdapter, SubjectId } from "../../crypto/kms-adapter";
|
|
31
|
+
import { KeyErasedError } from "../../crypto/kms-adapter";
|
|
32
|
+
import {
|
|
33
|
+
configuredPiiSubjectKms,
|
|
34
|
+
encryptPiiValueForSubject,
|
|
35
|
+
isPiiCiphertext,
|
|
36
|
+
PII_ERASED_SENTINEL,
|
|
37
|
+
} from "../../crypto/pii-field-encryption";
|
|
38
|
+
import { collectPiiSubjectFields, resolveSubjectForField } from "../../crypto/subject-resolver";
|
|
39
|
+
import type { EntityDefinition, Registry, TenantId } from "../../engine/types";
|
|
40
|
+
import type { DbRunner } from "../connection";
|
|
41
|
+
|
|
42
|
+
const LIFECYCLE_VERBS = ["created", "updated", "deleted", "restored", "forgotten"] as const;
|
|
43
|
+
|
|
44
|
+
export type PiiBackfillFailure = {
|
|
45
|
+
readonly eventId: string;
|
|
46
|
+
readonly reason: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type PiiBackfillResult = {
|
|
50
|
+
readonly scannedEvents: number;
|
|
51
|
+
readonly updatedEvents: number;
|
|
52
|
+
readonly encryptedFields: number;
|
|
53
|
+
readonly erasedFields: number;
|
|
54
|
+
readonly deletedSnapshots: number;
|
|
55
|
+
readonly failures: readonly PiiBackfillFailure[];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type PiiBackfillOptions = {
|
|
59
|
+
readonly batchSize?: number;
|
|
60
|
+
// Scan + count only, write nothing.
|
|
61
|
+
readonly dryRun?: boolean;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
type EventRow = {
|
|
65
|
+
readonly id: bigint | string;
|
|
66
|
+
readonly aggregate_id: string;
|
|
67
|
+
readonly aggregate_type: string;
|
|
68
|
+
readonly tenant_id: string;
|
|
69
|
+
readonly type: string;
|
|
70
|
+
readonly payload: Record<string, unknown>;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
type FieldOutcome = "unchanged" | "encrypted" | "erased";
|
|
74
|
+
|
|
75
|
+
export async function backfillEventPiiEncryption(
|
|
76
|
+
db: DbRunner,
|
|
77
|
+
registry: Registry,
|
|
78
|
+
options: PiiBackfillOptions = {},
|
|
79
|
+
): Promise<PiiBackfillResult> {
|
|
80
|
+
const kms = configuredPiiSubjectKms();
|
|
81
|
+
if (!kms) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
"backfillEventPiiEncryption requires a configured subject KMS — boot with " +
|
|
84
|
+
"runProdApp({ kms }) / configurePiiSubjectKms(adapter) before running the backfill.",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const batchSize = options.batchSize ?? 500;
|
|
88
|
+
const raw = asRawClient(db);
|
|
89
|
+
const kmsCtx: KmsContext = { requestId: "pii-backfill" };
|
|
90
|
+
|
|
91
|
+
const entityTargets = new Map<
|
|
92
|
+
string,
|
|
93
|
+
{ readonly entity: EntityDefinition; readonly piiFields: readonly string[] }
|
|
94
|
+
>();
|
|
95
|
+
for (const [name, entity] of registry.getAllEntities()) {
|
|
96
|
+
const piiFields = collectPiiSubjectFields(entity);
|
|
97
|
+
if (piiFields.length > 0) entityTargets.set(name, { entity, piiFields });
|
|
98
|
+
}
|
|
99
|
+
const eventCatalog = configuredEventPiiCatalog();
|
|
100
|
+
|
|
101
|
+
const aggregateTypes = [...entityTargets.keys()];
|
|
102
|
+
const catalogTypes = [...eventCatalog.keys()];
|
|
103
|
+
const result = {
|
|
104
|
+
scannedEvents: 0,
|
|
105
|
+
updatedEvents: 0,
|
|
106
|
+
encryptedFields: 0,
|
|
107
|
+
erasedFields: 0,
|
|
108
|
+
deletedSnapshots: 0,
|
|
109
|
+
failures: [] as PiiBackfillFailure[],
|
|
110
|
+
};
|
|
111
|
+
if (aggregateTypes.length === 0 && catalogTypes.length === 0) return result;
|
|
112
|
+
|
|
113
|
+
// Pre-KMS forgets left no key tombstone — the *.forgotten event on the
|
|
114
|
+
// stream is the only durable marker. Collect once; aggregate_id doubles
|
|
115
|
+
// as the user id for user-subject lookups.
|
|
116
|
+
const forgottenRows = (await raw.unsafe(
|
|
117
|
+
`SELECT DISTINCT "aggregate_id" FROM "kumiko_events" WHERE "type" LIKE '%.forgotten'`,
|
|
118
|
+
)) as ReadonlyArray<{ aggregate_id: string }>;
|
|
119
|
+
const forgottenAggregates = new Set(forgottenRows.map((r) => r.aggregate_id));
|
|
120
|
+
|
|
121
|
+
const touchedAggregates = new Set<string>();
|
|
122
|
+
let cursor = "0";
|
|
123
|
+
|
|
124
|
+
for (;;) {
|
|
125
|
+
const rows = (await raw.unsafe(
|
|
126
|
+
`SELECT "id", "aggregate_id", "aggregate_type", "tenant_id", "type", "payload"
|
|
127
|
+
FROM "kumiko_events"
|
|
128
|
+
WHERE ("aggregate_type" = ANY($1::text[]) OR "type" = ANY($2::text[])) AND "id" > $3::bigint
|
|
129
|
+
ORDER BY "id" ASC
|
|
130
|
+
LIMIT $4`,
|
|
131
|
+
[aggregateTypes, catalogTypes, cursor, batchSize],
|
|
132
|
+
)) as ReadonlyArray<EventRow>;
|
|
133
|
+
if (rows.length === 0) break;
|
|
134
|
+
|
|
135
|
+
for (const row of rows) {
|
|
136
|
+
result.scannedEvents++;
|
|
137
|
+
try {
|
|
138
|
+
const outcome = await transformEvent(row);
|
|
139
|
+
if (outcome === null) continue;
|
|
140
|
+
result.encryptedFields += outcome.encrypted;
|
|
141
|
+
result.erasedFields += outcome.erased;
|
|
142
|
+
if (!options.dryRun) {
|
|
143
|
+
await raw.unsafe(`UPDATE "kumiko_events" SET "payload" = $1::jsonb WHERE "id" = $2`, [
|
|
144
|
+
JSON.stringify(outcome.payload),
|
|
145
|
+
row.id,
|
|
146
|
+
]);
|
|
147
|
+
}
|
|
148
|
+
result.updatedEvents++;
|
|
149
|
+
touchedAggregates.add(row.aggregate_id);
|
|
150
|
+
} catch (e) {
|
|
151
|
+
result.failures.push({
|
|
152
|
+
eventId: String(row.id),
|
|
153
|
+
reason: e instanceof Error ? e.message : String(e),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const last = rows[rows.length - 1];
|
|
158
|
+
if (last === undefined) break;
|
|
159
|
+
cursor = String(last.id);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Snapshots may cache the plaintext state of touched aggregates.
|
|
163
|
+
if (!options.dryRun && touchedAggregates.size > 0) {
|
|
164
|
+
const deleted = (await raw.unsafe(
|
|
165
|
+
`DELETE FROM "kumiko_snapshots" WHERE "aggregate_id" = ANY($1::uuid[]) RETURNING "aggregate_id"`,
|
|
166
|
+
[[...touchedAggregates]],
|
|
167
|
+
)) as ReadonlyArray<unknown>;
|
|
168
|
+
result.deletedSnapshots = deleted.length;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return result;
|
|
172
|
+
|
|
173
|
+
async function transformEvent(
|
|
174
|
+
row: EventRow,
|
|
175
|
+
): Promise<{ payload: Record<string, unknown>; encrypted: number; erased: number } | null> {
|
|
176
|
+
const counters = { encrypted: 0, erased: 0 };
|
|
177
|
+
const payload = structuredClone(row.payload);
|
|
178
|
+
|
|
179
|
+
const catalogFields = eventCatalog.get(row.type);
|
|
180
|
+
if (catalogFields) {
|
|
181
|
+
for (const [field, spec] of Object.entries(catalogFields)) {
|
|
182
|
+
const subjectId = payload[spec.subjectField];
|
|
183
|
+
if (typeof subjectId !== "string" || subjectId.length === 0) continue;
|
|
184
|
+
const outcome = await encryptField(payload, field, { kind: "user", userId: subjectId });
|
|
185
|
+
bump(outcome);
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
const target = entityTargets.get(row.aggregate_type);
|
|
189
|
+
if (!target || !isLifecycleEventOf(row.type, row.aggregate_type)) return null;
|
|
190
|
+
const sections = lifecycleSections(payload);
|
|
191
|
+
for (const section of sections) {
|
|
192
|
+
// Update-changes may carry a pii field without its owner field —
|
|
193
|
+
// resolve subjects from the merged view; aggregate_id backs the
|
|
194
|
+
// self-subject when a section lacks the id column.
|
|
195
|
+
const subjectSource: Record<string, unknown> = {
|
|
196
|
+
id: row.aggregate_id,
|
|
197
|
+
...Object.assign({}, ...sections),
|
|
198
|
+
...section,
|
|
199
|
+
};
|
|
200
|
+
for (const field of target.piiFields) {
|
|
201
|
+
const subject = resolveSubjectForField(target.entity, field, subjectSource, {
|
|
202
|
+
// @cast-boundary db-read — tenant_id column is the branded TenantId
|
|
203
|
+
tenantId: row.tenant_id as TenantId,
|
|
204
|
+
});
|
|
205
|
+
if (subject === null) continue;
|
|
206
|
+
const outcome = await encryptField(section, field, subject);
|
|
207
|
+
bump(outcome);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (counters.encrypted === 0 && counters.erased === 0) return null;
|
|
213
|
+
return { payload, ...counters };
|
|
214
|
+
|
|
215
|
+
function bump(outcome: FieldOutcome): void {
|
|
216
|
+
if (outcome === "encrypted") counters.encrypted++;
|
|
217
|
+
if (outcome === "erased") counters.erased++;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function encryptField(
|
|
221
|
+
section: Record<string, unknown>,
|
|
222
|
+
field: string,
|
|
223
|
+
subject: SubjectId,
|
|
224
|
+
): Promise<FieldOutcome> {
|
|
225
|
+
const value = section[field];
|
|
226
|
+
if (value === null || value === undefined) return "unchanged";
|
|
227
|
+
if (typeof value !== "string") return "unchanged";
|
|
228
|
+
if (isPiiCiphertext(value) || value === PII_ERASED_SENTINEL) return "unchanged";
|
|
229
|
+
if (isForgottenSubject(subject, row.aggregate_id)) {
|
|
230
|
+
section[field] = PII_ERASED_SENTINEL;
|
|
231
|
+
return "erased";
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
section[field] = await encryptPiiValueForSubject(
|
|
235
|
+
kms as LocalKeyKmsAdapter,
|
|
236
|
+
subject,
|
|
237
|
+
value,
|
|
238
|
+
kmsCtx,
|
|
239
|
+
);
|
|
240
|
+
return "encrypted";
|
|
241
|
+
} catch (e) {
|
|
242
|
+
if (e instanceof KeyErasedError) {
|
|
243
|
+
section[field] = PII_ERASED_SENTINEL;
|
|
244
|
+
return "erased";
|
|
245
|
+
}
|
|
246
|
+
throw e;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function isForgottenSubject(subject: SubjectId, aggregateId: string): boolean {
|
|
252
|
+
if (forgottenAggregates.has(aggregateId)) return true;
|
|
253
|
+
return subject.kind === "user" && forgottenAggregates.has(subject.userId);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function isLifecycleEventOf(eventType: string, aggregateType: string): boolean {
|
|
258
|
+
if (!eventType.startsWith(`${aggregateType}.`)) return false;
|
|
259
|
+
const verb = eventType.slice(aggregateType.length + 1);
|
|
260
|
+
return (LIFECYCLE_VERBS as readonly string[]).includes(verb);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// created events carry the fields flat; updated carries { changes, previous };
|
|
264
|
+
// deleted/forgotten/restored carry { previous }. Returned sections are the
|
|
265
|
+
// mutable objects INSIDE the payload clone — encryptField writes in place.
|
|
266
|
+
function lifecycleSections(payload: Record<string, unknown>): Record<string, unknown>[] {
|
|
267
|
+
const sections: Record<string, unknown>[] = [];
|
|
268
|
+
if (isRecord(payload["changes"])) sections.push(payload["changes"]);
|
|
269
|
+
if (isRecord(payload["previous"])) sections.push(payload["previous"]);
|
|
270
|
+
if (sections.length === 0) sections.push(payload);
|
|
271
|
+
return sections;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
275
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
276
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { ZodObject, type ZodType, type z } from "zod";
|
|
2
2
|
import type { EntityTableMeta } from "../db/entity-table-meta";
|
|
3
3
|
import { toTableName } from "../db/table-builder";
|
|
4
4
|
import { LifecycleHookTypes } from "./constants";
|
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
EntityRef,
|
|
20
20
|
EventDef,
|
|
21
21
|
EventMigrationDef,
|
|
22
|
+
EventPiiFields,
|
|
22
23
|
EventUpcastFn,
|
|
23
24
|
ExtensionSelectorDef,
|
|
24
25
|
FeatureDefinition,
|
|
@@ -511,7 +512,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
511
512
|
defineEvent: <const TInner extends string, TPayload>(
|
|
512
513
|
eventName: TInner,
|
|
513
514
|
schema: ZodType<TPayload>,
|
|
514
|
-
options?: { readonly version?: number },
|
|
515
|
+
options?: { readonly version?: number; readonly piiFields?: EventPiiFields },
|
|
515
516
|
): EventDef<TPayload, QualifiedEventName<TName, TInner>> => {
|
|
516
517
|
// Return the fully-qualified event name so callers can pass it
|
|
517
518
|
// straight to ctx.appendEvent without hand-building the
|
|
@@ -530,6 +531,27 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
530
531
|
`[Feature ${name}] defineEvent("${eventName}"): version must be a positive integer, got ${String(version)}`,
|
|
531
532
|
);
|
|
532
533
|
}
|
|
534
|
+
// piiFields misconfiguration is a boot-time error, not a silent
|
|
535
|
+
// plaintext leak: both the pii field and its subjectField must exist
|
|
536
|
+
// on the payload schema (checkable when the schema is a ZodObject).
|
|
537
|
+
const piiFields = options?.piiFields;
|
|
538
|
+
if (piiFields) {
|
|
539
|
+
const shape = schema instanceof ZodObject ? schema.shape : undefined;
|
|
540
|
+
for (const [field, spec] of Object.entries(piiFields)) {
|
|
541
|
+
if (field === spec.subjectField) {
|
|
542
|
+
throw new Error(
|
|
543
|
+
`[Feature ${name}] defineEvent("${eventName}"): piiFields."${field}" cannot use itself as subjectField — the subject id is a plaintext pseudonymous fk, the pii field is the value it owns.`,
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
for (const required of [field, spec.subjectField]) {
|
|
547
|
+
if (shape && !(required in shape)) {
|
|
548
|
+
throw new Error(
|
|
549
|
+
`[Feature ${name}] defineEvent("${eventName}"): piiFields references "${required}" which is not a field of the payload schema.`,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
533
555
|
// @cast-boundary engine-bridge — runtime-string mirrors the
|
|
534
556
|
// template-literal-type via QualifiedEventName + toKebab. Both
|
|
535
557
|
// sides are tested (CamelToKebab type-tests + scan-events kebab
|
|
@@ -538,6 +560,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
538
560
|
name: qualified as QualifiedEventName<TName, TInner>,
|
|
539
561
|
schema,
|
|
540
562
|
version,
|
|
563
|
+
...(piiFields !== undefined && { piiFields }),
|
|
541
564
|
};
|
|
542
565
|
events[eventName] = def;
|
|
543
566
|
return def;
|
package/src/engine/registry.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { configureEventPiiCatalog } from "../crypto/event-pii";
|
|
1
2
|
import { applyEntityEvent } from "../db/apply-entity-event";
|
|
2
3
|
import {
|
|
3
4
|
assertBackingTableSuperset,
|
|
@@ -26,6 +27,7 @@ import type {
|
|
|
26
27
|
EntityProjectionExtension,
|
|
27
28
|
EntityRelations,
|
|
28
29
|
EventDef,
|
|
30
|
+
EventPiiFields,
|
|
29
31
|
EventUpcastFn,
|
|
30
32
|
FeatureDefinition,
|
|
31
33
|
FeatureMetricDef,
|
|
@@ -1363,6 +1365,16 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
1363
1365
|
return false;
|
|
1364
1366
|
})();
|
|
1365
1367
|
|
|
1368
|
+
// Publish the event-PII catalog (#799): append() — the single write funnel
|
|
1369
|
+
// into kumiko_events — encrypts catalogued payload fields regardless of
|
|
1370
|
+
// which path produced the event (ctx.appendEvent, MSP-apply, low-level
|
|
1371
|
+
// append in delivery/jobs loggers).
|
|
1372
|
+
const eventPiiCatalog = new Map<string, EventPiiFields>();
|
|
1373
|
+
for (const [qualified, def] of eventMap) {
|
|
1374
|
+
if (def.piiFields) eventPiiCatalog.set(qualified, def.piiFields);
|
|
1375
|
+
}
|
|
1376
|
+
configureEventPiiCatalog(eventPiiCatalog);
|
|
1377
|
+
|
|
1366
1378
|
// Auto-wire the soft-delete cleanup cron + its grace-days config key when ANY
|
|
1367
1379
|
// entity opts into softDelete — the framework owns this machinery, no feature
|
|
1368
1380
|
// declares it (mirrors the auto restore-handler). Job-runner reads getAllJobs
|
|
@@ -1395,6 +1407,10 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
1395
1407
|
return entityMap.get(name);
|
|
1396
1408
|
},
|
|
1397
1409
|
|
|
1410
|
+
getAllEntities(): ReadonlyMap<string, EntityDefinition> {
|
|
1411
|
+
return entityMap;
|
|
1412
|
+
},
|
|
1413
|
+
|
|
1398
1414
|
getWriteHandler(name: string): WriteHandlerDef | undefined {
|
|
1399
1415
|
return writeHandlerMap.get(name);
|
|
1400
1416
|
},
|
|
@@ -36,6 +36,7 @@ import type {
|
|
|
36
36
|
EntityRef,
|
|
37
37
|
EventDef,
|
|
38
38
|
EventMigrationDef,
|
|
39
|
+
EventPiiFields,
|
|
39
40
|
EventUpcastFn,
|
|
40
41
|
HandlerRef,
|
|
41
42
|
NameOrRef,
|
|
@@ -529,10 +530,14 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
529
530
|
// on first registration. When you bump the payload shape, raise version
|
|
530
531
|
// AND register r.eventMigration(shortName, N, N+1, transform) — the
|
|
531
532
|
// framework refuses to boot if the chain from 1 → version has gaps.
|
|
533
|
+
//
|
|
534
|
+
// `options.piiFields` declares PII payload fields encrypted under the DEK
|
|
535
|
+
// of the user named by `subjectField` (crypto-shredding, #799). append()
|
|
536
|
+
// enforces the catalog on every write path.
|
|
532
537
|
defineEvent<const TInner extends string, TPayload>(
|
|
533
538
|
name: TInner,
|
|
534
539
|
schema: ZodType<TPayload>,
|
|
535
|
-
options?: { readonly version?: number },
|
|
540
|
+
options?: { readonly version?: number; readonly piiFields?: EventPiiFields },
|
|
536
541
|
): EventDef<TPayload, QualifiedEventName<TFeature, TInner>>;
|
|
537
542
|
|
|
538
543
|
// Register a step-wise payload transform for event-schema evolution.
|
|
@@ -789,6 +794,7 @@ export type Registry = {
|
|
|
789
794
|
|
|
790
795
|
getFeature(name: string): FeatureDefinition | undefined;
|
|
791
796
|
getEntity(name: string): EntityDefinition | undefined;
|
|
797
|
+
getAllEntities(): ReadonlyMap<string, EntityDefinition>;
|
|
792
798
|
getWriteHandler(name: string): WriteHandlerDef | undefined;
|
|
793
799
|
getQueryHandler(name: string): QueryHandlerDef | undefined;
|
|
794
800
|
getSearchableFields(entityName: string): readonly string[];
|
|
@@ -584,6 +584,12 @@ export type QualifiedEventName<
|
|
|
584
584
|
TInner extends string,
|
|
585
585
|
> = `${CamelToKebab<TFeature>}:event:${CamelToKebab<TInner>}`;
|
|
586
586
|
|
|
587
|
+
// PII payload fields on a custom event (#799): `field` is encrypted under
|
|
588
|
+
// the DEK of the user named by the payload's `subjectField` (crypto-
|
|
589
|
+
// shredding). A null subject field leaves the value plaintext — there is
|
|
590
|
+
// no user key to shred for system-triggered events.
|
|
591
|
+
export type EventPiiFields = Readonly<Record<string, { readonly subjectField: string }>>;
|
|
592
|
+
|
|
587
593
|
export type EventDef<TPayload = unknown, TName extends string = string> = {
|
|
588
594
|
readonly name: TName;
|
|
589
595
|
readonly schema: ZodType<TPayload>;
|
|
@@ -592,6 +598,7 @@ export type EventDef<TPayload = unknown, TName extends string = string> = {
|
|
|
592
598
|
// upcasts older stored events. Reads consult this to decide if upcasters
|
|
593
599
|
// need to run before the payload hits consumer code.
|
|
594
600
|
readonly version: number;
|
|
601
|
+
readonly piiFields?: EventPiiFields;
|
|
595
602
|
};
|
|
596
603
|
|
|
597
604
|
// Args for ctx.appendEvent — explicit aggregate target, Marten-style.
|
|
@@ -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
|