@cosmicdrift/kumiko-framework 0.173.1 → 0.174.1

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.
Files changed (46) hide show
  1. package/package.json +3 -3
  2. package/src/api/__tests__/api.test.ts +11 -2
  3. package/src/api/__tests__/request-id-middleware.test.ts +73 -0
  4. package/src/api/request-id-middleware.ts +13 -2
  5. package/src/api/sse-broker.ts +7 -3
  6. package/src/crypto/__tests__/kms-wiring.test.ts +6 -0
  7. package/src/crypto/ciphertext-pattern.ts +19 -0
  8. package/src/crypto/kms-wiring.ts +5 -0
  9. package/src/db/__tests__/eagerload.integration.test.ts +119 -1
  10. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +45 -0
  11. package/src/db/__tests__/migrate-runner.test.ts +16 -0
  12. package/src/db/blind-index-cleanup.ts +15 -24
  13. package/src/db/eagerload.ts +75 -9
  14. package/src/db/entity-table-meta.ts +6 -1
  15. package/src/db/event-store-executor-write.ts +38 -3
  16. package/src/db/migrate-runner.ts +5 -0
  17. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +19 -0
  18. package/src/engine/__tests__/boot-validator.test.ts +51 -1
  19. package/src/engine/__tests__/ownership.test.ts +23 -0
  20. package/src/engine/__tests__/schema-builder.test.ts +76 -6
  21. package/src/engine/boot-validator/access-roles.ts +63 -21
  22. package/src/engine/boot-validator/entity-handler.ts +4 -0
  23. package/src/engine/boot-validator/index.ts +17 -2
  24. package/src/engine/boot-validator/pii-retention.ts +17 -10
  25. package/src/engine/boot-validator/screens.ts +10 -2
  26. package/src/engine/boot-validator.ts +1 -0
  27. package/src/engine/create-app.ts +4 -2
  28. package/src/engine/field-access.ts +17 -3
  29. package/src/engine/index.ts +2 -0
  30. package/src/engine/ownership.ts +19 -0
  31. package/src/engine/schema-builder.ts +46 -22
  32. package/src/entrypoint/index.ts +2 -5
  33. package/src/jobs/__tests__/scheduler-id.test.ts +13 -1
  34. package/src/jobs/job-runner.ts +7 -1
  35. package/src/pipeline/dispatch-shared.ts +11 -3
  36. package/src/pipeline/dispatch-stream.ts +3 -6
  37. package/src/pipeline/system-hooks.ts +20 -7
  38. package/src/schema-cli.ts +7 -5
  39. package/src/search/__tests__/reindex-entity.integration.test.ts +97 -1
  40. package/src/search/purge-subject.ts +2 -9
  41. package/src/search/reindex-entity.ts +15 -2
  42. package/src/secrets/derive-purpose-secret.ts +6 -16
  43. package/src/testing/__tests__/e2e-generator.test.ts +7 -0
  44. package/src/testing/__tests__/wait-for.test.ts +2 -2
  45. package/src/testing/e2e-generator.ts +5 -0
  46. package/src/testing/shared-entities.ts +3 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.173.1",
3
+ "version": "0.174.1",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -182,7 +182,7 @@
182
182
  "./package.json": "./package.json"
183
183
  },
184
184
  "dependencies": {
185
- "@cosmicdrift/kumiko-types": "0.173.1",
185
+ "@cosmicdrift/kumiko-types": "0.174.1",
186
186
  "bullmq": "^5.76.7",
187
187
  "bun-types": "^1.3.13",
188
188
  "hono": "^4.12.27",
@@ -198,7 +198,7 @@
198
198
  "zod": "^4.4.3"
199
199
  },
200
200
  "devDependencies": {
201
- "@cosmicdrift/kumiko-dispatcher-live": "0.173.1",
201
+ "@cosmicdrift/kumiko-dispatcher-live": "0.174.1",
202
202
  "bun-types": "^1.3.13",
203
203
  "pino-pretty": "^13.1.3"
204
204
  },
