@cosmicdrift/kumiko-framework 0.116.1 → 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 +6 -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__/kms-adapter-contract.ts +134 -0
- package/src/crypto/__tests__/kms-adapter.contract.test.ts +4 -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/__tests__/request-kms-cache.test.ts +74 -0
- package/src/crypto/__tests__/subject-resolver.test.ts +108 -0
- package/src/crypto/blind-index.ts +114 -0
- package/src/crypto/in-memory-kms-adapter.ts +50 -0
- package/src/crypto/index.ts +55 -0
- package/src/crypto/kms-adapter.ts +118 -0
- package/src/crypto/pg-kms-adapter.ts +173 -0
- package/src/crypto/pii-field-encryption.ts +181 -0
- package/src/crypto/request-kms-cache.ts +38 -0
- package/src/crypto/subject-resolver.ts +91 -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/handlers.ts +4 -0
- package/src/engine/types/index.ts +1 -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
|
|
|
@@ -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) ---
|
|
@@ -267,6 +267,10 @@ type SharedContextFields = {
|
|
|
267
267
|
// job which deliberately operates outside the per-call audit trail (it
|
|
268
268
|
// processes rows system-wide, not a per-user read).
|
|
269
269
|
readonly masterKeyProvider?: import("../../secrets").MasterKeyProvider;
|
|
270
|
+
// Subject-key adapter for crypto-shredding (GDPR Art. 17). Present when
|
|
271
|
+
// the app wired a KmsAdapter at boot; the PII envelope engine and the
|
|
272
|
+
// forget pipeline reach for it. Absent = crypto-shredding not enabled.
|
|
273
|
+
readonly kms?: import("../../crypto").KmsAdapter;
|
|
270
274
|
// Observability: optional at the outer boundary, always populated by the
|
|
271
275
|
// time a handler receives its ctx (Noop fallback when no provider is
|
|
272
276
|
// configured, so handler code can call ctx.tracer/ctx.metrics without
|