@cosmicdrift/kumiko-framework 0.209.0 → 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 +189 -19
- 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 +205 -2
- package/src/files/file-ref-entity.ts +2 -2
- package/src/migrations/pending-rebuilds.ts +1 -1
- 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();
|
|
@@ -279,3 +295,190 @@ describe("backfillEventPiiEncryption", () => {
|
|
|
279
295
|
);
|
|
280
296
|
});
|
|
281
297
|
});
|
|
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
|
+
|
|
460
|
+
describe("backfillEventPiiEncryption: raw jsonb column type (fw#2253)", () => {
|
|
461
|
+
// The UPDATE used to wrap outcome.payload in JSON.stringify before handing
|
|
462
|
+
// it to the ::jsonb cast — Bun.SQL already serializes objects, so the
|
|
463
|
+
// double encoding produced a jsonb STRING scalar instead of an object.
|
|
464
|
+
// loadAggregate stayed green either way: the typed read path (bun-db's
|
|
465
|
+
// coerceRow) re-parses string-shaped jsonb columns on the way out, which
|
|
466
|
+
// cancels the write-side bug out. Only raw SQL consumers (this query,
|
|
467
|
+
// GDPR exports, MSP replays) saw the corruption, so assert on the column
|
|
468
|
+
// type directly instead of going through loadAggregate.
|
|
469
|
+
test("UPDATE writes payload as a jsonb object, not a double-encoded string", async () => {
|
|
470
|
+
const c1 = generateId();
|
|
471
|
+
await appendPlain(c1, "contact", "contact.created", { id: c1, email: "raw@x.com" });
|
|
472
|
+
|
|
473
|
+
armKms();
|
|
474
|
+
const result = await backfillEventPiiEncryption(testDb.db, registry);
|
|
475
|
+
expect(result.updatedEvents).toBe(1);
|
|
476
|
+
|
|
477
|
+
const rows = (await asRawClient(testDb.db).unsafe(
|
|
478
|
+
`SELECT jsonb_typeof(payload) AS t FROM "kumiko_events" WHERE aggregate_id = $1`,
|
|
479
|
+
[c1],
|
|
480
|
+
)) as ReadonlyArray<{ t: string }>;
|
|
481
|
+
expect(rows).toHaveLength(1);
|
|
482
|
+
expect(rows[0]?.t).toBe("object");
|
|
483
|
+
});
|
|
484
|
+
});
|
|
@@ -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(),
|
|
@@ -200,7 +200,7 @@ export type EnqueueProjectionRebuildDeps = {
|
|
|
200
200
|
readonly db: DbConnection;
|
|
201
201
|
readonly registry: Registry;
|
|
202
202
|
// Present + projection-rebuild job registered (jobs composed) → tracked,
|
|
203
|
-
// retryable job (
|
|
203
|
+
// retryable job (store_job_runs + store_job_run_logs). Absent → inline rebuild.
|
|
204
204
|
readonly jobRunner?: JobRunner;
|
|
205
205
|
};
|
|
206
206
|
|
|
@@ -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
|
},
|