@cosmicdrift/kumiko-framework 0.173.0 → 0.174.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.
@@ -5,6 +5,11 @@
5
5
  // nothing.
6
6
 
7
7
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8
+ import {
9
+ configurePiiSubjectKms,
10
+ InMemoryKmsAdapter,
11
+ isPiiCiphertext,
12
+ } from "@cosmicdrift/kumiko-framework/crypto";
8
13
  import {
9
14
  asRawClient,
10
15
  buildEntityTable,
@@ -20,6 +25,7 @@ import {
20
25
  TestUsers,
21
26
  unsafeCreateEntityTable,
22
27
  } from "@cosmicdrift/kumiko-framework/stack";
28
+ import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
23
29
 
24
30
  const widgetEntity = createEntity({
25
31
  table: "read_reindex_widgets",
@@ -35,16 +41,38 @@ const widgetFeature = defineFeature("reindex-test", (r) => {
35
41
  r.entity("widget", widgetEntity);
36
42
  });
37
43
 
44
+ // fw#1611: pii + searchable — the read-table column holds ciphertext, a
45
+ // naive backfill would index blobs (or resurrect a crypto-shredded row).
46
+ const contactEntity = createEntity({
47
+ table: "read_reindex_contacts",
48
+ fields: {
49
+ label: createTextField({ required: true, maxLength: 100, pii: true, searchable: true }),
50
+ },
51
+ });
52
+ const contactTable = buildEntityTable("contact", contactEntity);
53
+ const contactFeature = defineFeature("reindex-pii-test", (r) => {
54
+ r.entity("contact", contactEntity);
55
+ });
56
+
38
57
  let stack: TestStack;
58
+ let kms: InMemoryKmsAdapter;
39
59
  const admin = TestUsers.admin;
40
60
 
41
61
  beforeAll(async () => {
42
- stack = await setupTestStack({ features: [widgetFeature] });
62
+ stack = await setupTestStack({ features: [widgetFeature, contactFeature] });
43
63
  await unsafeCreateEntityTable(stack.db, widgetEntity);
64
+ await unsafeCreateEntityTable(stack.db, contactEntity, "contact");
44
65
  await createEventsTable(stack.db);
66
+ // Shared across both pii tests below (not per-test) — reindexEntity scans
67
+ // the whole tenant table regardless of which test created which row, so a
68
+ // fresh KMS instance per test would make earlier rows' subject keys
69
+ // unresolvable (KeyNotFoundError) instead of exercising the erased path.
70
+ kms = new InMemoryKmsAdapter();
71
+ configurePiiSubjectKms(kms);
45
72
  });
46
73
 
47
74
  afterAll(async () => {
75
+ resetPiiSubjectKmsForTests();
48
76
  await stack.cleanup();
49
77
  });
50
78
 
@@ -141,4 +169,72 @@ describe("reindexEntity", () => {
141
169
  );
142
170
  }
143
171
  });
172
+
173
+ // fw#1611: reindexEntity read the read-table row (ciphertext for pii+
174
+ // searchable fields) straight into the search document, skipping the
175
+ // decrypt step createSearchEventConsumer applies on the live path.
176
+ test("decrypts pii+searchable fields before indexing — backfill is findable by plaintext, not ciphertext", async () => {
177
+ const plain = "UniqueReindexPiiLabel1611";
178
+ const executor = createEventStoreExecutor(contactTable, contactEntity, {
179
+ entityName: "contact",
180
+ });
181
+ const created = await executor.create(
182
+ { label: plain },
183
+ admin,
184
+ createTenantDb(stack.db, admin.tenantId, "system"),
185
+ );
186
+ if (!created.isSuccess) throw new Error("seed failed");
187
+
188
+ // No dispatcher run — row exists only on the read-table, ciphertext.
189
+ const row = (
190
+ await asRawClient(stack.db).unsafe(
191
+ `SELECT label FROM "read_reindex_contacts" WHERE id = $1`,
192
+ [created.data.id],
193
+ )
194
+ )[0] as { label: string };
195
+ expect(isPiiCiphertext(row.label)).toBe(true);
196
+
197
+ const result = await reindexEntity(
198
+ stack.db,
199
+ stack.registry,
200
+ stack.search,
201
+ "contact",
202
+ admin.tenantId,
203
+ );
204
+ expect(result.indexedRows).toBe(1);
205
+ expect(result.failures).toHaveLength(0);
206
+
207
+ const hits = await stack.search.search(admin.tenantId, plain, { filterType: "contact" });
208
+ expect(hits.some((h) => String(h.entityId) === String(created.data.id))).toBe(true);
209
+ });
210
+
211
+ test("skips a row whose subject key was already erased — reindex must not resurrect a crypto-shredded row", async () => {
212
+ const plain = "ErasedReindexPiiLabel1611";
213
+ const executor = createEventStoreExecutor(contactTable, contactEntity, {
214
+ entityName: "contact",
215
+ });
216
+ const created = await executor.create(
217
+ { label: plain },
218
+ admin,
219
+ createTenantDb(stack.db, admin.tenantId, "system"),
220
+ );
221
+ if (!created.isSuccess) throw new Error("seed failed");
222
+
223
+ // pii: true → subject key is the entity id itself.
224
+ await kms.eraseKey({ kind: "user", userId: String(created.data.id) });
225
+
226
+ const result = await reindexEntity(
227
+ stack.db,
228
+ stack.registry,
229
+ stack.search,
230
+ "contact",
231
+ admin.tenantId,
232
+ );
233
+ // Shares the tenant/table with the preceding test, so other rows may
234
+ // also index here — what matters is that THIS erased row didn't.
235
+ expect(result.failures).toHaveLength(0);
236
+
237
+ const hits = await stack.search.search(admin.tenantId, plain, { filterType: "contact" });
238
+ expect(hits.some((h) => String(h.entityId) === String(created.data.id))).toBe(false);
239
+ });
144
240
  });
