@cosmicdrift/kumiko-framework 0.118.0 → 0.119.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +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__/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/index.ts +27 -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 +181 -0
- package/src/db/__tests__/blind-index.integration.test.ts +232 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +169 -1
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +39 -0
- package/src/db/apply-entity-event.ts +8 -0
- package/src/db/blind-index-cleanup.ts +53 -0
- package/src/db/entity-table-meta.ts +47 -2
- package/src/db/event-store-executor.ts +125 -30
- package/src/db/index.ts +1 -0
- package/src/db/table-builder.ts +98 -51
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +95 -4
- package/src/engine/__tests__/unmanaged-table.test.ts +46 -1
- package/src/engine/boot-validator/pii-retention.ts +45 -9
- package/src/engine/define-feature.ts +3 -1
- package/src/engine/registry.ts +9 -0
- package/src/engine/types/feature.ts +10 -1
- package/src/engine/types/fields.ts +6 -0
- package/src/engine/types/index.ts +1 -0
|
@@ -133,19 +133,36 @@ describe("validateBoot — PII annotations", () => {
|
|
|
133
133
|
);
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
-
test("userOwned.ownerField
|
|
136
|
+
test("userOwned.ownerField on a text field passes (ES userId-by-convention)", () => {
|
|
137
137
|
const feature = defineFeature("test", (r) => {
|
|
138
138
|
r.entity(
|
|
139
139
|
"comment",
|
|
140
140
|
createEntity({
|
|
141
141
|
fields: {
|
|
142
|
-
body: createLongTextField({ userOwned: { ownerField: "
|
|
143
|
-
|
|
142
|
+
body: createLongTextField({ userOwned: { ownerField: "authorId" } }),
|
|
143
|
+
authorId: createTextField(),
|
|
144
|
+
},
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("userOwned.ownerField pointing to a non-id-capable field throws", () => {
|
|
152
|
+
const feature = defineFeature("test", (r) => {
|
|
153
|
+
r.entity(
|
|
154
|
+
"comment",
|
|
155
|
+
createEntity({
|
|
156
|
+
fields: {
|
|
157
|
+
body: createLongTextField({ userOwned: { ownerField: "isPublic" } }),
|
|
158
|
+
isPublic: { type: "boolean" },
|
|
144
159
|
},
|
|
145
160
|
}),
|
|
146
161
|
);
|
|
147
162
|
});
|
|
148
|
-
expect(() => validateBoot([feature])).toThrow(
|
|
163
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
164
|
+
/must be a reference or text \(userId\) field, got type "boolean"/,
|
|
165
|
+
);
|
|
149
166
|
});
|
|
150
167
|
|
|
151
168
|
test("userOwned.ownerField referencing non-user entity warns", () => {
|
|
@@ -568,3 +585,77 @@ describe("validateBoot — retention", () => {
|
|
|
568
585
|
expect(matchingWarn).toBeUndefined();
|
|
569
586
|
});
|
|
570
587
|
});
|
|
588
|
+
|
|
589
|
+
describe("validateBoot — lookupable / blind-index (#818)", () => {
|
|
590
|
+
test("lookupable on subject-annotated text field passes", () => {
|
|
591
|
+
const feature = defineFeature("test", (r) => {
|
|
592
|
+
r.entity(
|
|
593
|
+
"user",
|
|
594
|
+
createEntity({
|
|
595
|
+
fields: {
|
|
596
|
+
email: createTextField({ pii: true, lookupable: true }),
|
|
597
|
+
},
|
|
598
|
+
}),
|
|
599
|
+
);
|
|
600
|
+
});
|
|
601
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
test("lookupable without subject annotation throws", () => {
|
|
605
|
+
const feature = defineFeature("test", (r) => {
|
|
606
|
+
r.entity(
|
|
607
|
+
"doc",
|
|
608
|
+
createEntity({
|
|
609
|
+
fields: {
|
|
610
|
+
slug: createTextField({ lookupable: true }),
|
|
611
|
+
},
|
|
612
|
+
}),
|
|
613
|
+
);
|
|
614
|
+
});
|
|
615
|
+
expect(() => validateBoot([feature])).toThrow(/lookupable.*without a subject annotation/);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test("lookupable on non-text field throws", () => {
|
|
619
|
+
const feature = defineFeature("test", (r) => {
|
|
620
|
+
r.entity(
|
|
621
|
+
"person",
|
|
622
|
+
createEntity({
|
|
623
|
+
fields: {
|
|
624
|
+
// longText carries PiiAnnotations too — lookupable must still be rejected.
|
|
625
|
+
bio: createLongTextField({ userOwned: { ownerField: "ownerId" }, lookupable: true }),
|
|
626
|
+
ownerId: createTextField({ required: true }),
|
|
627
|
+
},
|
|
628
|
+
}),
|
|
629
|
+
);
|
|
630
|
+
});
|
|
631
|
+
expect(() => validateBoot([feature])).toThrow(/only apply to text fields/);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
test("searchable combined with a subject annotation throws", () => {
|
|
635
|
+
const feature = defineFeature("test", (r) => {
|
|
636
|
+
r.entity(
|
|
637
|
+
"user",
|
|
638
|
+
createEntity({
|
|
639
|
+
fields: {
|
|
640
|
+
displayName: createTextField({ pii: true, searchable: true }),
|
|
641
|
+
},
|
|
642
|
+
}),
|
|
643
|
+
);
|
|
644
|
+
});
|
|
645
|
+
expect(() => validateBoot([feature])).toThrow(/searchable.*cannot work/);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
test("sortable combined with a subject annotation throws", () => {
|
|
649
|
+
const feature = defineFeature("test", (r) => {
|
|
650
|
+
r.entity(
|
|
651
|
+
"user",
|
|
652
|
+
createEntity({
|
|
653
|
+
fields: {
|
|
654
|
+
email: createTextField({ pii: true, sortable: true }),
|
|
655
|
+
},
|
|
656
|
+
}),
|
|
657
|
+
);
|
|
658
|
+
});
|
|
659
|
+
expect(() => validateBoot([feature])).toThrow(/sortable.*cannot work/);
|
|
660
|
+
});
|
|
661
|
+
});
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
// drizzle migrate-runner). See define-feature.ts / DX-4.
|
|
4
4
|
|
|
5
5
|
import { describe, expect, test } from "bun:test";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
buildEntityTableMeta,
|
|
8
|
+
defineUnmanagedTable,
|
|
9
|
+
resolveTableName,
|
|
10
|
+
} from "../../db/entity-table-meta";
|
|
7
11
|
import { defineFeature } from "../define-feature";
|
|
8
12
|
import { createEntity, createTextField } from "../index";
|
|
9
13
|
import { createRegistry } from "../registry";
|
|
@@ -125,3 +129,44 @@ describe("createRegistry — unmanagedTable aggregation", () => {
|
|
|
125
129
|
);
|
|
126
130
|
});
|
|
127
131
|
});
|
|
132
|
+
|
|
133
|
+
describe("createRegistry — unmanaged tables with PII-annotated fields (#820)", () => {
|
|
134
|
+
const piiEntity = createEntity({
|
|
135
|
+
table: "ut_pii_probe",
|
|
136
|
+
fields: {
|
|
137
|
+
userId: createTextField({ required: true }),
|
|
138
|
+
ip: createTextField({ userOwned: { ownerField: "userId" } }),
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
const piiMeta = buildEntityTableMeta("ut-pii-probe", piiEntity);
|
|
142
|
+
|
|
143
|
+
test("buildEntityTableMeta records the subject-annotated field names", () => {
|
|
144
|
+
expect(piiMeta.piiSubjectFields).toEqual(["ip"]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("rejects a PII-carrying unmanaged table without piiEncryptedOnWrite", () => {
|
|
148
|
+
const feat = defineFeature("probe", (r) => {
|
|
149
|
+
r.unmanagedTable(piiMeta, { reason: "direct-write store" });
|
|
150
|
+
});
|
|
151
|
+
expect(() => createRegistry([feat])).toThrow(
|
|
152
|
+
/has PII-annotated fields \(ip\) but direct writes bypass the executor's PII encryption/,
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("accepts it once the feature declares piiEncryptedOnWrite", () => {
|
|
157
|
+
const feat = defineFeature("probe", (r) => {
|
|
158
|
+
r.unmanagedTable(piiMeta, {
|
|
159
|
+
reason: "direct-write store",
|
|
160
|
+
piiEncryptedOnWrite: true,
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
expect(() => createRegistry([feat])).not.toThrow();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("a PII-free unmanaged table needs no declaration", () => {
|
|
167
|
+
const feat = defineFeature("probe", (r) => {
|
|
168
|
+
r.unmanagedTable(probeMeta, { reason: "plain store" });
|
|
169
|
+
});
|
|
170
|
+
expect(() => createRegistry([feat])).not.toThrow();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -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
|
|
|
@@ -66,6 +66,7 @@ import type {
|
|
|
66
66
|
TreeChildrenSubscribe,
|
|
67
67
|
UiHints,
|
|
68
68
|
UnmanagedTableEntry,
|
|
69
|
+
UnmanagedTableOptions,
|
|
69
70
|
ValidationHookFn,
|
|
70
71
|
WriteHandlerDef,
|
|
71
72
|
WriteHandlerFn,
|
|
@@ -867,7 +868,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
867
868
|
};
|
|
868
869
|
},
|
|
869
870
|
|
|
870
|
-
unmanagedTable(meta: EntityTableMeta, options:
|
|
871
|
+
unmanagedTable(meta: EntityTableMeta, options: UnmanagedTableOptions): void {
|
|
871
872
|
// Name comes from the meta itself — apps already give the table a
|
|
872
873
|
// name when calling defineUnmanagedTable, no need to repeat it.
|
|
873
874
|
const tableName = meta.tableName;
|
|
@@ -896,6 +897,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
896
897
|
name: tableName,
|
|
897
898
|
meta,
|
|
898
899
|
reason: options.reason,
|
|
900
|
+
...(options.piiEncryptedOnWrite && { piiEncryptedOnWrite: true }),
|
|
899
901
|
};
|
|
900
902
|
},
|
|
901
903
|
|
package/src/engine/registry.ts
CHANGED
|
@@ -678,6 +678,15 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
678
678
|
`Pick a different tableName — both would emit CREATE TABLE "${umName}".`,
|
|
679
679
|
);
|
|
680
680
|
}
|
|
681
|
+
const piiFields = umDef.meta.piiSubjectFields ?? [];
|
|
682
|
+
if (piiFields.length > 0 && !umDef.piiEncryptedOnWrite) {
|
|
683
|
+
throw new Error(
|
|
684
|
+
`Unmanaged-table "${umName}" (feature "${feature.name}") has PII-annotated fields ` +
|
|
685
|
+
`(${piiFields.join(", ")}) but direct writes bypass the executor's PII encryption. ` +
|
|
686
|
+
`Encrypt those fields before every insert/update (encryptPiiFieldValues) and declare ` +
|
|
687
|
+
`{ piiEncryptedOnWrite: true }, or drop the subject annotations.`,
|
|
688
|
+
);
|
|
689
|
+
}
|
|
681
690
|
physicalTableOwners.set(umName, {
|
|
682
691
|
kind: "unmanaged",
|
|
683
692
|
owner: umName,
|
|
@@ -168,6 +168,15 @@ export type UnmanagedTableEntry = {
|
|
|
168
168
|
readonly name: string;
|
|
169
169
|
readonly meta: EntityTableMeta;
|
|
170
170
|
readonly reason: string;
|
|
171
|
+
readonly piiEncryptedOnWrite?: true;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/** Options for r.unmanagedTable(). Direct-write stores skip the executor,
|
|
175
|
+
* so the executor's PII encryption never runs for them — a feature whose
|
|
176
|
+
* meta carries piiSubjectFields must encrypt those fields itself before
|
|
177
|
+
* every insert/update and declare that here, or boot fails (#820). */
|
|
178
|
+
export type UnmanagedTableOptions = RawTableOptions & {
|
|
179
|
+
readonly piiEncryptedOnWrite?: true;
|
|
171
180
|
};
|
|
172
181
|
|
|
173
182
|
/** Registry-aggregated unmanaged-table — adds the owning feature name. */
|
|
@@ -718,7 +727,7 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
718
727
|
// `PgTable` (legacy), unmanagedTable carries the new `EntityTableMeta`
|
|
719
728
|
// shape that `migrate-runner` consumes. After the full drizzle-cut they
|
|
720
729
|
// will likely merge; for now they coexist.
|
|
721
|
-
unmanagedTable(meta: EntityTableMeta, options:
|
|
730
|
+
unmanagedTable(meta: EntityTableMeta, options: UnmanagedTableOptions): void;
|
|
722
731
|
|
|
723
732
|
// Register the tree-actions schema for this feature — a map of
|
|
724
733
|
// action-name → action-definition (with optional typed args). At-most-
|
|
@@ -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) ---
|