@cosmicdrift/kumiko-framework 0.118.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/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__/event-pii.test.ts +161 -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/blind-index.ts +114 -0
- package/src/crypto/event-pii.ts +69 -0
- package/src/crypto/index.ts +35 -0
- package/src/crypto/kms-adapter.ts +8 -0
- package/src/crypto/pg-kms-adapter.ts +173 -0
- package/src/crypto/pii-field-encryption.ts +195 -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/queries/backfill-pii.ts +276 -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 +28 -3
- package/src/engine/registry.ts +25 -0
- package/src/engine/types/feature.ts +17 -2
- package/src/engine/types/fields.ts +6 -0
- package/src/engine/types/handlers.ts +7 -0
- package/src/engine/types/index.ts +2 -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
|
@@ -64,6 +64,35 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
64
64
|
);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// lookupable (#818): nur text + nur MIT Subject-Annotation. Ohne
|
|
68
|
+
// Subject bleibt der Wert eh Klartext — der Blind-Index wäre eine
|
|
69
|
+
// zweite (deterministische!) Kopie ohne Nutzen.
|
|
70
|
+
if (annot.lookupable === true) {
|
|
71
|
+
if (field.type !== "text") {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares { lookupable: true } but has type "${field.type}" — blind-index equality lookups only apply to text fields.`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (annotCount === 0) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares { lookupable: true } without a subject annotation (pii / userOwned / tenantOwned). Plaintext fields don't need a blind index — add the subject annotation or drop lookupable.`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Substring-Suche/Sortierung auf Ciphertext ist prinzipbedingt
|
|
84
|
+
// unmöglich — searchable würde Plaintext-Kopien in den Suchindex
|
|
85
|
+
// schieben, sortable sortiert Base64-Blobs. Equality → lookupable.
|
|
86
|
+
if (annotCount > 0) {
|
|
87
|
+
const flags = field as { readonly searchable?: boolean; readonly sortable?: boolean }; // @cast-boundary schema-walk
|
|
88
|
+
if (flags.searchable === true || flags.sortable === true) {
|
|
89
|
+
const offending = flags.searchable === true ? "searchable" : "sortable";
|
|
90
|
+
throw new Error(
|
|
91
|
+
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines a subject-key annotation with { ${offending}: true } — ${offending} on encrypted fields cannot work (ciphertext at rest). For equality lookups use { lookupable: true }; for search/sort the field must stay plaintext (allowPlaintext).`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
67
96
|
if (annot.userOwned) {
|
|
68
97
|
const ownerName = annot.userOwned.ownerField;
|
|
69
98
|
if (!ownerName || typeof ownerName !== "string") {
|
|
@@ -78,21 +107,28 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
78
107
|
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" references userOwned.ownerField "${ownerName}" but no such field exists. Known fields: ${known}`,
|
|
79
108
|
);
|
|
80
109
|
}
|
|
81
|
-
|
|
110
|
+
// Text is accepted alongside reference: the ES-framework carries
|
|
111
|
+
// user ids as plain text columns throughout the bundled features
|
|
112
|
+
// (user-session.userId, invitation.invitedBy) — there is no
|
|
113
|
+
// relational FK to point a reference at. Self-referencing
|
|
114
|
+
// ownerField (the field's own value IS the owner id) rides on this.
|
|
115
|
+
if (ownerField.type !== "reference" && ownerField.type !== "text") {
|
|
82
116
|
throw new Error(
|
|
83
|
-
`[Feature ${feature.name}] userOwned.ownerField "${ownerName}" on entity "${entityName}" must be a reference field, got type "${ownerField.type}"`,
|
|
117
|
+
`[Feature ${feature.name}] userOwned.ownerField "${ownerName}" on entity "${entityName}" must be a reference or text (userId) field, got type "${ownerField.type}"`,
|
|
84
118
|
);
|
|
85
119
|
}
|
|
86
120
|
// Soft-Warning wenn das reference-target nicht offensichtlich user
|
|
87
121
|
// ist — custom subject-entities (HR-Mitarbeiter, Patient) sind
|
|
88
122
|
// erlaubt, müssen aber bewusste Wahl sein.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
123
|
+
if (ownerField.type === "reference") {
|
|
124
|
+
const refTarget = ownerField.entity;
|
|
125
|
+
const targetEntity = refTarget.includes(":") ? refTarget.split(":")[1] : refTarget;
|
|
126
|
+
if (targetEntity !== "user") {
|
|
127
|
+
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
128
|
+
console.warn(
|
|
129
|
+
`[kumiko:boot] [Feature ${feature.name}] userOwned.ownerField "${ownerName}" on entity "${entityName}" targets reference "${refTarget}" — typically should be a user reference. If intentional (custom subject-entity like employee/patient), ignore.`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
96
132
|
}
|
|
97
133
|
}
|
|
98
134
|
|
|
@@ -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,
|
|
@@ -66,6 +67,7 @@ import type {
|
|
|
66
67
|
TreeChildrenSubscribe,
|
|
67
68
|
UiHints,
|
|
68
69
|
UnmanagedTableEntry,
|
|
70
|
+
UnmanagedTableOptions,
|
|
69
71
|
ValidationHookFn,
|
|
70
72
|
WriteHandlerDef,
|
|
71
73
|
WriteHandlerFn,
|
|
@@ -510,7 +512,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
510
512
|
defineEvent: <const TInner extends string, TPayload>(
|
|
511
513
|
eventName: TInner,
|
|
512
514
|
schema: ZodType<TPayload>,
|
|
513
|
-
options?: { readonly version?: number },
|
|
515
|
+
options?: { readonly version?: number; readonly piiFields?: EventPiiFields },
|
|
514
516
|
): EventDef<TPayload, QualifiedEventName<TName, TInner>> => {
|
|
515
517
|
// Return the fully-qualified event name so callers can pass it
|
|
516
518
|
// straight to ctx.appendEvent without hand-building the
|
|
@@ -529,6 +531,27 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
529
531
|
`[Feature ${name}] defineEvent("${eventName}"): version must be a positive integer, got ${String(version)}`,
|
|
530
532
|
);
|
|
531
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
|
+
}
|
|
532
555
|
// @cast-boundary engine-bridge — runtime-string mirrors the
|
|
533
556
|
// template-literal-type via QualifiedEventName + toKebab. Both
|
|
534
557
|
// sides are tested (CamelToKebab type-tests + scan-events kebab
|
|
@@ -537,6 +560,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
537
560
|
name: qualified as QualifiedEventName<TName, TInner>,
|
|
538
561
|
schema,
|
|
539
562
|
version,
|
|
563
|
+
...(piiFields !== undefined && { piiFields }),
|
|
540
564
|
};
|
|
541
565
|
events[eventName] = def;
|
|
542
566
|
return def;
|
|
@@ -867,7 +891,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
867
891
|
};
|
|
868
892
|
},
|
|
869
893
|
|
|
870
|
-
unmanagedTable(meta: EntityTableMeta, options:
|
|
894
|
+
unmanagedTable(meta: EntityTableMeta, options: UnmanagedTableOptions): void {
|
|
871
895
|
// Name comes from the meta itself — apps already give the table a
|
|
872
896
|
// name when calling defineUnmanagedTable, no need to repeat it.
|
|
873
897
|
const tableName = meta.tableName;
|
|
@@ -896,6 +920,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
896
920
|
name: tableName,
|
|
897
921
|
meta,
|
|
898
922
|
reason: options.reason,
|
|
923
|
+
...(options.piiEncryptedOnWrite && { piiEncryptedOnWrite: true }),
|
|
899
924
|
};
|
|
900
925
|
},
|
|
901
926
|
|
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,
|
|
@@ -678,6 +680,15 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
678
680
|
`Pick a different tableName — both would emit CREATE TABLE "${umName}".`,
|
|
679
681
|
);
|
|
680
682
|
}
|
|
683
|
+
const piiFields = umDef.meta.piiSubjectFields ?? [];
|
|
684
|
+
if (piiFields.length > 0 && !umDef.piiEncryptedOnWrite) {
|
|
685
|
+
throw new Error(
|
|
686
|
+
`Unmanaged-table "${umName}" (feature "${feature.name}") has PII-annotated fields ` +
|
|
687
|
+
`(${piiFields.join(", ")}) but direct writes bypass the executor's PII encryption. ` +
|
|
688
|
+
`Encrypt those fields before every insert/update (encryptPiiFieldValues) and declare ` +
|
|
689
|
+
`{ piiEncryptedOnWrite: true }, or drop the subject annotations.`,
|
|
690
|
+
);
|
|
691
|
+
}
|
|
681
692
|
physicalTableOwners.set(umName, {
|
|
682
693
|
kind: "unmanaged",
|
|
683
694
|
owner: umName,
|
|
@@ -1354,6 +1365,16 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
1354
1365
|
return false;
|
|
1355
1366
|
})();
|
|
1356
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
|
+
|
|
1357
1378
|
// Auto-wire the soft-delete cleanup cron + its grace-days config key when ANY
|
|
1358
1379
|
// entity opts into softDelete — the framework owns this machinery, no feature
|
|
1359
1380
|
// declares it (mirrors the auto restore-handler). Job-runner reads getAllJobs
|
|
@@ -1386,6 +1407,10 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
1386
1407
|
return entityMap.get(name);
|
|
1387
1408
|
},
|
|
1388
1409
|
|
|
1410
|
+
getAllEntities(): ReadonlyMap<string, EntityDefinition> {
|
|
1411
|
+
return entityMap;
|
|
1412
|
+
},
|
|
1413
|
+
|
|
1389
1414
|
getWriteHandler(name: string): WriteHandlerDef | undefined {
|
|
1390
1415
|
return writeHandlerMap.get(name);
|
|
1391
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,
|
|
@@ -168,6 +169,15 @@ export type UnmanagedTableEntry = {
|
|
|
168
169
|
readonly name: string;
|
|
169
170
|
readonly meta: EntityTableMeta;
|
|
170
171
|
readonly reason: string;
|
|
172
|
+
readonly piiEncryptedOnWrite?: true;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** Options for r.unmanagedTable(). Direct-write stores skip the executor,
|
|
176
|
+
* so the executor's PII encryption never runs for them — a feature whose
|
|
177
|
+
* meta carries piiSubjectFields must encrypt those fields itself before
|
|
178
|
+
* every insert/update and declare that here, or boot fails (#820). */
|
|
179
|
+
export type UnmanagedTableOptions = RawTableOptions & {
|
|
180
|
+
readonly piiEncryptedOnWrite?: true;
|
|
171
181
|
};
|
|
172
182
|
|
|
173
183
|
/** Registry-aggregated unmanaged-table — adds the owning feature name. */
|
|
@@ -520,10 +530,14 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
520
530
|
// on first registration. When you bump the payload shape, raise version
|
|
521
531
|
// AND register r.eventMigration(shortName, N, N+1, transform) — the
|
|
522
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.
|
|
523
537
|
defineEvent<const TInner extends string, TPayload>(
|
|
524
538
|
name: TInner,
|
|
525
539
|
schema: ZodType<TPayload>,
|
|
526
|
-
options?: { readonly version?: number },
|
|
540
|
+
options?: { readonly version?: number; readonly piiFields?: EventPiiFields },
|
|
527
541
|
): EventDef<TPayload, QualifiedEventName<TFeature, TInner>>;
|
|
528
542
|
|
|
529
543
|
// Register a step-wise payload transform for event-schema evolution.
|
|
@@ -718,7 +732,7 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
718
732
|
// `PgTable` (legacy), unmanagedTable carries the new `EntityTableMeta`
|
|
719
733
|
// shape that `migrate-runner` consumes. After the full drizzle-cut they
|
|
720
734
|
// will likely merge; for now they coexist.
|
|
721
|
-
unmanagedTable(meta: EntityTableMeta, options:
|
|
735
|
+
unmanagedTable(meta: EntityTableMeta, options: UnmanagedTableOptions): void;
|
|
722
736
|
|
|
723
737
|
// Register the tree-actions schema for this feature — a map of
|
|
724
738
|
// action-name → action-definition (with optional typed args). At-most-
|
|
@@ -780,6 +794,7 @@ export type Registry = {
|
|
|
780
794
|
|
|
781
795
|
getFeature(name: string): FeatureDefinition | undefined;
|
|
782
796
|
getEntity(name: string): EntityDefinition | undefined;
|
|
797
|
+
getAllEntities(): ReadonlyMap<string, EntityDefinition>;
|
|
783
798
|
getWriteHandler(name: string): WriteHandlerDef | undefined;
|
|
784
799
|
getQueryHandler(name: string): QueryHandlerDef | undefined;
|
|
785
800
|
getSearchableFields(entityName: string): readonly string[];
|
|
@@ -62,6 +62,12 @@ export type PiiAnnotations = {
|
|
|
62
62
|
readonly tenantOwned?: boolean;
|
|
63
63
|
readonly anonymize?: () => unknown | Promise<unknown>;
|
|
64
64
|
readonly allowPlaintext?: string;
|
|
65
|
+
/** Equality-Lookups (fetchOne/filter eq) bleiben trotz Verschluesselung
|
|
66
|
+
* moeglich: generierte `<snake>_bidx`-Spalte traegt einen HMAC-Blind-
|
|
67
|
+
* Index, Query-Compiler matchen `(col = $1 OR col_bidx = $2)`. Nur auf
|
|
68
|
+
* text-Feldern MIT Subject-Annotation erlaubt (Boot-Validator).
|
|
69
|
+
* Siehe docs/plans/datenschutz/blind-index.md (kumiko-framework#818). */
|
|
70
|
+
readonly lookupable?: true;
|
|
65
71
|
};
|
|
66
72
|
|
|
67
73
|
// --- Retention (DSGVO Art. 5(1)(e) + HGB/AO Aufbewahrungspflichten) ---
|
|
@@ -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.
|
|
@@ -78,6 +78,7 @@ export type {
|
|
|
78
78
|
UiHints,
|
|
79
79
|
UnmanagedTableDef,
|
|
80
80
|
UnmanagedTableEntry,
|
|
81
|
+
UnmanagedTableOptions,
|
|
81
82
|
} from "./feature";
|
|
82
83
|
export type {
|
|
83
84
|
AnyFileFieldDef,
|
|
@@ -135,6 +136,7 @@ export type {
|
|
|
135
136
|
EntityRef,
|
|
136
137
|
EventDef,
|
|
137
138
|
EventMigrationDef,
|
|
139
|
+
EventPiiFields,
|
|
138
140
|
EventUpcastCtx,
|
|
139
141
|
EventUpcastFn,
|
|
140
142
|
FetchForWritingArgs,
|
|
@@ -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
|