@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
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Sofortiges Blind-Index-Nulling nach einem Subject-Erase (#818).
|
|
2
|
+
//
|
|
3
|
+
// Nach kms.eraseKey ist der Ciphertext unlesbar, aber die deterministische
|
|
4
|
+
// bidx-Spalte bliebe bis zum nächsten Write/Rebuild matchbar — ein
|
|
5
|
+
// Linkage-Fenster ("hat irgendeine Row den Wert X"). Dieser Sweep schließt
|
|
6
|
+
// es sofort: der Ciphertext nennt sein Subject inline
|
|
7
|
+
// (kumiko-pii:v1:<subjectKey>:...), also findet ein LIKE-Prefix-Match exakt
|
|
8
|
+
// die Rows des erased Subjects — pro lookupable-Feld ein UPDATE.
|
|
9
|
+
//
|
|
10
|
+
// Rows, die der Forget-Lauf ohnehin via Executor löscht/anonymisiert,
|
|
11
|
+
// bekommen ihren bidx dort automatisch neu berechnet; dieser Sweep deckt
|
|
12
|
+
// die liegen bleibenden Rows ab (fremde Entities mit userOwned-Feldern).
|
|
13
|
+
|
|
14
|
+
import { collectLookupableFields } from "../crypto/blind-index";
|
|
15
|
+
import type { FeatureDefinition } from "../engine/types";
|
|
16
|
+
import { toSnakeCase } from "../utils/case";
|
|
17
|
+
import type { DbRunner } from "./connection";
|
|
18
|
+
import { resolveTableName } from "./entity-table-meta";
|
|
19
|
+
import { executeRawQuery } from "./queries/raw-sql";
|
|
20
|
+
|
|
21
|
+
function quoteIdent(name: string): string {
|
|
22
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function escapeLikePattern(value: string): string {
|
|
26
|
+
return value.replace(/[\\%_]/g, (m) => `\\${m}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function nullBlindIndexesForSubject(
|
|
30
|
+
db: DbRunner,
|
|
31
|
+
features: ReadonlyMap<string, FeatureDefinition>,
|
|
32
|
+
subjectKey: string,
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
const likePattern = `kumiko-pii:v1:${escapeLikePattern(subjectKey)}:%`;
|
|
35
|
+
for (const feature of features.values()) {
|
|
36
|
+
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
37
|
+
const lookupable = collectLookupableFields(entity);
|
|
38
|
+
if (lookupable.length === 0) continue;
|
|
39
|
+
// Kein featureName-Prefix — der Dispatcher baut Entity-Tables ohne
|
|
40
|
+
// (buildEntityTable ohne featureName-Option), der Sweep muss dieselben
|
|
41
|
+
// Namen treffen.
|
|
42
|
+
const tableName = resolveTableName(entityName, entity, undefined);
|
|
43
|
+
for (const fieldName of lookupable) {
|
|
44
|
+
const snake = toSnakeCase(fieldName);
|
|
45
|
+
await executeRawQuery(
|
|
46
|
+
db,
|
|
47
|
+
`UPDATE ${quoteIdent(tableName)} SET ${quoteIdent(`${snake}_bidx`)} = NULL WHERE ${quoteIdent(snake)} LIKE $1`,
|
|
48
|
+
[likePattern],
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// auf Sondercases beschränken (child-projection-tables ohne tenant,
|
|
19
19
|
// append-only-logs mit serial PK, aggregate-ID ohne DEFAULT, …).
|
|
20
20
|
|
|
21
|
+
import { collectPiiSubjectFields } from "../crypto";
|
|
21
22
|
import type {
|
|
22
23
|
EntityDefinition,
|
|
23
24
|
EntityIndexDef,
|
|
@@ -92,6 +93,10 @@ export type EntityTableMeta = {
|
|
|
92
93
|
// trägt Verantwortung. Migration-Generator + Tooling können den
|
|
93
94
|
// discriminator nutzen um Warnungen zu rendern ("X tables are unmanaged").
|
|
94
95
|
readonly source: "managed" | "unmanaged";
|
|
96
|
+
// PII-Subject-annotated field names (pii/userOwned/tenantOwned). Set by
|
|
97
|
+
// buildEntityTableMeta so the registry can reject r.unmanagedTable stores
|
|
98
|
+
// whose direct writes would skip the executor's encryption (#820).
|
|
99
|
+
readonly piiSubjectFields?: readonly string[];
|
|
95
100
|
};
|
|
96
101
|
|
|
97
102
|
// Standard base-columns für event-sourced Read-Model-Tabellen. Spiegelt
|
|
@@ -153,7 +158,7 @@ function fieldToColumnMeta(
|
|
|
153
158
|
case "text":
|
|
154
159
|
case "longText": {
|
|
155
160
|
const def = fieldDefaultLiteral(field);
|
|
156
|
-
|
|
161
|
+
const cols: ColumnMeta[] = [
|
|
157
162
|
{
|
|
158
163
|
name: snake,
|
|
159
164
|
pgType: "text",
|
|
@@ -161,6 +166,13 @@ function fieldToColumnMeta(
|
|
|
161
166
|
...(def !== undefined && { defaultSql: def }),
|
|
162
167
|
},
|
|
163
168
|
];
|
|
169
|
+
// lookupable → HMAC-Blind-Index-Pendant. Nullable ist Pflicht:
|
|
170
|
+
// managedChangeRequiresRecreate würde eine NOT-NULL-Spalte ohne
|
|
171
|
+
// Default auf Bestandstabellen als DROP+Rebuild diffen.
|
|
172
|
+
if (field.type === "text" && field.lookupable === true) {
|
|
173
|
+
cols.push({ name: `${snake}_bidx`, pgType: "text", notNull: false });
|
|
174
|
+
}
|
|
175
|
+
return cols;
|
|
164
176
|
}
|
|
165
177
|
case "boolean": {
|
|
166
178
|
const def = fieldDefaultLiteral(field);
|
|
@@ -305,10 +317,17 @@ export function buildEntityTableMeta(
|
|
|
305
317
|
for (const c of baseCols) colByName.set(c.name, c);
|
|
306
318
|
|
|
307
319
|
const fieldNameToSnake = new Map<string, string>();
|
|
320
|
+
const bidxSnakeByFieldSnake = new Map<string, string>();
|
|
308
321
|
for (const [name, field] of Object.entries(entity.fields)) {
|
|
309
322
|
const fieldCols = fieldToColumnMeta(name, field, entity);
|
|
310
323
|
for (const c of fieldCols) colByName.set(c.name, c);
|
|
311
|
-
|
|
324
|
+
// Multi-column fields map to their primary column when its name IS the
|
|
325
|
+
// field's snake (text+bidx, money+currency) — matches the toSnakeCase
|
|
326
|
+
// fallback below, so explicit indexes keep resolving.
|
|
327
|
+
const primary = fieldCols[0];
|
|
328
|
+
if (primary && primary.name === toSnakeCase(name)) fieldNameToSnake.set(name, primary.name);
|
|
329
|
+
const bidxCol = fieldCols.find((c) => c.name.endsWith("_bidx"));
|
|
330
|
+
if (primary && bidxCol) bidxSnakeByFieldSnake.set(primary.name, bidxCol.name);
|
|
312
331
|
}
|
|
313
332
|
|
|
314
333
|
// Preserve base-col order, then any new user-col-names in fields-order.
|
|
@@ -347,6 +366,12 @@ export function buildEntityTableMeta(
|
|
|
347
366
|
indexes.push({ name: `${tableName}_${snake}_idx`, columns: [snake] });
|
|
348
367
|
}
|
|
349
368
|
|
|
369
|
+
// lookupable-Felder: Index auf der bidx-Spalte — der OR-Rewrite der
|
|
370
|
+
// Query-Compiler trifft sie bei jedem Equality-Lookup.
|
|
371
|
+
for (const bidxSnake of bidxSnakeByFieldSnake.values()) {
|
|
372
|
+
indexes.push({ name: `${tableName}_${bidxSnake}_idx`, columns: [bidxSnake] });
|
|
373
|
+
}
|
|
374
|
+
|
|
350
375
|
// Explizit deklarierte indexes (EntityIndexDef). `def.where` ist ein
|
|
351
376
|
// SqlExpression (`sql\`…\`` aus @cosmicdrift/kumiko-framework/db) —
|
|
352
377
|
// renderbar via `.text`. Unbekannte where-Shapes bleiben needsManualWhere.
|
|
@@ -364,13 +389,33 @@ export function buildEntityTableMeta(
|
|
|
364
389
|
...(whereSql !== undefined && { whereSql }),
|
|
365
390
|
...(def.where !== undefined && whereSql === undefined && { needsManualWhere: true }),
|
|
366
391
|
});
|
|
392
|
+
// Unique-Index über lookupable-Spalten: partielles bidx-Pendant, damit
|
|
393
|
+
// Uniqueness auch für verschlüsselte Rows greift. Das Original bleibt
|
|
394
|
+
// für Klartext-Alt-Rows; partial (IS NOT NULL) weil bidx bei erased/
|
|
395
|
+
// key-losen Rows NULL ist.
|
|
396
|
+
if (def.unique === true && def.where === undefined) {
|
|
397
|
+
const bidxCols = cols.map((c) => bidxSnakeByFieldSnake.get(c) ?? c);
|
|
398
|
+
if (bidxCols.some((c, i) => c !== cols[i])) {
|
|
399
|
+
const notNullParts = bidxCols
|
|
400
|
+
.filter((c, i) => c !== cols[i])
|
|
401
|
+
.map((c) => `"${c}" IS NOT NULL`);
|
|
402
|
+
indexes.push({
|
|
403
|
+
name: `${indexName}_bidx`,
|
|
404
|
+
columns: bidxCols,
|
|
405
|
+
unique: true,
|
|
406
|
+
whereSql: notNullParts.join(" AND "),
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
367
410
|
}
|
|
368
411
|
|
|
412
|
+
const piiSubjectFields = collectPiiSubjectFields(entity);
|
|
369
413
|
return {
|
|
370
414
|
tableName,
|
|
371
415
|
columns,
|
|
372
416
|
indexes,
|
|
373
417
|
source: "managed",
|
|
418
|
+
...(piiSubjectFields.length > 0 && { piiSubjectFields }),
|
|
374
419
|
};
|
|
375
420
|
}
|
|
376
421
|
|
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import { requestContext } from "../api/request-context";
|
|
2
|
+
import {
|
|
3
|
+
collectPiiSubjectFields,
|
|
4
|
+
computeBlindIndex,
|
|
5
|
+
configuredBlindIndexKey,
|
|
6
|
+
configuredPiiSubjectKms,
|
|
7
|
+
decryptPiiFieldValues,
|
|
8
|
+
encryptPiiFieldValues,
|
|
9
|
+
type KmsContext,
|
|
10
|
+
type LocalKeyKmsAdapter,
|
|
11
|
+
} from "../crypto";
|
|
2
12
|
import { executeRawQuery } from "../db/queries/raw-sql";
|
|
3
13
|
import type { WhereObject } from "../db/query";
|
|
4
14
|
import { coerceRow, extractTableInfo, selectMany } from "../db/query";
|
|
@@ -121,6 +131,8 @@ export type EventStoreExecutorOptions = {
|
|
|
121
131
|
entityCache?: EntityCache;
|
|
122
132
|
/** Override the boot-injected cipher for fields marked `encrypted: true`. */
|
|
123
133
|
encryption?: EnvelopeCipher;
|
|
134
|
+
/** Override the boot-injected subject KMS for pii-annotated fields. */
|
|
135
|
+
kms?: LocalKeyKmsAdapter;
|
|
124
136
|
};
|
|
125
137
|
|
|
126
138
|
// F8 helper: PG-23505 (unique-violation) catched aus applyEntityEvent
|
|
@@ -307,28 +319,64 @@ export function createEventStoreExecutor(
|
|
|
307
319
|
const encryptedFields = collectEncryptedFieldNames(entity);
|
|
308
320
|
const hasEncryptedFields = encryptedFields.size > 0;
|
|
309
321
|
|
|
322
|
+
const piiSubjectFields = collectPiiSubjectFields(entity);
|
|
323
|
+
const hasPiiFields = piiSubjectFields.length > 0;
|
|
324
|
+
|
|
310
325
|
function fieldCipher(): EnvelopeCipher {
|
|
311
326
|
if (options.encryption) return options.encryption;
|
|
312
327
|
return resolveEntityFieldEncryption();
|
|
313
328
|
}
|
|
314
329
|
|
|
330
|
+
// No adapter configured = crypto-shredding off; pii fields stay plaintext
|
|
331
|
+
// (pre-#724 behavior). The hard boot gate ships with the prod-grade
|
|
332
|
+
// PgKmsAdapter (phase E).
|
|
333
|
+
function piiKms(): LocalKeyKmsAdapter | undefined {
|
|
334
|
+
return options.kms ?? configuredPiiSubjectKms();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function kmsContextFor(user?: SessionUser): KmsContext {
|
|
338
|
+
return {
|
|
339
|
+
requestId: requestContext.get()?.requestId ?? "event-store-executor",
|
|
340
|
+
...(user && { tenantId: user.tenantId, userId: String(user.id) }),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
315
344
|
// Async on purpose: the envelope cipher wraps/unwraps DEKs via the
|
|
316
345
|
// MasterKeyProvider. Callers MUST await — a missed await here writes
|
|
317
346
|
// "[object Promise]" into the projection, which the Promise return
|
|
318
347
|
// types turn into a compile error at every call site.
|
|
319
348
|
async function encryptForStorage(
|
|
320
349
|
row: Record<string, unknown>,
|
|
321
|
-
|
|
350
|
+
user: SessionUser,
|
|
351
|
+
opts?: { onlyKeys?: Iterable<string>; subjectSource?: Record<string, unknown> },
|
|
322
352
|
): Promise<Record<string, unknown>> {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
353
|
+
let out = row;
|
|
354
|
+
if (hasEncryptedFields) {
|
|
355
|
+
out = await encryptEntityFieldValues(out, encryptedFields, fieldCipher(), {
|
|
356
|
+
...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
const kms = piiKms();
|
|
360
|
+
if (hasPiiFields && kms) {
|
|
361
|
+
out = await encryptPiiFieldValues(out, entity, piiSubjectFields, kms, kmsContextFor(user), {
|
|
362
|
+
tenantId: user.tenantId,
|
|
363
|
+
...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
|
|
364
|
+
...(opts?.subjectSource !== undefined && { subjectSource: opts.subjectSource }),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
return out;
|
|
327
368
|
}
|
|
328
369
|
|
|
329
370
|
async function decryptForRead(row: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
330
|
-
|
|
331
|
-
|
|
371
|
+
let out = row;
|
|
372
|
+
if (hasEncryptedFields) {
|
|
373
|
+
out = await decryptEntityFieldValues(out, encryptedFields, fieldCipher());
|
|
374
|
+
}
|
|
375
|
+
const kms = piiKms();
|
|
376
|
+
if (hasPiiFields && kms) {
|
|
377
|
+
out = await decryptPiiFieldValues(out, piiSubjectFields, kms, kmsContextFor());
|
|
378
|
+
}
|
|
379
|
+
return out;
|
|
332
380
|
}
|
|
333
381
|
|
|
334
382
|
function applyDefaults(payload: Record<string, unknown>): Record<string, unknown> {
|
|
@@ -469,7 +517,12 @@ export function createEventStoreExecutor(
|
|
|
469
517
|
// Alle Compound-Types (locatedTimestamp, money, ...) gehen durch
|
|
470
518
|
// dieselbe Pipeline. Caller schickt combined API-Form, Framework
|
|
471
519
|
// speichert flat DB-Form. Siehe db/compound-types.ts.
|
|
472
|
-
|
|
520
|
+
// subjectSource carries the freshly minted aggregateId: the create
|
|
521
|
+
// payload has no id column, but a pii:true self-subject resolves from it.
|
|
522
|
+
const flatCreateData = flattenCompoundTypes(data, entity);
|
|
523
|
+
const flatData = await encryptForStorage(flatCreateData, user, {
|
|
524
|
+
subjectSource: { ...flatCreateData, id: aggregateId },
|
|
525
|
+
});
|
|
473
526
|
|
|
474
527
|
// 1. Append event (same TX as the projection write — both must succeed
|
|
475
528
|
// or both roll back; the dispatcher wraps both in one transaction).
|
|
@@ -563,7 +616,9 @@ export function createEventStoreExecutor(
|
|
|
563
616
|
previous: {},
|
|
564
617
|
isNew: true,
|
|
565
618
|
entityName,
|
|
566
|
-
event
|
|
619
|
+
// Persisted event carries ciphertext by design — the caller-facing
|
|
620
|
+
// echo must be plaintext like every other response field (#820).
|
|
621
|
+
event: { ...event, payload: stripSensitive(flatCreateData) },
|
|
567
622
|
},
|
|
568
623
|
};
|
|
569
624
|
},
|
|
@@ -650,10 +705,13 @@ export function createEventStoreExecutor(
|
|
|
650
705
|
|
|
651
706
|
try {
|
|
652
707
|
// Compound-Types Auto-Convert (alle in einem Pass).
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
708
|
+
// subjectSource: partial changes may carry a pii field without its
|
|
709
|
+
// ownerField — the merged row still names the subject.
|
|
710
|
+
const flatChangesPlain = flattenCompoundTypes(payload.changes, entity);
|
|
711
|
+
const flatChanges = await encryptForStorage(flatChangesPlain, user, {
|
|
712
|
+
onlyKeys: Object.keys(payload.changes),
|
|
713
|
+
subjectSource: mergedNew,
|
|
714
|
+
});
|
|
657
715
|
|
|
658
716
|
// The event payload carries BOTH `changes` (what the user asked for) AND
|
|
659
717
|
// `previous` (the pre-update row). Cross-aggregate projections need the
|
|
@@ -673,7 +731,7 @@ export function createEventStoreExecutor(
|
|
|
673
731
|
type: entityEventName(entityName, "updated"),
|
|
674
732
|
payload: {
|
|
675
733
|
changes: stripSensitive(flatChanges),
|
|
676
|
-
previous: stripSensitive(await encryptForStorage(previous)),
|
|
734
|
+
previous: stripSensitive(await encryptForStorage(previous, user)),
|
|
677
735
|
},
|
|
678
736
|
metadata: buildEventMetadata(user),
|
|
679
737
|
});
|
|
@@ -717,7 +775,13 @@ export function createEventStoreExecutor(
|
|
|
717
775
|
previous,
|
|
718
776
|
isNew: false,
|
|
719
777
|
entityName,
|
|
720
|
-
event
|
|
778
|
+
event: {
|
|
779
|
+
...event,
|
|
780
|
+
payload: {
|
|
781
|
+
changes: stripSensitive(flatChangesPlain),
|
|
782
|
+
previous: stripSensitive(previous),
|
|
783
|
+
},
|
|
784
|
+
},
|
|
721
785
|
},
|
|
722
786
|
};
|
|
723
787
|
} catch (e) {
|
|
@@ -783,7 +847,7 @@ export function createEventStoreExecutor(
|
|
|
783
847
|
tenantId: streamTenantFor(user),
|
|
784
848
|
expectedVersion: currentVersion,
|
|
785
849
|
type: entityEventName(entityName, "deleted"),
|
|
786
|
-
payload: { previous: stripSensitive(await encryptForStorage(existing)) },
|
|
850
|
+
payload: { previous: stripSensitive(await encryptForStorage(existing, user)) },
|
|
787
851
|
metadata: buildEventMetadata(user),
|
|
788
852
|
});
|
|
789
853
|
|
|
@@ -805,7 +869,13 @@ export function createEventStoreExecutor(
|
|
|
805
869
|
|
|
806
870
|
return {
|
|
807
871
|
isSuccess: true,
|
|
808
|
-
data: {
|
|
872
|
+
data: {
|
|
873
|
+
kind: "delete",
|
|
874
|
+
id: payload.id,
|
|
875
|
+
data: existing,
|
|
876
|
+
entityName,
|
|
877
|
+
event: { ...event, payload: { previous: stripSensitive(existing) } },
|
|
878
|
+
},
|
|
809
879
|
};
|
|
810
880
|
},
|
|
811
881
|
|
|
@@ -846,7 +916,9 @@ export function createEventStoreExecutor(
|
|
|
846
916
|
tenantId: streamTenantFor(user),
|
|
847
917
|
expectedVersion: currentVersion,
|
|
848
918
|
type: entityEventName(entityName, "forgotten"),
|
|
849
|
-
|
|
919
|
+
// Re-encrypt like delete(): `existing` came decrypted from loadById —
|
|
920
|
+
// plaintext must not land in the immutable log, least of all on forget.
|
|
921
|
+
payload: { previous: stripSensitive(await encryptForStorage(existing, user)) },
|
|
850
922
|
metadata: buildEventMetadata(user),
|
|
851
923
|
});
|
|
852
924
|
|
|
@@ -863,7 +935,13 @@ export function createEventStoreExecutor(
|
|
|
863
935
|
|
|
864
936
|
return {
|
|
865
937
|
isSuccess: true,
|
|
866
|
-
data: {
|
|
938
|
+
data: {
|
|
939
|
+
kind: "delete",
|
|
940
|
+
id: payload.id,
|
|
941
|
+
data: existing,
|
|
942
|
+
entityName,
|
|
943
|
+
event: { ...event, payload: { previous: stripSensitive(existing) } },
|
|
944
|
+
},
|
|
867
945
|
};
|
|
868
946
|
},
|
|
869
947
|
|
|
@@ -947,6 +1025,7 @@ export function createEventStoreExecutor(
|
|
|
947
1025
|
rehydrateCompoundTypes(restored as DbRow, entity) as DbRow,
|
|
948
1026
|
);
|
|
949
1027
|
|
|
1028
|
+
const previousPlain = await decryptForRead(data);
|
|
950
1029
|
return {
|
|
951
1030
|
isSuccess: true,
|
|
952
1031
|
data: {
|
|
@@ -954,10 +1033,10 @@ export function createEventStoreExecutor(
|
|
|
954
1033
|
id: payload.id,
|
|
955
1034
|
data: restoredHydrated,
|
|
956
1035
|
changes: { isDeleted: false },
|
|
957
|
-
previous:
|
|
1036
|
+
previous: previousPlain,
|
|
958
1037
|
isNew: false,
|
|
959
1038
|
entityName,
|
|
960
|
-
event,
|
|
1039
|
+
event: { ...event, payload: { previous: stripSensitive(previousPlain) } },
|
|
961
1040
|
},
|
|
962
1041
|
};
|
|
963
1042
|
},
|
|
@@ -1067,8 +1146,19 @@ export function createEventStoreExecutor(
|
|
|
1067
1146
|
whereSql.push(`${colSql(field)} ${opSym} $${params.length}`);
|
|
1068
1147
|
}
|
|
1069
1148
|
} else {
|
|
1070
|
-
|
|
1071
|
-
|
|
1149
|
+
// Blind-Index-OR-Rewrite (#818), lock-step mit buildWhereClause
|
|
1150
|
+
// in bun-db/query.ts — Equality auf lookupable-Feldern matcht
|
|
1151
|
+
// Klartext-Arm ODER HMAC-Arm.
|
|
1152
|
+
const bidxKey = configuredBlindIndexKey();
|
|
1153
|
+
if (bidxKey !== undefined && typeof value === "string" && table[`${field}Bidx`]) {
|
|
1154
|
+
params.push(value, computeBlindIndex(bidxKey, value));
|
|
1155
|
+
whereSql.push(
|
|
1156
|
+
`(${colSql(field)} = $${params.length - 1} OR ${colSql(`${field}Bidx`)} = $${params.length})`,
|
|
1157
|
+
);
|
|
1158
|
+
} else {
|
|
1159
|
+
params.push(value);
|
|
1160
|
+
whereSql.push(`${colSql(field)} = $${params.length}`);
|
|
1161
|
+
}
|
|
1072
1162
|
}
|
|
1073
1163
|
}
|
|
1074
1164
|
};
|
|
@@ -1086,21 +1176,26 @@ export function createEventStoreExecutor(
|
|
|
1086
1176
|
const listSql = `SELECT * FROM "${tableName}"${whereClauseSqlText}${orderByClause} LIMIT ${limit}${offsetClause}`;
|
|
1087
1177
|
|
|
1088
1178
|
const rawRows = await executeRawQuery<Record<string, unknown>>(db.raw, listSql, params);
|
|
1089
|
-
// Read-Side rehydrate pro Row + snake→camel coercion für driver-agnostic Feldnamen
|
|
1179
|
+
// Read-Side rehydrate pro Row + snake→camel coercion für driver-agnostic Feldnamen.
|
|
1180
|
+
// Coerce BEFORE decrypt: the raw SELECT * rows carry snake_case column
|
|
1181
|
+
// names, while the encrypted/pii field lists are camelCase — decrypting
|
|
1182
|
+
// first silently skipped every multi-word field (ciphertext leaked to
|
|
1183
|
+
// the caller).
|
|
1090
1184
|
const tableInfo = extractTableInfo(table);
|
|
1091
|
-
const
|
|
1092
|
-
|
|
1093
|
-
coerceRow(await decryptForRead(rehydrateCompoundTypes(r, entity)), tableInfo),
|
|
1094
|
-
),
|
|
1185
|
+
const encryptedRows = rawRows.map((r) =>
|
|
1186
|
+
coerceRow(rehydrateCompoundTypes(r, entity), tableInfo),
|
|
1095
1187
|
);
|
|
1188
|
+
const rows = await Promise.all(encryptedRows.map((r) => decryptForRead(r)));
|
|
1096
1189
|
|
|
1097
1190
|
// list rows carry the READ-ROW version (display-only), never an optimistic-lock
|
|
1098
1191
|
// base — edit flows reload via detail(), which reconciles the stream version.
|
|
1192
|
+
// Cache the still-encrypted form: same at-rest guarantee as detail()'s
|
|
1193
|
+
// encryptForStorage round-trip, without paying a re-encrypt.
|
|
1099
1194
|
if (entityCache && entityName && rows.length > 0) {
|
|
1100
1195
|
await entityCache.mset(
|
|
1101
1196
|
user.tenantId,
|
|
1102
1197
|
entityName,
|
|
1103
|
-
|
|
1198
|
+
encryptedRows.map((r) => ({ id: r["id"] as EntityId, data: r })), // @cast-boundary engine-payload
|
|
1104
1199
|
);
|
|
1105
1200
|
}
|
|
1106
1201
|
|
|
@@ -1180,7 +1275,7 @@ export function createEventStoreExecutor(
|
|
|
1180
1275
|
user.tenantId,
|
|
1181
1276
|
entityName,
|
|
1182
1277
|
payload.id,
|
|
1183
|
-
await encryptForStorage(coerced),
|
|
1278
|
+
await encryptForStorage(coerced, user),
|
|
1184
1279
|
);
|
|
1185
1280
|
}
|
|
1186
1281
|
|
package/src/db/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { assertExistsIn } from "./assert-exists-in";
|
|
2
|
+
export { nullBlindIndexesForSubject } from "./blind-index-cleanup";
|
|
2
3
|
export { collectTableMetas } from "./collect-table-metas";
|
|
3
4
|
export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
|
|
4
5
|
export { seedConfigValues } from "./config-seed";
|