@cosmicdrift/kumiko-framework 0.173.1 → 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.
- package/package.json +3 -3
- package/src/api/__tests__/request-id-middleware.test.ts +73 -0
- package/src/api/request-id-middleware.ts +13 -2
- package/src/api/sse-broker.ts +7 -3
- package/src/crypto/__tests__/kms-wiring.test.ts +6 -0
- package/src/crypto/kms-wiring.ts +5 -0
- package/src/db/__tests__/eagerload.integration.test.ts +119 -1
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +27 -0
- package/src/db/eagerload.ts +58 -8
- package/src/db/event-store-executor-write.ts +32 -2
- package/src/engine/__tests__/schema-builder.test.ts +12 -0
- package/src/engine/field-access.ts +17 -3
- package/src/engine/schema-builder.ts +4 -1
- package/src/pipeline/dispatch-shared.ts +11 -3
- package/src/pipeline/dispatch-stream.ts +3 -6
- package/src/pipeline/system-hooks.ts +4 -4
- package/src/search/__tests__/reindex-entity.integration.test.ts +97 -1
- package/src/search/reindex-entity.ts +15 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.174.0",
|
|
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.
|
|
185
|
+
"@cosmicdrift/kumiko-types": "0.174.0",
|
|
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.
|
|
201
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.174.0",
|
|
202
202
|
"bun-types": "^1.3.13",
|
|
203
203
|
"pino-pretty": "^13.1.3"
|
|
204
204
|
},
|
|
@@ -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 =
|
|
19
|
-
|
|
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);
|
package/src/api/sse-broker.ts
CHANGED
|
@@ -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
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
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", () => {
|
package/src/crypto/kms-wiring.ts
CHANGED
|
@@ -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,33 @@ 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
|
+
});
|
|
248
275
|
});
|
|
249
276
|
|
|
250
277
|
// =============================================================================
|
package/src/db/eagerload.ts
CHANGED
|
@@ -95,25 +95,80 @@ 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,
|
|
101
121
|
): Promise<Record<string, unknown>> {
|
|
102
|
-
let out = row;
|
|
103
122
|
const piiFields = collectPiiSubjectFields(refEntity);
|
|
123
|
+
const encryptedFields = collectEncryptedFieldNames(refEntity);
|
|
124
|
+
if (hasOwnershipScopedRead(refEntity)) {
|
|
125
|
+
if (piiFields.length === 0 && encryptedFields.size === 0) return row;
|
|
126
|
+
const out = { ...row };
|
|
127
|
+
for (const field of piiFields) delete out[field];
|
|
128
|
+
for (const field of encryptedFields) delete out[field];
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let out = row;
|
|
104
133
|
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
|
+
async function buildRefLookupMap(
|
|
151
|
+
rawRefRows: ReadonlyArray<Record<string, unknown>>,
|
|
152
|
+
refEntity: EntityDefinition,
|
|
153
|
+
refEntityName: string,
|
|
154
|
+
): Promise<Map<string, Record<string, unknown>>> {
|
|
155
|
+
const map = new Map<string, Record<string, unknown>>();
|
|
156
|
+
for (const r of rawRefRows) {
|
|
157
|
+
let decrypted: Record<string, unknown>;
|
|
158
|
+
try {
|
|
159
|
+
decrypted = await decryptReferencedRow(r, refEntity);
|
|
160
|
+
} catch (e) {
|
|
161
|
+
console.warn(
|
|
162
|
+
`[eagerload] failed to decrypt referenced row entity=${refEntityName} id=${String(r["id"])}: ${e instanceof Error ? e.message : String(e)}`,
|
|
163
|
+
);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const id = decrypted["id"];
|
|
167
|
+
if (typeof id === "string") map.set(id, decrypted);
|
|
168
|
+
}
|
|
169
|
+
return map;
|
|
170
|
+
}
|
|
171
|
+
|
|
117
172
|
/** Eagerload für eine Liste von Rows. Mutiert nicht — gibt eine
|
|
118
173
|
* flache Kopie der Rows mit hinzugefügtem `_refs`-Property zurück. */
|
|
119
174
|
export async function enrichWithReferences(
|
|
@@ -159,12 +214,7 @@ export async function enrichWithReferences(
|
|
|
159
214
|
const rawRefRows = (await selectMany(db, refTable, { id: idArray })) as Array<
|
|
160
215
|
Record<string, unknown>
|
|
161
216
|
>;
|
|
162
|
-
const
|
|
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
|
-
}
|
|
217
|
+
const map = await buildRefLookupMap(rawRefRows, refEntity, rf.refEntityName);
|
|
168
218
|
return { fieldName: rf.fieldName, multiple: rf.multiple, map };
|
|
169
219
|
}),
|
|
170
220
|
);
|
|
@@ -133,7 +133,20 @@ export function createWriteVerbs(
|
|
|
133
133
|
// Field-level write-ownership on create — mirror of entity-level but
|
|
134
134
|
// per declared field. Role-level was already checked by the
|
|
135
135
|
// dispatcher; here we enforce ownership-rules against the new row.
|
|
136
|
-
|
|
136
|
+
//
|
|
137
|
+
// Which fields get checked is scoped to the pre-hook payload (fw#1685)
|
|
138
|
+
// — a hook-derived field the user never submitted must not be
|
|
139
|
+
// field-ownership-checked against the user. The rule for a checked
|
|
140
|
+
// field is still evaluated against the full post-hook row (`data`) —
|
|
141
|
+
// an ownership rule can reference a column only a hook populates
|
|
142
|
+
// (kumiko-framework#1672).
|
|
143
|
+
const fieldDeniedCreate = checkWriteFieldOwnership(
|
|
144
|
+
entity,
|
|
145
|
+
applyDefaults(payloadWithoutId),
|
|
146
|
+
user,
|
|
147
|
+
undefined,
|
|
148
|
+
data,
|
|
149
|
+
);
|
|
137
150
|
if (fieldDeniedCreate) {
|
|
138
151
|
return writeFailure(
|
|
139
152
|
new UnprocessableError("ownership_denied", {
|
|
@@ -301,7 +314,24 @@ export function createWriteVerbs(
|
|
|
301
314
|
// `previous`, we can run the ownership rules per field against both
|
|
302
315
|
// sides and reject individual fields the user isn't entitled to
|
|
303
316
|
// touch on this specific row.
|
|
304
|
-
|
|
317
|
+
//
|
|
318
|
+
// Which fields get checked is scoped to `payload.changes` (the user's
|
|
319
|
+
// actual submission), NOT `changes` (post-preSave-hook data, fw#1685)
|
|
320
|
+
// — a hook-derived field the user never submitted (e.g. a system hook
|
|
321
|
+
// setting `assignedTo`) has no business being field-ownership-checked
|
|
322
|
+
// against the *user*, and doing so rejects writes the user is fully
|
|
323
|
+
// entitled to make. The dispatcher's role-gate (checkWriteFieldAccess)
|
|
324
|
+
// already runs on this same pre-hook payload for consistency. The rule
|
|
325
|
+
// for a checked field is still evaluated against the full post-hook
|
|
326
|
+
// row (`changes` merged onto `previous`) — an ownership rule can
|
|
327
|
+
// reference a column only a hook populates (kumiko-framework#1672).
|
|
328
|
+
const fieldDeniedUpdate = checkWriteFieldOwnership(
|
|
329
|
+
entity,
|
|
330
|
+
payload.changes,
|
|
331
|
+
user,
|
|
332
|
+
previous,
|
|
333
|
+
changes,
|
|
334
|
+
);
|
|
305
335
|
if (fieldDeniedUpdate) {
|
|
306
336
|
return writeFailure(
|
|
307
337
|
new UnprocessableError("ownership_denied", {
|
|
@@ -131,6 +131,18 @@ describe("buildInsertSchema", () => {
|
|
|
131
131
|
valid: { age: 5 },
|
|
132
132
|
invalid: { age: 11 },
|
|
133
133
|
},
|
|
134
|
+
{
|
|
135
|
+
name: "integer field rejects a value outside Postgres int4 range (must 400, not crash the DB write)",
|
|
136
|
+
fields: { attempt: createNumberField({ integer: true }) },
|
|
137
|
+
valid: { attempt: 2147483647 },
|
|
138
|
+
invalid: { attempt: 2147483648 },
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: "integer field with explicit max narrower than int4 still enforces the explicit bound",
|
|
142
|
+
fields: { displayOrder: createNumberField({ integer: true, max: 100 }) },
|
|
143
|
+
valid: { displayOrder: 100 },
|
|
144
|
+
invalid: { displayOrder: 101 },
|
|
145
|
+
},
|
|
134
146
|
{
|
|
135
147
|
name: "date field",
|
|
136
148
|
fields: { born: createDateField() },
|
|
@@ -98,15 +98,29 @@ export function checkWriteFieldRoles(
|
|
|
98
98
|
// row. For creates, pass oldRow = undefined; the check degenerates to a
|
|
99
99
|
// newRow-only evaluation.
|
|
100
100
|
//
|
|
101
|
+
// `submittedChanges` and `rowContext` are deliberately separate (fw#1685):
|
|
102
|
+
// - `submittedChanges` drives WHICH fields get checked — only what the user
|
|
103
|
+
// actually wrote in the request. A preSave-hook-derived field the user
|
|
104
|
+
// never touched (e.g. a system hook setting `assignedTo`) must not be
|
|
105
|
+
// field-ownership-checked against the user at all.
|
|
106
|
+
// - `rowContext` drives what a checked field's rule is evaluated AGAINST —
|
|
107
|
+
// this needs the full post-hook row, because an ownership rule can
|
|
108
|
+
// reference a DIFFERENT column that only a hook populates (kumiko-
|
|
109
|
+
// framework#1672: a hook derives `authorId`, a user-submitted `secretNote`
|
|
110
|
+
// field's rule is `from("user:id", "authorId")` — the check needs the
|
|
111
|
+
// hook-derived `authorId` in scope even though the user only wrote
|
|
112
|
+
// `secretNote`). Defaults to `submittedChanges` when omitted.
|
|
113
|
+
//
|
|
101
114
|
// Returns the denied field name for the caller to wrap into an
|
|
102
115
|
// `ownership_denied` error with scope: "field", or null if all fields pass.
|
|
103
116
|
export function checkWriteFieldOwnership(
|
|
104
117
|
entity: EntityDefinition,
|
|
105
|
-
|
|
118
|
+
submittedChanges: Readonly<Record<string, unknown>>,
|
|
106
119
|
user: SessionUser,
|
|
107
120
|
oldRow?: Readonly<Record<string, unknown>>,
|
|
121
|
+
rowContext: Readonly<Record<string, unknown>> = submittedChanges,
|
|
108
122
|
): string | null {
|
|
109
|
-
for (const key of Object.keys(
|
|
123
|
+
for (const key of Object.keys(submittedChanges)) {
|
|
110
124
|
const field = entity.fields[key];
|
|
111
125
|
if (!field) continue;
|
|
112
126
|
|
|
@@ -120,7 +134,7 @@ export function checkWriteFieldOwnership(
|
|
|
120
134
|
const hasOwnershipRule = Object.values(accessMap).some((r) => r !== "all");
|
|
121
135
|
if (!hasOwnershipRule) continue;
|
|
122
136
|
|
|
123
|
-
const newRow: Record<string, unknown> = { ...(oldRow ?? {}), ...
|
|
137
|
+
const newRow: Record<string, unknown> = { ...(oldRow ?? {}), ...rowContext };
|
|
124
138
|
const effectiveOld = oldRow ?? newRow; // create: compare against newRow
|
|
125
139
|
|
|
126
140
|
if (!userCanWriteFieldRow(user, accessMap, effectiveOld, newRow)) {
|
|
@@ -109,7 +109,10 @@ export function fieldToZod(field: FieldDefinition, currencies: readonly string[]
|
|
|
109
109
|
}
|
|
110
110
|
case "number": {
|
|
111
111
|
let schema = z.number();
|
|
112
|
-
|
|
112
|
+
// `integer: true` maps to a Postgres int4 column (entity-table-meta.ts)
|
|
113
|
+
// — bound it here so an out-of-range write fails loud (400) at the
|
|
114
|
+
// schema boundary instead of dying in Postgres (22003 → 500).
|
|
115
|
+
if (field.integer) schema = schema.int().min(-2147483648).max(2147483647);
|
|
113
116
|
if (field.min !== undefined) schema = schema.min(field.min);
|
|
114
117
|
if (field.max !== undefined) schema = schema.max(field.max);
|
|
115
118
|
return field.default !== undefined ? schema.default(field.default) : schema;
|
|
@@ -60,7 +60,7 @@ import {
|
|
|
60
60
|
observabilityContext,
|
|
61
61
|
} from "../observability";
|
|
62
62
|
import { buildBucketKey } from "../rate-limit";
|
|
63
|
-
import { createTzContext } from "../time";
|
|
63
|
+
import { createTzContext, isValidIanaTimeZone } from "../time";
|
|
64
64
|
import { appendDomainEventCore } from "./append-event-core";
|
|
65
65
|
import { resolveAuthClaims as runAuthClaimsResolver } from "./auth-claims-resolver";
|
|
66
66
|
import { executeQuery } from "./dispatch-query";
|
|
@@ -523,10 +523,18 @@ export async function buildHandlerContext(
|
|
|
523
523
|
// tenant (createTzContext's own default). An app-injected GeoTzProvider
|
|
524
524
|
// (context.geoTzProvider) feeds ctx.tz.fromCoordinates / fromAddress.
|
|
525
525
|
const tenantTz = config !== undefined ? await config("tenant:config:timezone") : undefined;
|
|
526
|
+
// Guarded against garbage: an unvalidated string here (free-form config
|
|
527
|
+
// key, legacy JWT claim predating validation) blows up every ctx.tz call
|
|
528
|
+
// for the whole tenant with a RangeError. Fall back to UTC/tenant instead
|
|
529
|
+
// of trusting the raw value.
|
|
530
|
+
const safeTenantTz =
|
|
531
|
+
typeof tenantTz === "string" && isValidIanaTimeZone(tenantTz) ? tenantTz : "UTC";
|
|
532
|
+
const safeUserTz =
|
|
533
|
+
user.timezone !== undefined && isValidIanaTimeZone(user.timezone) ? user.timezone : undefined;
|
|
526
534
|
const tz = createTzContext({
|
|
527
535
|
...(context.geoTzProvider !== undefined ? { geoTz: context.geoTzProvider } : {}),
|
|
528
|
-
tenant:
|
|
529
|
-
...(
|
|
536
|
+
tenant: safeTenantTz,
|
|
537
|
+
...(safeUserTz !== undefined && { user: safeUserTz }),
|
|
530
538
|
});
|
|
531
539
|
|
|
532
540
|
return {
|
|
@@ -59,12 +59,9 @@ async function* executeStreamInner(
|
|
|
59
59
|
const invalidated = new Promise<void>((resolve) => {
|
|
60
60
|
resolveInvalidated = resolve;
|
|
61
61
|
});
|
|
62
|
-
const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
resolveInvalidated?.();
|
|
66
|
-
},
|
|
67
|
-
);
|
|
62
|
+
const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation(user.id, () => {
|
|
63
|
+
resolveInvalidated?.();
|
|
64
|
+
});
|
|
68
65
|
|
|
69
66
|
let iterator: AsyncIterator<unknown> | undefined;
|
|
70
67
|
// When access is revoked mid-pull, `iterator.next()` is still in flight.
|
|
@@ -94,7 +94,7 @@ export function createSearchEventConsumer(
|
|
|
94
94
|
// #1610 — subject-annotated searchable fields are ciphertext in the event
|
|
95
95
|
// payload; decrypt into the derived index only. No KMS → omit ciphertext
|
|
96
96
|
// values rather than indexing blobs.
|
|
97
|
-
async function decryptSearchableSubjectFields(
|
|
97
|
+
export async function decryptSearchableSubjectFields(
|
|
98
98
|
entityName: string,
|
|
99
99
|
state: Record<string, unknown>,
|
|
100
100
|
registry: Registry,
|
|
@@ -116,7 +116,7 @@ async function decryptSearchableSubjectFields(
|
|
|
116
116
|
});
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
function hasErasedSearchableSubjectField(
|
|
119
|
+
export function hasErasedSearchableSubjectField(
|
|
120
120
|
entityName: string,
|
|
121
121
|
state: Record<string, unknown>,
|
|
122
122
|
registry: Registry,
|
|
@@ -416,7 +416,7 @@ export function createAccessInvalidationEventConsumer(sseBroker: SseBroker): Eve
|
|
|
416
416
|
// poison would otherwise permanently stop access-invalidation for
|
|
417
417
|
// every user behind one bad row).
|
|
418
418
|
if (typeof userId !== "string" || userId.length === 0) return;
|
|
419
|
-
sseBroker.publishAccessInvalidation
|
|
419
|
+
sseBroker.publishAccessInvalidation(userId);
|
|
420
420
|
}
|
|
421
421
|
|
|
422
422
|
if (
|
|
@@ -427,7 +427,7 @@ export function createAccessInvalidationEventConsumer(sseBroker: SseBroker): Eve
|
|
|
427
427
|
// skip: previous snapshot missing/malformed userId — same fail-open
|
|
428
428
|
// reasoning as above.
|
|
429
429
|
if (userId === undefined) return;
|
|
430
|
-
sseBroker.publishAccessInvalidation
|
|
430
|
+
sseBroker.publishAccessInvalidation(userId);
|
|
431
431
|
}
|
|
432
432
|
},
|
|
433
433
|
};
|
|
@@ -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 {
|
|
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
|
|
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) {
|