@@ -564,9 +564,18 @@ describe("POST /api/stream pre-pull race", () => {
564
564
  // in-flight .next(), so cleanup only runs once the pending pull settles.
565
565
  // Abort before heartbeatMs so the route hits the 499 branch; sleep then
566
566
  // completes and the queued return drains the generator's finally.
567
+ //
568
+ // Abort is triggered off an entry signal, not a fixed sleep margin against
569
+ // the heartbeat timer — a stalled event loop could otherwise let the
570
+ // heartbeat fire first and flip the route onto the 200-SSE branch.
567
571
  let cleanedUp = false;
572
+ let entered: () => void;
573
+ const atEntry = new Promise<void>((resolve) => {
574
+ entered = resolve;
575
+ });
568
576
  const dispatcher = stubDispatcher(async function* () {
569
577
  try {
578
+ entered();
570
579
  await Bun.sleep(80);
571
580
  yield { i: 0 };
572
581
  } finally {
@@ -583,8 +592,8 @@ describe("POST /api/stream pre-pull race", () => {
583
592
  signal: ac.signal,
584
593
  }),
585
594
  );
586
- // Abort while firstPull is still racing the heartbeat timer.
587
- await Bun.sleep(5);
595
+ // Abort as soon as the generator has been entered — no timing window left.
596
+ await atEntry;
588
597
  ac.abort();
589
598
  const res = await pending;
590
599
  expect(res.status).toBe(499);
@@ -121,3 +121,76 @@ describe("requestIdMiddleware — ip + userAgent capture (603/2)", () => {
121
121
  expect(captured).toBeUndefined();
122
122
  });
123
123
  });
124
+
125
+ describe("requestIdMiddleware — client-supplied id sanitization (input-validation)", () => {
126
+ test("oversized X-Request-ID is rejected, a fresh id is generated instead", async () => {
127
+ let captured: string | undefined;
128
+
129
+ const app = new Hono();
130
+ app.use("/probe", requestIdMiddleware());
131
+ app.get("/probe", (c) => {
132
+ captured = requestContext.get()?.requestId;
133
+ return c.text("ok");
134
+ });
135
+
136
+ const junk = "x".repeat(5000);
137
+ const res = await app.request(
138
+ new Request("http://test.local/probe", {
139
+ method: "GET",
140
+ headers: { "X-Request-ID": junk },
141
+ }),
142
+ );
143
+
144
+ expect(res.status).toBe(200);
145
+ expect(captured).toBeDefined();
146
+ expect(captured).not.toBe(junk);
147
+ expect(captured?.length).toBeLessThan(128);
148
+ });
149
+
150
+ test("disallowed-character X-Correlation-ID falls back to the (sanitized) requestId", async () => {
151
+ let captured: { requestId: string | undefined; correlationId: string | undefined } = {
152
+ requestId: undefined,
153
+ correlationId: undefined,
154
+ };
155
+
156
+ const app = new Hono();
157
+ app.use("/probe", requestIdMiddleware());
158
+ app.get("/probe", (c) => {
159
+ const ctx = requestContext.get();
160
+ captured = { requestId: ctx?.requestId, correlationId: ctx?.correlationId };
161
+ return c.text("ok");
162
+ });
163
+
164
+ const res = await app.request(
165
+ new Request("http://test.local/probe", {
166
+ method: "GET",
167
+ headers: { "X-Request-ID": "req-1", "X-Correlation-ID": "corr injected; drop table" },
168
+ }),
169
+ );
170
+
171
+ expect(res.status).toBe(200);
172
+ expect(captured.requestId).toBe("req-1");
173
+ expect(captured.correlationId).toBe("req-1");
174
+ });
175
+
176
+ test("a well-formed client id is preserved", async () => {
177
+ let captured: string | undefined;
178
+
179
+ const app = new Hono();
180
+ app.use("/probe", requestIdMiddleware());
181
+ app.get("/probe", (c) => {
182
+ captured = requestContext.get()?.requestId;
183
+ return c.text("ok");
184
+ });
185
+
186
+ const res = await app.request(
187
+ new Request("http://test.local/probe", {
188
+ method: "GET",
189
+ headers: { "X-Request-ID": "trace-abc123.def" },
190
+ }),
191
+ );
192
+
193
+ expect(res.status).toBe(200);
194
+ expect(captured).toBe("trace-abc123.def");
195
+ });
196
+ });
@@ -4,6 +4,16 @@ import { requestContext } from "./request-context";
4
4
  const REQUEST_ID_HEADER = "X-Request-ID";
5
5
  const CORRELATION_ID_HEADER = "X-Correlation-ID";
6
6
 
7
+ // requestId/correlationId flow unvalidated into append-only event-store
8
+ // metadata (e.g. sessions:revoke-all-for-user) — cap shape at the trust
9
+ // boundary so a client can't smuggle an oversized/control-char payload into
10
+ // permanent, replayed, DSGVO-exported storage via a client-set header.
11
+ const SAFE_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
12
+
13
+ function sanitizeClientId(value: string | undefined): string | undefined {
14
+ return value !== undefined && SAFE_ID_RE.test(value) ? value : undefined;
15
+ }
16
+
7
17
  /**
8
18
  * Assigns a requestId + correlationId to every request and wraps execution
9
19
  * in AsyncLocalStorage. Runs BEFORE auth — both ids are available even for
@@ -15,8 +25,9 @@ const CORRELATION_ID_HEADER = "X-Correlation-ID";
15
25
  */
16
26
  export function requestIdMiddleware() {
17
27
  return async (c: Context, next: Next) => {
18
- const requestId = c.req.header(REQUEST_ID_HEADER) ?? requestContext.generateId();
19
- const correlationId = c.req.header(CORRELATION_ID_HEADER) ?? requestId;
28
+ const requestId =
29
+ sanitizeClientId(c.req.header(REQUEST_ID_HEADER)) ?? requestContext.generateId();
30
+ const correlationId = sanitizeClientId(c.req.header(CORRELATION_ID_HEADER)) ?? requestId;
20
31
  c.header(REQUEST_ID_HEADER, requestId);
21
32
  c.header(CORRELATION_ID_HEADER, correlationId);
22
33
  c.set("requestId", requestId);
@@ -19,9 +19,13 @@ export type SseBroker = {
19
19
  getClientCount(channel: string): number;
20
20
  getTotalClientCount(): number;
21
21
  // Separate from addClient so it doesn't count towards getClientCount.
22
- // Optional: additive for external/test SseBroker impls (call sites use?.).
23
- subscribeAccessInvalidation?(userId: string, onInvalidate: () => void): () => void;
24
- publishAccessInvalidation?(userId: string): void;
22
+ // Required (fw#1601): an app-injected SseBroker (e.g. a Redis-backed
23
+ // multi-replica broker) that skips these silently turns the mid-stream
24
+ // access-teardown security control (#1561) into a no-op — a revoked
25
+ // session keeps receiving live SSE data with no error or log. A no-op
26
+ // stub is one line for a broker that genuinely doesn't need it.
27
+ subscribeAccessInvalidation(userId: string, onInvalidate: () => void): () => void;
28
+ publishAccessInvalidation(userId: string): void;
25
29
  };
26
30
 
27
31
  export function createSseBroker(): SseBroker {
@@ -83,6 +83,12 @@ describe("buildPgKmsOptions", () => {
83
83
  /PLATFORM_KEK_PREVIOUS_VERSION must be set/,
84
84
  );
85
85
  });
86
+
87
+ test("rejects PLATFORM_KEK_PREVIOUS_VERSION without PLATFORM_KEK_PREVIOUS — half-set rotation must not silently drop the version", () => {
88
+ expect(() => buildPgKmsOptions({ ...rotationEnv, PLATFORM_KEK_PREVIOUS_VERSION: "1" })).toThrow(
89
+ /PLATFORM_KEK_PREVIOUS must be set/,
90
+ );
91
+ });
86
92
  });
87
93
 
88
94
  describe("resolveKmsWiring", () => {
@@ -0,0 +1,19 @@
1
+ // Shared SQL helpers for locating a subject's PII ciphertext by its inline
2
+ // subject-key prefix (kumiko-pii:v<version>:<subjectKey>:...). Used by both
3
+ // the blind-index sweep (db/blind-index-cleanup.ts) and the search-index
4
+ // purge (search/purge-subject.ts) — the two sweeps must stay in lockstep
5
+ // across ciphertext format versions.
6
+
7
+ export function quoteIdent(name: string): string {
8
+ return `"${name.replace(/"/g, '""')}"`;
9
+ }
10
+
11
+ export function escapeLikePattern(value: string): string {
12
+ return value.replace(/[\\%_]/g, (m) => `\\${m}`);
13
+ }
14
+
15
+ // "v%" matches any format version (v1 no-AAD, v2 AAD-bound, #1263) — the
16
+ // subject key placement is stable across versions.
17
+ export function subjectCiphertextLikePattern(subjectKey: string): string {
18
+ return `kumiko-pii:v%:${escapeLikePattern(subjectKey)}:%`;
19
+ }
@@ -83,6 +83,11 @@ export function buildPgKmsOptions(env: PgKmsRotationEnv): PgKmsAdapterOptions {
83
83
  ? parseKekVersion(env.PLATFORM_KEK_VERSION, "PLATFORM_KEK_VERSION")
84
84
  : 1;
85
85
  if (!env.PLATFORM_KEK_PREVIOUS) {
86
+ if (env.PLATFORM_KEK_PREVIOUS_VERSION) {
87
+ throw new Error(
88
+ "PLATFORM_KEK_PREVIOUS must be set when PLATFORM_KEK_PREVIOUS_VERSION is set.",
89
+ );
90
+ }
86
91
  return {
87
92
  databaseUrl: env.SUBJECT_KEYS_DATABASE_URL,
88
93
  platformKek: env.PLATFORM_KEK,
@@ -9,7 +9,7 @@ import {
9
9
  InMemoryKmsAdapter,
10
10
  isPiiCiphertext,
11
11
  } from "../../crypto";
12
- import { createEntity, createTextField } from "../../engine";
12
+ import { createEntity, createTextField, from } from "../../engine";
13
13
  import type { EntityDefinition } from "../../engine/types";
14
14
  import { setupTestStack, type TestStack, testTenantId, unsafeCreateEntityTable } from "../../stack";
15
15
  import { createTestEnvelopeCipher, resetPiiSubjectKmsForTests, seedRows } from "../../testing";
@@ -58,13 +58,44 @@ const leadEntity = createEntity({
58
58
  fields: {
59
59
  title: createTextField({ required: true }),
60
60
  contact: { type: "reference", entity: "contact" },
61
+ ownedContact: { type: "reference", entity: "ownedContact" },
62
+ unrestrictedContact: { type: "reference", entity: "unrestrictedContact" },
61
63
  },
62
64
  });
63
65
  const contactTable = buildEntityTable("contact", contactEntity);
64
66
 
67
+ // fw#1671: ownership-scoped ref entity — eagerload has no SessionUser to
68
+ // evaluate access.read against, so PII/encrypted fields must be stripped
69
+ // entirely rather than decrypted (fail closed, not a same-tenant PII leak).
70
+ const ownedContactEntity = createEntity({
71
+ table: "el_owned_contacts",
72
+ fields: {
73
+ name: createTextField({ required: true }),
74
+ email: createTextField({ required: true, tenantOwned: true }),
75
+ iban: createTextField({ required: true, encrypted: true }),
76
+ },
77
+ access: { read: { admin: from("user:id", "ownerId") } },
78
+ });
79
+ const ownedContactTable = buildEntityTable("ownedContact", ownedContactEntity);
80
+
81
+ // access.read present but every role is "all" (unrestricted) — must NOT
82
+ // trigger the strip path, or #1667's decrypt would silently regress for
83
+ // every entity that merely declares an access.read map without narrowing it.
84
+ const unrestrictedContactEntity = createEntity({
85
+ table: "el_unrestricted_contacts",
86
+ fields: {
87
+ name: createTextField({ required: true }),
88
+ email: createTextField({ required: true, tenantOwned: true }),
89
+ },
90
+ access: { read: { admin: "all", member: "all" } },
91
+ });
92
+ const unrestrictedContactTable = buildEntityTable("unrestrictedContact", unrestrictedContactEntity);
93
+
65
94
  const resolve = (name: string): EntityDefinition | undefined => {
66
95
  if (name === "author") return authorEntity;
67
96
  if (name === "contact") return contactEntity;
97
+ if (name === "ownedContact") return ownedContactEntity;
98
+ if (name === "unrestrictedContact") return unrestrictedContactEntity;
68
99
  return undefined;
69
100
  };
70
101
 
@@ -83,6 +114,9 @@ const many = (r: EagerloadedRow | undefined, f: string) =>
83
114
  r?._refs?.[f] as ReadonlyArray<Record<string, unknown>> | undefined;
84
115
 
85
116
  const CX = "44444444-4444-4444-8444-444444444444";
117
+ const OWNED = "66666666-6666-4666-8666-666666666666";
118
+ const UNRESTRICTED = "88888888-8888-4888-8888-888888888888";
119
+ const BROKEN = "77777777-7777-4777-8777-777777777777";
86
120
  const TEST_KEY = Buffer.from("a]bJm#kP9xQ2@wN!vL$hR5yT8eU0iO3f").toString("base64");
87
121
  const cipher = createTestEnvelopeCipher(TEST_KEY);
88
122
  const kms = new InMemoryKmsAdapter();
@@ -94,6 +128,8 @@ beforeAll(async () => {
94
128
  stack = await setupTestStack({ features: [] });
95
129
  await unsafeCreateEntityTable(stack.db, authorEntity);
96
130
  await unsafeCreateEntityTable(stack.db, contactEntity);
131
+ await unsafeCreateEntityTable(stack.db, ownedContactEntity);
132
+ await unsafeCreateEntityTable(stack.db, unrestrictedContactEntity);
97
133
  dbA = createTenantDb(stack.db, tenantA, "tenant");
98
134
 
99
135
  await seedRows(stack.db, authorTable, [
@@ -120,6 +156,44 @@ beforeAll(async () => {
120
156
  cipher,
121
157
  );
122
158
  await seedRows(stack.db, contactTable, [encrypted]);
159
+
160
+ const plainOwned = {
161
+ id: OWNED,
162
+ tenantId: tenantA,
163
+ name: "Owned Grace",
164
+ email: "owned-grace@acme.test",
165
+ iban: "DE99999",
166
+ };
167
+ const ownedPiiEncrypted = await encryptPiiFieldValues(
168
+ plainOwned,
169
+ ownedContactEntity,
170
+ ["email"],
171
+ kms,
172
+ { requestId: "test" },
173
+ );
174
+ const ownedEncrypted = await encryptEntityFieldValues(
175
+ ownedPiiEncrypted,
176
+ collectEncryptedFieldNames(ownedContactEntity),
177
+ cipher,
178
+ );
179
+ await seedRows(stack.db, ownedContactTable, [ownedEncrypted]);
180
+
181
+ // Legacy/backfilled row: iban never went through encryptEntityFieldValues,
182
+ // so decryptEntityFieldValues throws on it (fw#1671 error-propagation) —
183
+ // a single row like this must not 500 the whole eagerload lookup.
184
+ await seedRows(stack.db, contactTable, [
185
+ {
186
+ id: BROKEN,
187
+ tenantId: tenantA,
188
+ name: "Broken",
189
+ email: "broken@acme.test",
190
+ iban: "not-an-envelope",
191
+ },
192
+ ]);
193
+
194
+ await seedRows(stack.db, unrestrictedContactTable, [
195
+ { id: UNRESTRICTED, tenantId: tenantA, name: "Unrestricted", email: "unrestricted@acme.test" },
196
+ ]);
123
197
  });
124
198
 
125
199
  afterAll(async () => {
@@ -237,4 +311,48 @@ describe("enrichWithReferences", () => {
237
311
  expect(contact?.["iban"]).toBe("DE12345");
238
312
  expect(isPiiCiphertext(contact?.["email"])).toBe(false);
239
313
  });
314
+
315
+ test('fw#1671: access.read map where every rule is "all" (unrestricted) does NOT trigger the strip path', async () => {
316
+ const [row] = (await enrichWithReferences(
317
+ [{ id: "l1", unrestrictedContact: UNRESTRICTED }],
318
+ leadEntity,
319
+ resolve,
320
+ dbA,
321
+ )) as EagerloadedRow[];
322
+ const unrestricted = single(row, "unrestrictedContact");
323
+
324
+ expect(unrestricted).toBeDefined();
325
+ expect(unrestricted?.["name"]).toBe("Unrestricted");
326
+ expect(unrestricted?.["email"]).toBe("unrestricted@acme.test");
327
+ });
328
+
329
+ test("fw#1671: ownership-scoped ref entity — PII/encrypted fields are stripped, not decrypted or leaked as ciphertext", async () => {
330
+ const [row] = (await enrichWithReferences(
331
+ [{ id: "l1", ownedContact: OWNED }],
332
+ leadEntity,
333
+ resolve,
334
+ dbA,
335
+ )) as EagerloadedRow[];
336
+ const owned = single(row, "ownedContact");
337
+
338
+ expect(owned).toBeDefined();
339
+ expect(owned?.["name"]).toBe("Owned Grace");
340
+ expect(owned?.["email"]).toBeUndefined();
341
+ expect(owned?.["iban"]).toBeUndefined();
342
+ });
343
+
344
+ test("fw#1671: a row with a broken envelope is dropped, a sibling row's good ref still resolves in the same batch", async () => {
345
+ const [goodRow, brokenRow] = (await enrichWithReferences(
346
+ [
347
+ { id: "l1", contact: CX },
348
+ { id: "l2", contact: BROKEN },
349
+ ],
350
+ leadEntity,
351
+ resolve,
352
+ dbA,
353
+ )) as EagerloadedRow[];
354
+
355
+ expect(single(goodRow, "contact")?.["email"]).toBe("grace@acme.test");
356
+ expect(single(brokenRow, "contact")).toBeUndefined();
357
+ });
240
358
  });
@@ -245,6 +245,51 @@ describe("event-store-executor write-verbs — field-level ownership_denied", ()
245
245
  if (result.isSuccess) return;
246
246
  expect((result.error.details as { reason?: string }).reason).toBe("ownership_denied");
247
247
  });
248
+
249
+ // fw#1685: a preSave hook that derives a field the user never submitted
250
+ // must not have that field field-ownership-checked against the user —
251
+ // only what the user actually wrote in `payload.changes` is checked.
252
+ test("create: preSave-derived `note` (not submitted by the user) does not trigger ownership_denied", async () => {
253
+ const result = await crud.create({ authorId: TestUsers.driver.id }, nonAdmin, tdb, {
254
+ preSave: async (changes) => ({ ...changes, note: "hook-derived" }),
255
+ });
256
+ expect(result.isSuccess).toBe(true);
257
+ });
258
+
259
+ test("update: preSave-derived `note` (not submitted by the user) does not trigger ownership_denied", async () => {
260
+ const created = await crud.create(
261
+ { authorId: TestUsers.driver.id, note: "original" },
262
+ admin,
263
+ tdb,
264
+ );
265
+ if (!created.isSuccess) throw new Error("setup failed");
266
+
267
+ const result = await crud.update(
268
+ { id: created.data.id, version: 1, changes: {} },
269
+ nonAdmin,
270
+ tdb,
271
+ { preSave: async (changes) => ({ ...changes, note: "hook-derived" }) },
272
+ );
273
+ expect(result.isSuccess).toBe(true);
274
+ });
275
+
276
+ // Review-fix (kumiko-framework#1685): a preSave hook that echoes `id`/
277
+ // `version` back in its return value must not have those leak into the
278
+ // persisted row — the framework-minted aggregateId stays authoritative.
279
+ test("create: preSave hook returning `id`/`version` does not override the minted aggregateId", async () => {
280
+ const result = await crud.create({ authorId: nonAdmin.id, note: "mine" }, nonAdmin, tdb, {
281
+ preSave: async (changes) => ({ ...changes, id: "hook-injected-id", version: 999 }),
282
+ });
283
+ expect(result.isSuccess).toBe(true);
284
+ if (!result.isSuccess) return;
285
+ expect(result.data.id).not.toBe("hook-injected-id");
286
+
287
+ const row = await asRawClient(testDb.db).unsafe(
288
+ `SELECT id FROM read_es_write_owned_field WHERE id = $1`,
289
+ [result.data.id],
290
+ );
291
+ expect(row.length).toBe(1);
292
+ });
248
293
  });
249
294
 
250
295
  // =============================================================================
@@ -99,4 +99,20 @@ describe("splitSqlStatements", () => {
99
99
  'CREATE TABLE "a" ("id" uuid);',
100
100
  ]);
101
101
  });
102
+
103
+ test("throws fail-loud on a dollar-quoted body instead of splitting it in half", () => {
104
+ expect(() => splitSqlStatements("DO $$ BEGIN PERFORM 1; END $$;")).toThrow(
105
+ /unsupported dollar-quoted body/,
106
+ );
107
+ });
108
+
109
+ test("throws fail-loud on a tagged dollar-quoted body ($tag$...$tag$)", () => {
110
+ expect(() => splitSqlStatements("DO $tag$ BEGIN PERFORM 1; END $tag$;")).toThrow(
111
+ /unsupported dollar-quoted body/,
112
+ );
113
+ });
114
+
115
+ test("a bare $ not opening a dollar-tag does not false-positive (digit after $ is not a tag)", () => {
116
+ expect(splitSqlStatements("SELECT $1;")).toEqual(["SELECT $1;"]);
117
+ });
102
118
  });
@@ -1,46 +1,37 @@
1
- // Sofortiges Blind-Index-Nulling nach einem Subject-Erase (#818).
1
+ // Immediate blind-index nulling after a subject erase (#818).
2
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.
3
+ // After kms.eraseKey the ciphertext is unreadable, but the deterministic
4
+ // bidx column would stay matchable until the next write/rebuilda
5
+ // linkage window ("does any row hold value X"). This sweep closes it right
6
+ // away: the ciphertext names its subject inline
7
+ // (kumiko-pii:v1:<subjectKey>:...), so a LIKE-prefix match finds exactly
8
+ // the erased subject's rowsone UPDATE per lookupable field.
9
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).
10
+ // Rows the forget run deletes/anonymizes via the executor anyway get their
11
+ // bidx recomputed automatically there; this sweep covers the rows left
12
+ // behind (foreign entities with userOwned fields).
13
13
 
14
14
  import { collectLookupableFields } from "../crypto/blind-index";
15
+ import { quoteIdent, subjectCiphertextLikePattern } from "../crypto/ciphertext-pattern";
15
16
  import type { FeatureDefinition } from "../engine/types";
16
17
  import { toSnakeCase } from "../utils/case";
17
18
  import type { DbRunner } from "./connection";
18
19
  import { resolveTableName } from "./entity-table-meta";
19
20
  import { executeRawQuery } from "./queries/raw-sql";
20
21
 
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
22
  export async function nullBlindIndexesForSubject(
30
23
  db: DbRunner,
31
24
  features: ReadonlyMap<string, FeatureDefinition>,
32
25
  subjectKey: string,
33
26
  ): Promise<void> {
34
- // "v%" matches any format version (v1 no-AAD, v2 AAD-bound, #1263) — the
35
- // subject key placement is stable across versions.
36
- const likePattern = `kumiko-pii:v%:${escapeLikePattern(subjectKey)}:%`;
27
+ const likePattern = subjectCiphertextLikePattern(subjectKey);
37
28
  for (const feature of features.values()) {
38
29
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
39
30
  const lookupable = collectLookupableFields(entity);
40
31
  if (lookupable.length === 0) continue;
41
- // Kein featureName-Prefixder Dispatcher baut Entity-Tables ohne
42
- // (buildEntityTable ohne featureName-Option), der Sweep muss dieselben
43
- // Namen treffen.
32
+ // No featureName prefix the dispatcher builds entity tables without
33
+ // one (buildEntityTable with no featureName option), the sweep has to
34
+ // hit the same names.
44
35
  const tableName = resolveTableName(entityName, entity, undefined);
45
36
  for (const fieldName of lookupable) {
46
37
  const snake = toSnakeCase(fieldName);
@@ -95,25 +95,86 @@ export function collectReferenceFields(entity: EntityDefinition): readonly Refer
95
95
  // event-store-executor-context's decryptForRead ordering: PII is the outer
96
96
  // layer, peel it before the envelope-encrypted fields, or the envelope
97
97
  // cipher chokes on a still-PII-wrapped string.
98
+ //
99
+ // Ownership guard (fw#1671): the ref lookup below is tenant-scoped only, not
100
+ // ownership-scoped — this function has no SessionUser to evaluate
101
+ // refEntity.access.read against. If the ref entity declares row-level
102
+ // ownership at all, decrypting here would hand a same-tenant User A the
103
+ // plaintext PII of a User B row they merely reference (e.g. a freely-settable
104
+ // reference UUID), even though refEntity.access.read says "own" — the caller
105
+ // never gets to run that ownership check. Fail closed: strip PII/encrypted
106
+ // fields entirely instead of decrypting (or leaking ciphertext) when that
107
+ // guarantee can't be evaluated here.
108
+ function hasOwnershipScopedRead(refEntity: EntityDefinition): boolean {
109
+ const readMap = refEntity.access?.read;
110
+ if (readMap === undefined) return false;
111
+ // "all" means that role sees every row unrestricted — a map where every
112
+ // rule is "all" carries no ownership restriction at all, so stripping
113
+ // here would just silently drop PII/encrypted fields #1667 wants
114
+ // decrypted, for no security benefit.
115
+ return Object.values(readMap).some((rule) => rule !== "all");
116
+ }
117
+
98
118
  async function decryptReferencedRow(
99
119
  row: Record<string, unknown>,
100
120
  refEntity: EntityDefinition,
121
+ piiFields: readonly string[],
122
+ encryptedFields: ReadonlySet<string>,
123
+ kms: ReturnType<typeof configuredPiiSubjectKms>,
101
124
  ): Promise<Record<string, unknown>> {
125
+ if (hasOwnershipScopedRead(refEntity)) {
126
+ if (piiFields.length === 0 && encryptedFields.size === 0) return row;
127
+ const out = { ...row };
128
+ for (const field of piiFields) delete out[field];
129
+ for (const field of encryptedFields) delete out[field];
130
+ return out;
131
+ }
132
+
102
133
  let out = row;
103
- const piiFields = collectPiiSubjectFields(refEntity);
104
- const kms = configuredPiiSubjectKms();
105
134
  if (piiFields.length > 0 && kms) {
106
135
  out = await decryptPiiFieldValues(out, piiFields, kms, {
107
136
  requestId: requestContext.get()?.requestId ?? "eagerload",
108
137
  });
109
138
  }
110
- const encryptedFields = collectEncryptedFieldNames(refEntity);
111
139
  if (encryptedFields.size > 0) {
112
140
  out = await decryptEntityFieldValues(out, encryptedFields, resolveEntityFieldEncryption());
113
141
  }
114
142
  return out;
115
143
  }
116
144
 
145
+ // Per-row, not Promise.all: a single legacy/backfilled row without a valid
146
+ // envelope (decryptEntityFieldValues throws hard on malformed ciphertext)
147
+ // must not 500 the whole list request — the main rows the caller asked for
148
+ // are unrelated to this one broken reference. Drop just that row from the
149
+ // map; the renderer falls back to the raw UUID.
150
+ //
151
+ // piiFields/encryptedFields/kms are constant per refEntity (fw#1671) — the
152
+ // caller computes them once and passes them in instead of recomputing per row.
153
+ async function buildRefLookupMap(
154
+ rawRefRows: ReadonlyArray<Record<string, unknown>>,
155
+ refEntity: EntityDefinition,
156
+ refEntityName: string,
157
+ piiFields: readonly string[],
158
+ encryptedFields: ReadonlySet<string>,
159
+ kms: ReturnType<typeof configuredPiiSubjectKms>,
160
+ ): Promise<Map<string, Record<string, unknown>>> {
161
+ const map = new Map<string, Record<string, unknown>>();
162
+ for (const r of rawRefRows) {
163
+ let decrypted: Record<string, unknown>;
164
+ try {
165
+ decrypted = await decryptReferencedRow(r, refEntity, piiFields, encryptedFields, kms);
166
+ } catch (e) {
167
+ console.warn(
168
+ `[eagerload] failed to decrypt referenced row entity=${refEntityName} id=${String(r["id"])}: ${e instanceof Error ? e.message : String(e)}`,
169
+ );
170
+ continue;
171
+ }
172
+ const id = decrypted["id"];
173
+ if (typeof id === "string") map.set(id, decrypted);
174
+ }
175
+ return map;
176
+ }
177
+
117
178
  /** Eagerload für eine Liste von Rows. Mutiert nicht — gibt eine
118
179
  * flache Kopie der Rows mit hinzugefügtem `_refs`-Property zurück. */
119
180
  export async function enrichWithReferences(
@@ -159,12 +220,17 @@ export async function enrichWithReferences(
159
220
  const rawRefRows = (await selectMany(db, refTable, { id: idArray })) as Array<
160
221
  Record<string, unknown>
161
222
  >;
162
- const refRows = await Promise.all(rawRefRows.map((r) => decryptReferencedRow(r, refEntity)));
163
- const map = new Map<string, Record<string, unknown>>();
164
- for (const r of refRows) {
165
- const id = r["id"];
166
- if (typeof id === "string") map.set(id, r);
167
- }
223
+ const piiFields = collectPiiSubjectFields(refEntity);
224
+ const encryptedFields = collectEncryptedFieldNames(refEntity);
225
+ const kms = configuredPiiSubjectKms();
226
+ const map = await buildRefLookupMap(
227
+ rawRefRows,
228
+ refEntity,
229
+ rf.refEntityName,
230
+ piiFields,
231
+ encryptedFields,
232
+ kms,
233
+ );
168
234
  return { fieldName: rf.fieldName, multiple: rf.multiple, map };
169
235
  }),
170
236
  );