@@ -9,7 +9,11 @@ import type { DbRunner } from "../db/connection";
9
9
  import { resolveTableName } from "../db/entity-table-meta";
10
10
  import { executeRawQuery } from "../db/queries/raw-sql";
11
11
  import type { Registry, TenantId } from "../engine/types";
12
- import { buildSearchDocument } from "../pipeline/system-hooks";
12
+ import {
13
+ buildSearchDocument,
14
+ decryptSearchableSubjectFields,
15
+ hasErasedSearchableSubjectField,
16
+ } from "../pipeline/system-hooks";
13
17
  import { toSnakeCase } from "../utils/case";
14
18
  import type { SearchAdapter, SearchDocument } from "./types";
15
19
 
@@ -135,7 +139,16 @@ export async function reindexEntity(
135
139
  result.scannedRows++;
136
140
  const entityId = String(row["id"]);
137
141
  try {
138
- const state = rowToState(row, fieldNames);
142
+ const rawState = rowToState(row, fieldNames);
143
+ // `pii + searchable` fields hold ciphertext on the read-table row —
144
+ // decrypt the same way the live write-path consumer does. An
145
+ // erased subject key doesn't throw, it swaps the value for
146
+ // PII_ERASED_SENTINEL (see decryptSearchableSubjectFields) — check
147
+ // for that AFTER decrypting, mirroring the softDelete filter above:
148
+ // resurrecting a crypto-shredded row here would undo
149
+ // purgeSearchDocumentsForSubject and make it findable again.
150
+ const state = await decryptSearchableSubjectFields(entityName, rawState, registry);
151
+ if (hasErasedSearchableSubjectField(entityName, state, registry)) continue;
139
152
  const doc = await buildSearchDocument(entityName, entityId, state, registry);
140
153
  if (doc) docs.push({ entityId, doc });
141
154
  } catch (e) {