@cosmicdrift/kumiko-framework 0.209.1 → 0.210.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 +3 -3
- package/src/__tests__/entity-permalink-open.integration.test.ts +6 -1
- package/src/crypto/__tests__/blind-index.test.ts +1 -1
- package/src/crypto/__tests__/pii-field-encryption.test.ts +16 -10
- package/src/crypto/__tests__/subject-resolver.test.ts +9 -3
- package/src/db/__tests__/blind-index.integration.test.ts +1 -1
- package/src/db/__tests__/cursor.test.ts +26 -1
- package/src/db/__tests__/eagerload.integration.test.ts +3 -3
- package/src/db/__tests__/entity-table-meta-source.test.ts +4 -1
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +1 -1
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +144 -1
- package/src/db/__tests__/event-store-executor.integration.test.ts +5 -2
- package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +1 -1
- package/src/db/cursor.ts +32 -0
- package/src/db/event-store-executor-read.ts +80 -9
- package/src/db/index.ts +2 -2
- package/src/db/pg-error.ts +7 -0
- package/src/db/queries/backfill-pii.ts +188 -18
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +6 -1
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +140 -140
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +17 -5
- package/src/engine/__tests__/factories-personal.test.ts +130 -0
- package/src/engine/__tests__/field-access.test.ts +1 -23
- package/src/engine/__tests__/store-table.test.ts +4 -1
- package/src/engine/boot-validator/pii-retention.ts +22 -54
- package/src/engine/build-config-feature-schema.ts +9 -1
- package/src/engine/factories.ts +108 -30
- package/src/engine/field-access.ts +2 -13
- package/src/engine/index.ts +7 -1
- package/src/engine/types/index.ts +7 -1
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +179 -2
- package/src/files/file-ref-entity.ts +2 -2
- package/src/search/__tests__/reindex-entity.integration.test.ts +1 -1
- package/src/search/__tests__/search-pii-derived-index.integration.test.ts +2 -2
- package/src/testing/shared-entities.ts +6 -3
|
@@ -106,16 +106,22 @@ export type {
|
|
|
106
106
|
FieldsMap,
|
|
107
107
|
FileFieldDef,
|
|
108
108
|
FilesFieldDef,
|
|
109
|
+
Findability,
|
|
109
110
|
ImageFieldDef,
|
|
110
111
|
ImagesFieldDef,
|
|
111
112
|
JsonbFieldDef,
|
|
112
113
|
LocatedTimestampFieldDef,
|
|
113
114
|
LongTextFieldDef,
|
|
115
|
+
LongTextFindability,
|
|
114
116
|
MoneyFieldDef,
|
|
115
117
|
MultiSelectFieldDef,
|
|
116
118
|
NumberFieldDef,
|
|
117
|
-
|
|
119
|
+
PersonalAnnotations,
|
|
120
|
+
PersonalAnnotationsLongText,
|
|
121
|
+
PersonalAnnotationsNoFind,
|
|
122
|
+
PersonalSubject,
|
|
118
123
|
ReferenceFieldDef,
|
|
124
|
+
ResolvedPiiFlags,
|
|
119
125
|
RetentionDef,
|
|
120
126
|
SelectFieldDef,
|
|
121
127
|
TextFieldDef,
|
|
@@ -37,7 +37,7 @@ const BIDX_KEY = Buffer.alloc(32, 5).toString("base64");
|
|
|
37
37
|
|
|
38
38
|
const contactEntity = createEntity({
|
|
39
39
|
fields: {
|
|
40
|
-
email: createTextField({ required: true,
|
|
40
|
+
email: createTextField({ required: true, personal: "self", find: "exact" }),
|
|
41
41
|
displayName: createTextField(),
|
|
42
42
|
},
|
|
43
43
|
});
|
|
@@ -55,6 +55,20 @@ const mailerFeature = defineFeature("mailer", (r) => {
|
|
|
55
55
|
);
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
// personal: { of } (not "self") — the named owner field can legitimately be
|
|
59
|
+
// absent from an old event's payload, unlike contact.email (self-subject via row id).
|
|
60
|
+
const noteEntity = createEntity({
|
|
61
|
+
fields: {
|
|
62
|
+
authorId: createTextField(),
|
|
63
|
+
body: createTextField({ personal: { of: "authorId" }, find: "none" }),
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
const noteTable = buildEntityTable("note", noteEntity);
|
|
67
|
+
|
|
68
|
+
const notesFeature = defineFeature("notes", (r) => {
|
|
69
|
+
r.entity("note", noteEntity);
|
|
70
|
+
});
|
|
71
|
+
|
|
58
72
|
let testDb: TestDb;
|
|
59
73
|
let registry: Registry;
|
|
60
74
|
let kms: InMemoryKmsAdapter;
|
|
@@ -79,9 +93,10 @@ async function appendPlain(
|
|
|
79
93
|
|
|
80
94
|
beforeAll(async () => {
|
|
81
95
|
testDb = await createTestDb();
|
|
82
|
-
registry = createRegistry([crmFeature, mailerFeature]);
|
|
96
|
+
registry = createRegistry([crmFeature, mailerFeature, notesFeature]);
|
|
83
97
|
await createEventsTable(testDb.db);
|
|
84
98
|
await unsafeCreateEntityTable(testDb.db, contactEntity, "contact");
|
|
99
|
+
await unsafeCreateEntityTable(testDb.db, noteEntity, "note");
|
|
85
100
|
});
|
|
86
101
|
|
|
87
102
|
afterAll(async () => {
|
|
@@ -92,6 +107,7 @@ beforeEach(async () => {
|
|
|
92
107
|
const raw = asRawClient(testDb.db);
|
|
93
108
|
await raw.unsafe(`TRUNCATE "kumiko_events" RESTART IDENTITY`);
|
|
94
109
|
await raw.unsafe(`TRUNCATE "${contactTable.tableName}"`);
|
|
110
|
+
await raw.unsafe(`TRUNCATE "${noteTable.tableName}"`);
|
|
95
111
|
// Plaintext era: NO KMS while the legacy events are appended.
|
|
96
112
|
resetPiiSubjectKmsForTests();
|
|
97
113
|
resetBlindIndexKeyForTests();
|
|
@@ -280,6 +296,167 @@ describe("backfillEventPiiEncryption", () => {
|
|
|
280
296
|
});
|
|
281
297
|
});
|
|
282
298
|
|
|
299
|
+
describe("backfillEventPiiEncryption: owner-resolution chain (fw#2266)", () => {
|
|
300
|
+
async function insertNoteProjectionRow(id: string, authorId: string): Promise<void> {
|
|
301
|
+
await asRawClient(testDb.db).unsafe(
|
|
302
|
+
`INSERT INTO "${noteTable.tableName}" (id, tenant_id, author_id, body) VALUES ($1, $2, $3, $4)`,
|
|
303
|
+
[id, TENANT, authorId, "projection-row-body"],
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
test("payload missing owner + projection row has it: resolveOwnerFromProjection encrypts, no failures", async () => {
|
|
308
|
+
const author = generateId();
|
|
309
|
+
const noteId = generateId();
|
|
310
|
+
await insertNoteProjectionRow(noteId, author);
|
|
311
|
+
// Legacy event predating the authorId column — payload never carried it.
|
|
312
|
+
await appendPlain(noteId, "note", "note.created", { id: noteId, body: "old plaintext note" });
|
|
313
|
+
|
|
314
|
+
armKms();
|
|
315
|
+
const result = await backfillEventPiiEncryption(testDb.db, registry, {
|
|
316
|
+
resolveOwnerFromProjection: true,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
expect(result.failures).toEqual([]);
|
|
320
|
+
expect(result.encryptedFields).toBe(1);
|
|
321
|
+
expect(result.ownerFromProjection).toBe(1);
|
|
322
|
+
|
|
323
|
+
const created = (await loadAggregate(testDb.db, noteId, TENANT))[0]?.payload as Record<
|
|
324
|
+
string,
|
|
325
|
+
unknown
|
|
326
|
+
>;
|
|
327
|
+
expect(isPiiCiphertext(created["body"])).toBe(true);
|
|
328
|
+
expect(String(created["body"])).toContain(`user:${author}`);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("same event without the flags: stays in failures (default behavior unchanged)", async () => {
|
|
332
|
+
const author = generateId();
|
|
333
|
+
const noteId = generateId();
|
|
334
|
+
await insertNoteProjectionRow(noteId, author);
|
|
335
|
+
await appendPlain(noteId, "note", "note.created", { id: noteId, body: "old plaintext note" });
|
|
336
|
+
|
|
337
|
+
armKms();
|
|
338
|
+
const result = await backfillEventPiiEncryption(testDb.db, registry);
|
|
339
|
+
|
|
340
|
+
expect(result.encryptedFields).toBe(0);
|
|
341
|
+
expect(result.failures).toHaveLength(1);
|
|
342
|
+
expect(result.failures[0]?.reason).toContain("resolveOwnerFromProjection");
|
|
343
|
+
expect(result.failures[0]?.reason).toContain("eraseUnresolvableSubjects");
|
|
344
|
+
|
|
345
|
+
const created = (await loadAggregate(testDb.db, noteId, TENANT))[0]?.payload as Record<
|
|
346
|
+
string,
|
|
347
|
+
unknown
|
|
348
|
+
>;
|
|
349
|
+
expect(created["body"]).toBe("old plaintext note");
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("owner missing from payload AND projection: eraseUnresolvableSubjects sentinels the field", async () => {
|
|
353
|
+
const noteId = generateId();
|
|
354
|
+
// No projection row for this aggregate — hard-deleted, or never rebuilt.
|
|
355
|
+
await appendPlain(noteId, "note", "note.created", { id: noteId, body: "orphaned note" });
|
|
356
|
+
|
|
357
|
+
armKms();
|
|
358
|
+
const result = await backfillEventPiiEncryption(testDb.db, registry, {
|
|
359
|
+
resolveOwnerFromProjection: true,
|
|
360
|
+
eraseUnresolvableSubjects: true,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
expect(result.failures).toEqual([]);
|
|
364
|
+
expect(result.erasedFields).toBe(1);
|
|
365
|
+
expect(result.erasedUnresolvable).toBe(1);
|
|
366
|
+
|
|
367
|
+
const created = (await loadAggregate(testDb.db, noteId, TENANT))[0]?.payload as Record<
|
|
368
|
+
string,
|
|
369
|
+
unknown
|
|
370
|
+
>;
|
|
371
|
+
expect(created["body"]).toBe(PII_ERASED_SENTINEL);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("already-ciphertext field with unresolvable owner is left untouched (no data loss)", async () => {
|
|
375
|
+
// Produce genuine ciphertext under a real, resolvable subject first.
|
|
376
|
+
const author = generateId();
|
|
377
|
+
const seedId = generateId();
|
|
378
|
+
await appendPlain(seedId, "note", "note.created", {
|
|
379
|
+
id: seedId,
|
|
380
|
+
authorId: author,
|
|
381
|
+
body: "seed",
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
armKms();
|
|
385
|
+
await backfillEventPiiEncryption(testDb.db, registry);
|
|
386
|
+
const seeded = (await loadAggregate(testDb.db, seedId, TENANT))[0]?.payload as Record<
|
|
387
|
+
string,
|
|
388
|
+
unknown
|
|
389
|
+
>;
|
|
390
|
+
const ciphertext = seeded["body"];
|
|
391
|
+
expect(isPiiCiphertext(ciphertext)).toBe(true);
|
|
392
|
+
|
|
393
|
+
// New event: body already holds that ciphertext, owner unresolvable
|
|
394
|
+
// (no authorId in payload, no projection row for THIS aggregate).
|
|
395
|
+
const noteId = generateId();
|
|
396
|
+
await appendPlain(noteId, "note", "note.created", { id: noteId, body: ciphertext });
|
|
397
|
+
|
|
398
|
+
const result = await backfillEventPiiEncryption(testDb.db, registry, {
|
|
399
|
+
resolveOwnerFromProjection: true,
|
|
400
|
+
eraseUnresolvableSubjects: true,
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
expect(result.failures).toEqual([]);
|
|
404
|
+
expect(result.erasedUnresolvable).toBe(0);
|
|
405
|
+
expect(result.encryptedFields).toBe(0);
|
|
406
|
+
expect(result.erasedFields).toBe(0);
|
|
407
|
+
|
|
408
|
+
const created = (await loadAggregate(testDb.db, noteId, TENANT))[0]?.payload as Record<
|
|
409
|
+
string,
|
|
410
|
+
unknown
|
|
411
|
+
>;
|
|
412
|
+
expect(created["body"]).toBe(ciphertext);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("payload owner differs from projection owner: payload wins, ownerFromProjection stays 0", async () => {
|
|
416
|
+
const payloadAuthor = generateId();
|
|
417
|
+
const projectionAuthor = generateId();
|
|
418
|
+
const noteId = generateId();
|
|
419
|
+
await insertNoteProjectionRow(noteId, projectionAuthor);
|
|
420
|
+
// Ownership changed since this event was written — payload is the
|
|
421
|
+
// historical truth, the projection only today's state. Stage 2 must
|
|
422
|
+
// fill gaps, never override a value stage 1 already resolved.
|
|
423
|
+
await appendPlain(noteId, "note", "note.created", {
|
|
424
|
+
id: noteId,
|
|
425
|
+
authorId: payloadAuthor,
|
|
426
|
+
body: "old plaintext note",
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
armKms();
|
|
430
|
+
const result = await backfillEventPiiEncryption(testDb.db, registry, {
|
|
431
|
+
resolveOwnerFromProjection: true,
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
expect(result.failures).toEqual([]);
|
|
435
|
+
expect(result.encryptedFields).toBe(1);
|
|
436
|
+
expect(result.ownerFromProjection).toBe(0);
|
|
437
|
+
|
|
438
|
+
const created = (await loadAggregate(testDb.db, noteId, TENANT))[0]?.payload as Record<
|
|
439
|
+
string,
|
|
440
|
+
unknown
|
|
441
|
+
>;
|
|
442
|
+
expect(isPiiCiphertext(created["body"])).toBe(true);
|
|
443
|
+
expect(String(created["body"])).toContain(`user:${payloadAuthor}`);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("pii field absent from the section with an unresolvable owner: skipped, no failure (no flags)", async () => {
|
|
447
|
+
const noteId = generateId();
|
|
448
|
+
// No "body" key at all, no authorId, no projection row — the value
|
|
449
|
+
// guard skips owner resolution entirely before it could ever fail.
|
|
450
|
+
await appendPlain(noteId, "note", "note.created", { id: noteId });
|
|
451
|
+
|
|
452
|
+
armKms();
|
|
453
|
+
const result = await backfillEventPiiEncryption(testDb.db, registry);
|
|
454
|
+
|
|
455
|
+
expect(result.failures).toEqual([]);
|
|
456
|
+
expect(result.updatedEvents).toBe(0);
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
283
460
|
describe("backfillEventPiiEncryption: raw jsonb column type (fw#2253)", () => {
|
|
284
461
|
// The UPDATE used to wrap outcome.payload in JSON.stringify before handing
|
|
285
462
|
// it to the ::jsonb cast — Bun.SQL already serializes objects, so the
|
|
@@ -14,7 +14,7 @@ import { createBigIntField, createEntity, createTextField } from "../engine";
|
|
|
14
14
|
// ohne `.default(now()).notNull()` macht inserted_at still nullable.
|
|
15
15
|
//
|
|
16
16
|
// PII-Annotations:
|
|
17
|
-
// - fileName →
|
|
17
|
+
// - fileName → personal: "self" (Originalname enthält oft Personen-Bezug:
|
|
18
18
|
// "Marc-Lebenslauf.pdf", "Krankheitsattest-Mai.pdf"). Andere Felder
|
|
19
19
|
// (storageKey, mimeType, size, entityType, entityId, fieldName) treffen
|
|
20
20
|
// die PII-Heuristik nicht.
|
|
@@ -23,7 +23,7 @@ export const fileRefEntity = createEntity({
|
|
|
23
23
|
softDelete: true,
|
|
24
24
|
fields: {
|
|
25
25
|
storageKey: createTextField({ required: true }),
|
|
26
|
-
fileName: createTextField({ required: true,
|
|
26
|
+
fileName: createTextField({ required: true, personal: "self", find: "none" }),
|
|
27
27
|
mimeType: createTextField({ required: true }),
|
|
28
28
|
size: createBigIntField({ required: true }),
|
|
29
29
|
entityType: createTextField(),
|
|
@@ -46,7 +46,7 @@ const widgetFeature = defineFeature("reindex-test", (r) => {
|
|
|
46
46
|
const contactEntity = createEntity({
|
|
47
47
|
table: "read_reindex_contacts",
|
|
48
48
|
fields: {
|
|
49
|
-
label: createTextField({ required: true, maxLength: 100,
|
|
49
|
+
label: createTextField({ required: true, maxLength: 100, personal: "self", find: "fuzzy" }),
|
|
50
50
|
},
|
|
51
51
|
});
|
|
52
52
|
const contactTable = buildEntityTable("contact", contactEntity);
|
|
@@ -18,8 +18,8 @@ import { purgeSearchDocumentsForSubject } from "../purge-subject";
|
|
|
18
18
|
const contactEntity = createEntity({
|
|
19
19
|
table: "read_search_pii_contacts",
|
|
20
20
|
fields: {
|
|
21
|
-
//
|
|
22
|
-
label: createTextField({ required: true, maxLength: 100,
|
|
21
|
+
// personal: "self" → subject = entity id. find: "fuzzy" → searchable via derived index.
|
|
22
|
+
label: createTextField({ required: true, maxLength: 100, personal: "self", find: "fuzzy" }),
|
|
23
23
|
note: createTextField({ required: true, maxLength: 100, searchable: true }),
|
|
24
24
|
},
|
|
25
25
|
});
|
|
@@ -31,15 +31,18 @@ export const sharedUserEntity = createEntity({
|
|
|
31
31
|
required: true,
|
|
32
32
|
format: "email",
|
|
33
33
|
searchable: true,
|
|
34
|
-
|
|
34
|
+
personal: false,
|
|
35
|
+
reason: "test_fixture",
|
|
35
36
|
}),
|
|
36
37
|
firstName: createTextField({
|
|
37
38
|
searchable: true,
|
|
38
|
-
|
|
39
|
+
personal: false,
|
|
40
|
+
reason: "test_fixture",
|
|
39
41
|
}),
|
|
40
42
|
lastName: createTextField({
|
|
41
43
|
searchable: true,
|
|
42
|
-
|
|
44
|
+
personal: false,
|
|
45
|
+
reason: "test_fixture",
|
|
43
46
|
}),
|
|
44
47
|
isEnabled: createBooleanField({ default: true }),
|
|
45
48
|
},
|