@cosmicdrift/kumiko-framework 0.273.0 → 0.274.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/changes.json +21 -7
- package/src/db/__tests__/build-filter-where.test.ts +8 -0
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +69 -0
- package/src/db/__tests__/event-store-executor-reference-search.integration.test.ts +266 -2
- package/src/db/entity-field-encryption.ts +18 -0
- package/src/db/event-store-executor-context.ts +7 -3
- package/src/db/event-store-executor-read.ts +120 -34
- package/src/engine/__tests__/boot-validator.test.ts +20 -1
- package/src/engine/__tests__/engine.test.ts +86 -0
- package/src/engine/__tests__/screen-filter-ops.test.ts +1 -1
- package/src/engine/boot-validator/projection-list-screens.ts +2 -2
- package/src/engine/entity-handlers.ts +3 -3
- package/src/engine/registry-validate.ts +24 -2
- package/src/engine/screen-filter-ops.ts +10 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.274.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>",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-types": "0.274.0",
|
|
202
202
|
"bullmq": "^5.76.7",
|
|
203
203
|
"bun-types": "^1.3.13",
|
|
204
204
|
"hono": "^4.13.1",
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
"zod": "^4.4.3"
|
|
215
215
|
},
|
|
216
216
|
"devDependencies": {
|
|
217
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
217
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.274.0",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,25 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.274.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "A searchable reference field targeting an encrypted/PII labelField now fails boot unless that label is itself searchable.",
|
|
6
|
+
"detail": "A searchable reference field can now match a search term against a target row's label even when that label is encrypted or personal data — the match runs through the target entity's own search index instead of the plain-text lookup used for a readable label, which could never match ciphertext and always returned zero matches. As with the plain-text lookup, an unusually large number of index matches drops the match clause instead of silently returning an incomplete page. A matched target row is also checked against the target entity's own read access for the current user, so a match can never surface a row the user could not otherwise see.",
|
|
7
|
+
"migration": "A searchable reference field whose target label is encrypted or personal data, but is not itself marked searchable (with fuzzy matching enabled if it also carries a personal-data annotation), now fails to boot instead of silently never matching at request time. Mark the target's label field searchable — with fuzzy matching if it holds personal data, so it is indexed for search — or remove the searchable flag from the reference field if the label was never meant to be searched."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.274.0",
|
|
11
|
+
"type": "improvement",
|
|
12
|
+
"title": "Entity-list filters gain lte/gte comparison operators.",
|
|
13
|
+
"detail": "Entity-list filters — a screen's static filter, its faceted filters, and any handler built on the entity-list executor — now accept `lte` and `gte` alongside the existing `eq`/`ne`/`lt`/`gt`/`in`, so a \"valid as of today or earlier\" filter can be applied server-side instead of loading every row and filtering in memory. Allowed on the same field types as `lt`/`gt` (number/money/decimal/date/timestamp/locatedTimestamp) — the boot validator rejects them elsewhere the same way it already rejects `lt`/`gt`.",
|
|
14
|
+
"migration": "No action required — purely additive; existing filters and their allowed operators are unchanged."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "0.274.0",
|
|
18
|
+
"type": "improvement",
|
|
19
|
+
"title": "Entity create handlers accept a caller-chosen id for idempotent creates from jobs/hooks.",
|
|
20
|
+
"detail": "`buildInsertSchema` — and every write handler built through `defineEntityCreateHandler`/`defineEntityWriteHandler`'s `create` verb — now accepts an optional `id` field validated as a UUID string. The create handler only honors it when the acting `SessionUser` is a system identity (`createSystemUser(tenantId, extraRoles)`; `extraRoles` is still needed to clear the handler's normal `hasAccess` role gate, and carrying `SYSTEM_ROLE` is what `isSystemIdentity` checks). An HTTP-authenticated end user's payload `id` is silently ignored and a fresh `generateId()` is minted, exactly as before this field existed — `SYSTEM_ROLE` is reserved and never assignable through tenant membership, so an ordinary caller can never present that identity. `executor.create()` already honored an explicit `id` (the seed/SCIM-import pattern); this only stops the generic create-handler schema from stripping it for a trusted caller. A same-id retry within the same tenant returns `version_conflict` (no duplicate row, no overwrite). A same id already used by a different tenant returns `unique_violation` instead — every entity's projection table has a plain `id` primary key that is not tenant-scoped, so the id space is shared per entity table across tenants; a deterministic id must fold the tenant id into its key to stay collision-free. New `generateDeterministicId(namespace, key)` (`@cosmicdrift/kumiko-framework/utils`, UUIDv5) sits next to `generateId()` for deriving such an id. The agent-tool manifest now lists `id` as an optional parameter on every entity's create tool; supplying it has no effect for a non-system caller, since agent-tool dispatch always passes the real caller's identity, never a system one.",
|
|
21
|
+
"migration": "No action for existing callers — the field is additive and inert for anyone but a system-identity caller, so no existing create request changes behavior. To create idempotently from an event-triggered job or hook: derive an id with `generateDeterministicId(namespace, key)` (fold the tenant id into `key` so two tenants can't collide), dispatch via `ctx.writeAs(createSystemUser(tenantId, [<a role your handler's access rule allows>]), qn, { ...payload, id })` instead of matching on description text or other business fields, and treat a `version_conflict` response as \"already created\"."
|
|
22
|
+
},
|
|
2
23
|
{
|
|
3
24
|
"version": "0.272.0",
|
|
4
25
|
"type": "breaking",
|
|
@@ -27,13 +48,6 @@
|
|
|
27
48
|
"migration": "Replace `crossTenant: true` with `escapeHatch: { reason: \"...\" }` on the same entity-convention handler call. Run `bun packages/framework/src/scripts/codemod/migrate-cross-tenant.ts [--dry-run] <path...>` first — it rewrites every `defineEntity*Handler`/`defineEntityWriteHandler`/`defineEntityQueryHandler` call whose entity name and verb it can read off the call itself, with a generated reason to replace with the concrete operator use case. Sites it lists as manual (a `crossTenant` on a shared/spread access object, a `registerEntityCrud` `write`/`read` block covering several verbs, a non-boolean-literal value, or an object that already declares `escapeHatch`) need a hand-written `escapeHatch: { reason }` per handler/verb.",
|
|
28
49
|
"codemod": "scripts/codemod/migrate-cross-tenant.ts"
|
|
29
50
|
},
|
|
30
|
-
{
|
|
31
|
-
"version": "0.271.0",
|
|
32
|
-
"type": "improvement",
|
|
33
|
-
"title": "Entity create handlers accept a caller-chosen id for idempotent creates from jobs/hooks.",
|
|
34
|
-
"detail": "`buildInsertSchema` — and every write handler built through `defineEntityCreateHandler`/`defineEntityWriteHandler`'s `create` verb — now accepts an optional `id` field validated as a UUID string. The create handler only honors it when the acting `SessionUser` is a system identity (`createSystemUser(tenantId, extraRoles)`; `extraRoles` is still needed to clear the handler's normal `hasAccess` role gate, and carrying `SYSTEM_ROLE` is what `isSystemIdentity` checks). An HTTP-authenticated end user's payload `id` is silently ignored and a fresh `generateId()` is minted, exactly as before this field existed — `SYSTEM_ROLE` is reserved and never assignable through tenant membership, so an ordinary caller can never present that identity. `executor.create()` already honored an explicit `id` (the seed/SCIM-import pattern); this only stops the generic create-handler schema from stripping it for a trusted caller. A same-id retry within the same tenant returns `version_conflict` (no duplicate row, no overwrite). A same id already used by a different tenant returns `unique_violation` instead — every entity's projection table has a plain `id` primary key that is not tenant-scoped, so the id space is shared per entity table across tenants; a deterministic id must fold the tenant id into its key to stay collision-free. New `generateDeterministicId(namespace, key)` (`@cosmicdrift/kumiko-framework/utils`, UUIDv5) sits next to `generateId()` for deriving such an id. The agent-tool manifest now lists `id` as an optional parameter on every entity's create tool; supplying it has no effect for a non-system caller, since agent-tool dispatch always passes the real caller's identity, never a system one.",
|
|
35
|
-
"migration": "No action for existing callers — the field is additive and inert for anyone but a system-identity caller, so no existing create request changes behavior. To create idempotently from an event-triggered job or hook: derive an id with `generateDeterministicId(namespace, key)` (fold the tenant id into `key` so two tenants can't collide), dispatch via `ctx.writeAs(createSystemUser(tenantId, [<a role your handler's access rule allows>]), qn, { ...payload, id })` instead of matching on description text or other business fields, and treat a `version_conflict` response as \"already created\"."
|
|
36
|
-
},
|
|
37
51
|
{
|
|
38
52
|
"version": "0.271.0",
|
|
39
53
|
"type": "breaking",
|
|
@@ -26,6 +26,14 @@ describe("buildFilterWhere", () => {
|
|
|
26
26
|
expect(buildFilterWhere("createdAt", "gt", 100)).toEqual({ createdAt: { gt: 100 } });
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
+
test("lte: wraps the value in a { lte } clause", () => {
|
|
30
|
+
expect(buildFilterWhere("createdAt", "lte", 100)).toEqual({ createdAt: { lte: 100 } });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("gte: wraps the value in a { gte } clause", () => {
|
|
34
|
+
expect(buildFilterWhere("createdAt", "gte", 100)).toEqual({ createdAt: { gte: 100 } });
|
|
35
|
+
});
|
|
36
|
+
|
|
29
37
|
test("in: non-empty array → direct array WhereObject", () => {
|
|
30
38
|
expect(buildFilterWhere("status", "in", ["active", "pending"])).toEqual({
|
|
31
39
|
status: ["active", "pending"],
|
|
@@ -283,6 +283,75 @@ describe("event-store-executor.list — filter (Tier 2.7c)", () => {
|
|
|
283
283
|
expect(res.rows.map((r) => r["rank"])).toEqual([8, 9]);
|
|
284
284
|
});
|
|
285
285
|
|
|
286
|
+
test("filter lte: rank <= 3 → 0,1,2,3 (boundary included)", async () => {
|
|
287
|
+
await seed(6);
|
|
288
|
+
const res = await exec.list(
|
|
289
|
+
{
|
|
290
|
+
limit: 50,
|
|
291
|
+
sort: "rank",
|
|
292
|
+
sortDirection: "asc",
|
|
293
|
+
filter: { field: "rank", op: "lte", value: 3 },
|
|
294
|
+
},
|
|
295
|
+
admin,
|
|
296
|
+
tdb,
|
|
297
|
+
);
|
|
298
|
+
expect(res.rows.map((r) => r["rank"])).toEqual([0, 1, 2, 3]);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("filter gte: rank >= 7 → 7,8,9 (boundary included)", async () => {
|
|
302
|
+
await seed(10);
|
|
303
|
+
const res = await exec.list(
|
|
304
|
+
{
|
|
305
|
+
limit: 50,
|
|
306
|
+
sort: "rank",
|
|
307
|
+
sortDirection: "asc",
|
|
308
|
+
filter: { field: "rank", op: "gte", value: 7 },
|
|
309
|
+
},
|
|
310
|
+
admin,
|
|
311
|
+
tdb,
|
|
312
|
+
);
|
|
313
|
+
expect(res.rows.map((r) => r["rank"])).toEqual([7, 8, 9]);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("filter lte on dueDate: boundary date included", async () => {
|
|
317
|
+
await exec.create({ title: "d-1", dueDate: "2026-01-10" }, admin, tdb);
|
|
318
|
+
await exec.create({ title: "d-2", dueDate: "2026-02-10" }, admin, tdb);
|
|
319
|
+
await exec.create({ title: "d-3", dueDate: "2026-03-10" }, admin, tdb);
|
|
320
|
+
const res = await exec.list(
|
|
321
|
+
{
|
|
322
|
+
limit: 50,
|
|
323
|
+
sort: "dueDate",
|
|
324
|
+
sortDirection: "asc",
|
|
325
|
+
filter: { field: "dueDate", op: "lte", value: "2026-02-10" },
|
|
326
|
+
},
|
|
327
|
+
admin,
|
|
328
|
+
tdb,
|
|
329
|
+
);
|
|
330
|
+
expect(res.rows.map((r) => r["title"])).toEqual(["d-1", "d-2"]);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("filters[] gte+lte AND, kombiniert mit offset-Paging: zweite Seite korrekt", async () => {
|
|
334
|
+
await seed(10);
|
|
335
|
+
const res = await exec.list(
|
|
336
|
+
{
|
|
337
|
+
limit: 2,
|
|
338
|
+
offset: 2,
|
|
339
|
+
sort: "rank",
|
|
340
|
+
sortDirection: "asc",
|
|
341
|
+
totalCount: true,
|
|
342
|
+
filters: [
|
|
343
|
+
{ field: "rank", op: "gte", value: 2 },
|
|
344
|
+
{ field: "rank", op: "lte", value: 7 },
|
|
345
|
+
],
|
|
346
|
+
},
|
|
347
|
+
admin,
|
|
348
|
+
tdb,
|
|
349
|
+
);
|
|
350
|
+
// range gte:2/lte:7 → [2,3,4,5,6,7] (6 rows), page size 2 offset 2 → [4,5]
|
|
351
|
+
expect(res.rows.map((r) => r["rank"])).toEqual([4, 5]);
|
|
352
|
+
expect(res.total).toBe(6);
|
|
353
|
+
});
|
|
354
|
+
|
|
286
355
|
test("filter in: rank in [1,3,5]", async () => {
|
|
287
356
|
await seed(10);
|
|
288
357
|
const res = await exec.list(
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
10
10
|
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
|
11
11
|
import { asRawClient } from "../../db/query";
|
|
12
|
-
import { createEntity, createTextField } from "../../engine";
|
|
12
|
+
import { createEntity, createTextField, from } from "../../engine";
|
|
13
13
|
import type { EntityDefinition } from "../../engine/types";
|
|
14
14
|
import { createEventsTable } from "../../event-store";
|
|
15
15
|
import { createInMemorySearchAdapter } from "../../search";
|
|
@@ -70,6 +70,45 @@ const tenantMetaEntity = createEntity({
|
|
|
70
70
|
});
|
|
71
71
|
const tenantMetaTable = buildEntityTable("tenant", tenantMetaEntity);
|
|
72
72
|
|
|
73
|
+
// PII target with a derived search index — ciphertext at rest, plaintext
|
|
74
|
+
// mirrored into the adapter; `ownerId` + `access.read` gives a real row-owned rule.
|
|
75
|
+
const piiCustomerEntity = createEntity({
|
|
76
|
+
table: "read_ref_search_pii_customers",
|
|
77
|
+
fields: {
|
|
78
|
+
ownerId: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
79
|
+
name: createTextField({ required: true, personal: "self", find: "fuzzy" }),
|
|
80
|
+
},
|
|
81
|
+
access: {
|
|
82
|
+
read: { Admin: "all", User: from("user:id", "ownerId") },
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
const piiCustomerTable = buildEntityTable("refSearchPiiCustomer", piiCustomerEntity);
|
|
86
|
+
|
|
87
|
+
const piiOrderEntity = createEntity({
|
|
88
|
+
table: "read_ref_search_pii_orders",
|
|
89
|
+
fields: {
|
|
90
|
+
note: createTextField({ searchable: true, personal: false, reason: "test_fixture" }),
|
|
91
|
+
customerId: {
|
|
92
|
+
type: "reference",
|
|
93
|
+
entity: "refSearchPiiCustomer",
|
|
94
|
+
labelField: "name",
|
|
95
|
+
searchable: true,
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
const piiOrderTable = buildEntityTable("refSearchPiiOrder", piiOrderEntity);
|
|
100
|
+
|
|
101
|
+
function resolvePiiEntity(name: string): EntityDefinition | undefined {
|
|
102
|
+
return name === "refSearchPiiCustomer" ? piiCustomerEntity : undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const piiReferenceSearch = {
|
|
106
|
+
fields: [
|
|
107
|
+
{ fieldName: "customerId", targetEntityName: "refSearchPiiCustomer", labelField: "name" },
|
|
108
|
+
],
|
|
109
|
+
resolveEntity: resolvePiiEntity,
|
|
110
|
+
};
|
|
111
|
+
|
|
73
112
|
function resolveEntity(name: string): EntityDefinition | undefined {
|
|
74
113
|
return name === "refSearchCustomer" ? customerEntity : undefined;
|
|
75
114
|
}
|
|
@@ -104,6 +143,9 @@ const orderExec = createEventStoreExecutor(orderTable, orderEntity, {
|
|
|
104
143
|
const restrictedOrderExec = createEventStoreExecutor(restrictedOrderTable, restrictedOrderEntity, {
|
|
105
144
|
entityName: "refSearchRestrictedOrder",
|
|
106
145
|
});
|
|
146
|
+
const piiOrderExec = createEventStoreExecutor(piiOrderTable, piiOrderEntity, {
|
|
147
|
+
entityName: "refSearchPiiOrder",
|
|
148
|
+
});
|
|
107
149
|
|
|
108
150
|
beforeAll(async () => {
|
|
109
151
|
await ensureTemporalPolyfill();
|
|
@@ -111,6 +153,8 @@ beforeAll(async () => {
|
|
|
111
153
|
await unsafeCreateEntityTable(testDb.db, customerEntity, "refSearchCustomer");
|
|
112
154
|
await unsafeCreateEntityTable(testDb.db, orderEntity, "refSearchOrder");
|
|
113
155
|
await unsafeCreateEntityTable(testDb.db, restrictedOrderEntity, "refSearchRestrictedOrder");
|
|
156
|
+
await unsafeCreateEntityTable(testDb.db, piiCustomerEntity, "refSearchPiiCustomer");
|
|
157
|
+
await unsafeCreateEntityTable(testDb.db, piiOrderEntity, "refSearchPiiOrder");
|
|
114
158
|
await unsafeCreateEntityTable(testDb.db, tenantMetaEntity, "tenant");
|
|
115
159
|
await createEventsTable(testDb.db);
|
|
116
160
|
tdbA = createTenantDb(testDb.db, admin.tenantId);
|
|
@@ -124,7 +168,8 @@ afterAll(async () => {
|
|
|
124
168
|
beforeEach(async () => {
|
|
125
169
|
await asRawClient(testDb.db).unsafe(
|
|
126
170
|
"TRUNCATE kumiko_events, read_ref_search_customers, read_ref_search_orders, " +
|
|
127
|
-
"read_ref_search_restricted_orders,
|
|
171
|
+
"read_ref_search_restricted_orders, read_ref_search_pii_customers, " +
|
|
172
|
+
"read_ref_search_pii_orders, read_tenants RESTART IDENTITY CASCADE",
|
|
128
173
|
);
|
|
129
174
|
});
|
|
130
175
|
|
|
@@ -379,3 +424,222 @@ describe("event-store-executor.list — searchable reference fields (fw#2660)",
|
|
|
379
424
|
}
|
|
380
425
|
});
|
|
381
426
|
});
|
|
427
|
+
|
|
428
|
+
describe("event-store-executor.list — searchable reference to an encrypted/PII labelField via the search index", () => {
|
|
429
|
+
test('matches via the target\'s derived search index when the labelField is PII (find: "fuzzy")', async () => {
|
|
430
|
+
const [contact] = await seedRows(testDb.db, piiCustomerTable, [
|
|
431
|
+
{
|
|
432
|
+
id: crypto.randomUUID(),
|
|
433
|
+
tenantId: admin.tenantId,
|
|
434
|
+
ownerId: admin.id,
|
|
435
|
+
name: "Confidential Contact",
|
|
436
|
+
},
|
|
437
|
+
]);
|
|
438
|
+
const [order] = await seedRows(testDb.db, piiOrderTable, [
|
|
439
|
+
{
|
|
440
|
+
id: crypto.randomUUID(),
|
|
441
|
+
tenantId: admin.tenantId,
|
|
442
|
+
note: "totally unrelated",
|
|
443
|
+
customerId: (contact as { id: string }).id,
|
|
444
|
+
},
|
|
445
|
+
]);
|
|
446
|
+
|
|
447
|
+
const searchAdapter = createInMemorySearchAdapter();
|
|
448
|
+
// One shared per-tenant index across every entity type (real Meilisearch
|
|
449
|
+
// adapter parity: one index per tenant, `_type` is only a filter
|
|
450
|
+
// attribute) — `searchableFields` must list every entity's own
|
|
451
|
+
// searchable field, not just this test's own entity's.
|
|
452
|
+
await searchAdapter.configure(admin.tenantId, { searchableFields: ["note", "name"] });
|
|
453
|
+
// Plaintext label lives in the search adapter under the target's own
|
|
454
|
+
// type — never a raw ILIKE scan of the (encrypted-at-rest) column.
|
|
455
|
+
await searchAdapter.index(admin.tenantId, {
|
|
456
|
+
entityType: "refSearchPiiCustomer",
|
|
457
|
+
entityId: (contact as { id: string }).id,
|
|
458
|
+
weight: 1,
|
|
459
|
+
fields: { name: "Confidential Contact" },
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const res = await piiOrderExec.list({ search: "confidential" }, admin, tdbA, {
|
|
463
|
+
searchAdapter,
|
|
464
|
+
referenceSearch: piiReferenceSearch,
|
|
465
|
+
});
|
|
466
|
+
expect(res.rows.map((r) => r["id"])).toEqual([(order as { id: string }).id]);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("drops the reference clause above 200 search-index matches — native search still applies", async () => {
|
|
470
|
+
const fooCustomers = Array.from({ length: 201 }, (_, i) => ({
|
|
471
|
+
id: crypto.randomUUID(),
|
|
472
|
+
tenantId: admin.tenantId,
|
|
473
|
+
ownerId: admin.id,
|
|
474
|
+
name: `Foo Contact ${i}`,
|
|
475
|
+
}));
|
|
476
|
+
await seedRows(testDb.db, piiCustomerTable, fooCustomers);
|
|
477
|
+
|
|
478
|
+
const [refOnlyOrder, textMatchOrder] = await seedRows(testDb.db, piiOrderTable, [
|
|
479
|
+
{
|
|
480
|
+
id: crypto.randomUUID(),
|
|
481
|
+
tenantId: admin.tenantId,
|
|
482
|
+
note: "no keyword here",
|
|
483
|
+
customerId: fooCustomers[5]?.id,
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
id: crypto.randomUUID(),
|
|
487
|
+
tenantId: admin.tenantId,
|
|
488
|
+
note: "foo appears right here",
|
|
489
|
+
customerId: fooCustomers[10]?.id,
|
|
490
|
+
},
|
|
491
|
+
]);
|
|
492
|
+
|
|
493
|
+
const searchAdapter = createInMemorySearchAdapter();
|
|
494
|
+
await searchAdapter.configure(admin.tenantId, { searchableFields: ["note", "name"] });
|
|
495
|
+
for (const c of fooCustomers) {
|
|
496
|
+
await searchAdapter.index(admin.tenantId, {
|
|
497
|
+
entityType: "refSearchPiiCustomer",
|
|
498
|
+
entityId: c.id,
|
|
499
|
+
weight: 1,
|
|
500
|
+
fields: { name: c.name },
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
for (const o of [refOnlyOrder, textMatchOrder]) {
|
|
504
|
+
const row = o as { id: string; note: string };
|
|
505
|
+
await searchAdapter.index(admin.tenantId, {
|
|
506
|
+
entityType: "refSearchPiiOrder",
|
|
507
|
+
entityId: row.id,
|
|
508
|
+
weight: 1,
|
|
509
|
+
fields: { note: row.note },
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const res = await piiOrderExec.list({ search: "foo" }, admin, tdbA, {
|
|
514
|
+
searchAdapter,
|
|
515
|
+
referenceSearch: piiReferenceSearch,
|
|
516
|
+
});
|
|
517
|
+
expect(res.rows.map((r) => r["id"])).toEqual([(textMatchOrder as { id: string }).id]);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test("a target label match in a foreign tenant's search index never surfaces a row (mandatory)", async () => {
|
|
521
|
+
const [contact] = await seedRows(testDb.db, piiCustomerTable, [
|
|
522
|
+
{
|
|
523
|
+
id: crypto.randomUUID(),
|
|
524
|
+
tenantId: otherTenantAdmin.tenantId,
|
|
525
|
+
ownerId: otherTenantAdmin.id,
|
|
526
|
+
name: "Foreign Contact",
|
|
527
|
+
},
|
|
528
|
+
]);
|
|
529
|
+
await seedRows(testDb.db, piiOrderTable, [
|
|
530
|
+
{
|
|
531
|
+
id: crypto.randomUUID(),
|
|
532
|
+
tenantId: otherTenantAdmin.tenantId,
|
|
533
|
+
note: "unrelated",
|
|
534
|
+
customerId: (contact as { id: string }).id,
|
|
535
|
+
},
|
|
536
|
+
]);
|
|
537
|
+
|
|
538
|
+
const searchAdapter = createInMemorySearchAdapter();
|
|
539
|
+
await searchAdapter.configure(admin.tenantId, { searchableFields: ["note"] });
|
|
540
|
+
// Indexed only under the OTHER tenant's search index — admin's own
|
|
541
|
+
// search must never see it, regardless of the FK row's actual tenantId.
|
|
542
|
+
await searchAdapter.index(otherTenantAdmin.tenantId, {
|
|
543
|
+
entityType: "refSearchPiiCustomer",
|
|
544
|
+
entityId: (contact as { id: string }).id,
|
|
545
|
+
weight: 1,
|
|
546
|
+
fields: { name: "Foreign Contact" },
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
const res = await piiOrderExec.list({ search: "foreign" }, admin, tdbA, {
|
|
550
|
+
searchAdapter,
|
|
551
|
+
referenceSearch: piiReferenceSearch,
|
|
552
|
+
});
|
|
553
|
+
expect(res.rows).toHaveLength(0);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test("row-level ownership on the target entity intersects the search-index candidates, same as the target's own list would", async () => {
|
|
557
|
+
const [ownRow, otherRow] = await seedRows(testDb.db, piiCustomerTable, [
|
|
558
|
+
{
|
|
559
|
+
id: crypto.randomUUID(),
|
|
560
|
+
tenantId: admin.tenantId,
|
|
561
|
+
ownerId: TestUsers.user.id,
|
|
562
|
+
name: "Shared Label",
|
|
563
|
+
},
|
|
564
|
+
{
|
|
565
|
+
id: crypto.randomUUID(),
|
|
566
|
+
tenantId: admin.tenantId,
|
|
567
|
+
ownerId: admin.id,
|
|
568
|
+
name: "Shared Label",
|
|
569
|
+
},
|
|
570
|
+
]);
|
|
571
|
+
const [ownOrder, otherOrder] = await seedRows(testDb.db, piiOrderTable, [
|
|
572
|
+
{
|
|
573
|
+
id: crypto.randomUUID(),
|
|
574
|
+
tenantId: admin.tenantId,
|
|
575
|
+
note: "n1",
|
|
576
|
+
customerId: (ownRow as { id: string }).id,
|
|
577
|
+
},
|
|
578
|
+
{
|
|
579
|
+
id: crypto.randomUUID(),
|
|
580
|
+
tenantId: admin.tenantId,
|
|
581
|
+
note: "n2",
|
|
582
|
+
customerId: (otherRow as { id: string }).id,
|
|
583
|
+
},
|
|
584
|
+
]);
|
|
585
|
+
|
|
586
|
+
const searchAdapter = createInMemorySearchAdapter();
|
|
587
|
+
await searchAdapter.configure(admin.tenantId, { searchableFields: ["note", "name"] });
|
|
588
|
+
for (const row of [ownRow, otherRow]) {
|
|
589
|
+
const r = row as { id: string; name: string };
|
|
590
|
+
await searchAdapter.index(admin.tenantId, {
|
|
591
|
+
entityType: "refSearchPiiCustomer",
|
|
592
|
+
entityId: r.id,
|
|
593
|
+
weight: 1,
|
|
594
|
+
fields: { name: r.name },
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Both customers match "shared" in the index — the search index itself
|
|
599
|
+
// has no ownership awareness. Only the order pointing at the customer
|
|
600
|
+
// TestUsers.user (role "User") actually owns via ownerId may surface.
|
|
601
|
+
const res = await piiOrderExec.list({ search: "shared" }, TestUsers.user, tdbA, {
|
|
602
|
+
searchAdapter,
|
|
603
|
+
referenceSearch: piiReferenceSearch,
|
|
604
|
+
});
|
|
605
|
+
expect(res.rows.map((r) => r["id"])).toEqual([(ownOrder as { id: string }).id]);
|
|
606
|
+
expect(res.rows.map((r) => r["id"])).not.toContain((otherOrder as { id: string }).id);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test("a role with no read grant on the target entity gets no reference-match hit at all", async () => {
|
|
610
|
+
const [contact] = await seedRows(testDb.db, piiCustomerTable, [
|
|
611
|
+
{
|
|
612
|
+
id: crypto.randomUUID(),
|
|
613
|
+
tenantId: admin.tenantId,
|
|
614
|
+
ownerId: admin.id,
|
|
615
|
+
name: "Driver Blind Spot",
|
|
616
|
+
},
|
|
617
|
+
]);
|
|
618
|
+
await seedRows(testDb.db, piiOrderTable, [
|
|
619
|
+
{
|
|
620
|
+
id: crypto.randomUUID(),
|
|
621
|
+
tenantId: admin.tenantId,
|
|
622
|
+
note: "unrelated",
|
|
623
|
+
customerId: (contact as { id: string }).id,
|
|
624
|
+
},
|
|
625
|
+
]);
|
|
626
|
+
|
|
627
|
+
const searchAdapter = createInMemorySearchAdapter();
|
|
628
|
+
await searchAdapter.configure(admin.tenantId, { searchableFields: ["note", "name"] });
|
|
629
|
+
await searchAdapter.index(admin.tenantId, {
|
|
630
|
+
entityType: "refSearchPiiCustomer",
|
|
631
|
+
entityId: (contact as { id: string }).id,
|
|
632
|
+
weight: 1,
|
|
633
|
+
fields: { name: "Driver Blind Spot" },
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
// "Driver" has no rule at all on piiCustomerEntity.access.read →
|
|
637
|
+
// buildOwnershipClause returns "empty" — no reference-match hit, no
|
|
638
|
+
// matter what the search index found.
|
|
639
|
+
const res = await piiOrderExec.list({ search: "driver" }, TestUsers.driver, tdbA, {
|
|
640
|
+
searchAdapter,
|
|
641
|
+
referenceSearch: piiReferenceSearch,
|
|
642
|
+
});
|
|
643
|
+
expect(res.rows).toHaveLength(0);
|
|
644
|
+
});
|
|
645
|
+
});
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// single-key ciphertexts (pre-envelope ENCRYPTION_KEY era) readable via
|
|
5
5
|
// the cipher's legacy fallback until re-encrypted.
|
|
6
6
|
|
|
7
|
+
import { collectPiiSubjectFields } from "../crypto";
|
|
7
8
|
import type { EntityDefinition, TenantId } from "../engine/types";
|
|
8
9
|
import { createEnvMasterKeyProvider } from "../secrets/env-master-key-provider";
|
|
9
10
|
import type { EnvelopeCipher } from "../secrets/envelope-cipher";
|
|
@@ -19,6 +20,23 @@ export function collectEncryptedFieldNames(entity: EntityDefinition): ReadonlySe
|
|
|
19
20
|
return names;
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
// A field whose stored value is ciphertext, either always (encrypted) or
|
|
24
|
+
// depending on runtime KMS wiring (PII) — either way it has no plaintext
|
|
25
|
+
// column to compare against directly.
|
|
26
|
+
export function isSensitiveLabelField(entity: EntityDefinition, field: string): boolean {
|
|
27
|
+
return (
|
|
28
|
+
collectEncryptedFieldNames(entity).has(field) || collectPiiSubjectFields(entity).includes(field)
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// `searchable: true` on a text field means it is indexed plaintext
|
|
33
|
+
// somewhere findable by term — a plain text field's own column, or (for a
|
|
34
|
+
// personal-data field) the derived search index mirroring it.
|
|
35
|
+
export function hasSearchablePlaintext(entity: EntityDefinition, field: string): boolean {
|
|
36
|
+
const def = entity.fields[field];
|
|
37
|
+
return def?.type === "text" && def.searchable === true;
|
|
38
|
+
}
|
|
39
|
+
|
|
22
40
|
// BYOK hook: thread the row's tenant to the cipher when the row carries one
|
|
23
41
|
// (system-scope rows may not).
|
|
24
42
|
function scopeOf(row: Record<string, unknown>): KeyScope | undefined {
|
|
@@ -51,11 +51,11 @@ export type Table = TableColumns<any>;
|
|
|
51
51
|
|
|
52
52
|
// Screen-Filter (Tier 2.7c) — Op-Mapping als Where-Operator. Boot-
|
|
53
53
|
// Validator pinst field-Existenz + filterable + op-vs-Type-Compat.
|
|
54
|
-
// `op` ist auf {eq,ne,lt,gt,in} normalisiert; "in" mit
|
|
55
|
-
// explizit no-match.
|
|
54
|
+
// `op` ist auf {eq,ne,lt,gt,lte,gte,in} normalisiert; "in" mit
|
|
55
|
+
// empty-array ist explizit no-match.
|
|
56
56
|
export function buildFilterWhere(
|
|
57
57
|
field: string,
|
|
58
|
-
op: "eq" | "ne" | "lt" | "gt" | "in",
|
|
58
|
+
op: "eq" | "ne" | "lt" | "gt" | "lte" | "gte" | "in",
|
|
59
59
|
value: unknown,
|
|
60
60
|
): WhereObject | null {
|
|
61
61
|
switch (op) {
|
|
@@ -67,6 +67,10 @@ export function buildFilterWhere(
|
|
|
67
67
|
return { [field]: { lt: value } };
|
|
68
68
|
case "gt":
|
|
69
69
|
return { [field]: { gt: value } };
|
|
70
|
+
case "lte":
|
|
71
|
+
return { [field]: { lte: value } };
|
|
72
|
+
case "gte":
|
|
73
|
+
return { [field]: { gte: value } };
|
|
70
74
|
case "in":
|
|
71
75
|
if (Array.isArray(value) && value.length > 0) {
|
|
72
76
|
return { [field]: value };
|
|
@@ -9,14 +9,19 @@ import {
|
|
|
9
9
|
normalizeAccessEntry,
|
|
10
10
|
shiftParams,
|
|
11
11
|
} from "../engine/ownership";
|
|
12
|
-
import type { EntityDefinition, EntityId } from "../engine/types";
|
|
12
|
+
import type { EntityDefinition, EntityId, SessionUser } from "../engine/types";
|
|
13
13
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
|
|
14
14
|
import { UnprocessableError } from "../errors";
|
|
15
15
|
import { getStreamVersion } from "../event-store";
|
|
16
|
+
import type { SearchAdapter } from "../search/types";
|
|
16
17
|
import { LIST_ROW_META_REFERENCES } from "../ui-types/list-row-meta";
|
|
17
18
|
import { rehydrateCompoundTypes } from "./compound-types";
|
|
18
19
|
import { decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor";
|
|
19
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
collectEncryptedFieldNames,
|
|
22
|
+
hasSearchablePlaintext,
|
|
23
|
+
isSensitiveLabelField,
|
|
24
|
+
} from "./entity-field-encryption";
|
|
20
25
|
import type { EventStoreExecutor } from "./event-store-executor";
|
|
21
26
|
import { buildFilterWhere, type ExecutorContext, type Table } from "./event-store-executor-context";
|
|
22
27
|
import { buildParentRefClause } from "./parent-ref-clause";
|
|
@@ -104,26 +109,27 @@ function keysetBoundarySql(
|
|
|
104
109
|
|
|
105
110
|
type ListFilter = {
|
|
106
111
|
readonly field: string;
|
|
107
|
-
readonly op: "eq" | "ne" | "lt" | "gt" | "in";
|
|
112
|
+
readonly op: "eq" | "ne" | "lt" | "gt" | "lte" | "gte" | "in";
|
|
108
113
|
readonly value: unknown;
|
|
109
114
|
};
|
|
110
115
|
|
|
111
116
|
// multiSelect stores its options as a jsonb array — a filter value is one
|
|
112
117
|
// option, not the whole array, so eq/ne/in must check array containment
|
|
113
118
|
// (`@>`) instead of scalar `=`/`<>`/`IN` against the jsonb column (fw#2490).
|
|
114
|
-
// lt/gt have no containment analogue; the boot-validator already
|
|
115
|
-
// for screen-declared filters (screen-filter-ops.ts
|
|
116
|
-
// client-supplied facet filter reaches here
|
|
117
|
-
// unsatisfiable rather than emitting SQL
|
|
119
|
+
// lt/gt/lte/gte have no containment analogue; the boot-validator already
|
|
120
|
+
// blocks them for screen-declared filters (screen-filter-ops.ts
|
|
121
|
+
// EQUALITY_ONLY), but a client-supplied facet filter reaches here
|
|
122
|
+
// unvalidated, so treat it as unsatisfiable rather than emitting SQL
|
|
123
|
+
// Postgres would reject.
|
|
118
124
|
function applyMultiSelectFilter(
|
|
119
125
|
colSql: (field: string) => string,
|
|
120
126
|
whereSql: string[],
|
|
121
127
|
params: unknown[],
|
|
122
128
|
f: ListFilter,
|
|
123
129
|
): void {
|
|
124
|
-
if (f.op === "lt" || f.op === "gt") {
|
|
130
|
+
if (f.op === "lt" || f.op === "gt" || f.op === "lte" || f.op === "gte") {
|
|
125
131
|
whereSql.push("FALSE");
|
|
126
|
-
// skip: lt/gt is unsatisfiable on a multiSelect column
|
|
132
|
+
// skip: lt/gt/lte/gte is unsatisfiable on a multiSelect column
|
|
127
133
|
return;
|
|
128
134
|
}
|
|
129
135
|
if (f.op === "in") {
|
|
@@ -290,11 +296,86 @@ function collectReferenceSearchDescriptors(
|
|
|
290
296
|
// unscoped SQL, a match in a foreign tenant must never surface a row here
|
|
291
297
|
// (fw#2660's hard constraint). Mirrors the main table's own tenant filter
|
|
292
298
|
// (see the `table["tenantId"]` check further down in list()).
|
|
299
|
+
async function resolveReferenceMatchesViaIlike(
|
|
300
|
+
descriptor: ReferenceSearchDescriptor,
|
|
301
|
+
searchTerm: string,
|
|
302
|
+
targetTable: Table,
|
|
303
|
+
targetTableName: string,
|
|
304
|
+
db: TenantDb,
|
|
305
|
+
): Promise<{ readonly ownColumn: string; readonly ids: readonly string[] } | undefined> {
|
|
306
|
+
const labelCol = physicalColumnName(targetTable, descriptor.labelField);
|
|
307
|
+
const subParams: unknown[] = [`%${escapeLikePattern(searchTerm)}%`];
|
|
308
|
+
let tenantClause = "";
|
|
309
|
+
if (targetTable["tenantId"] !== undefined && db.mode === "tenant") {
|
|
310
|
+
subParams.push(db.tenantId, SYSTEM_TENANT_ID);
|
|
311
|
+
tenantClause = ` AND "${physicalColumnName(targetTable, "tenantId")}" IN ($2, $3)`;
|
|
312
|
+
}
|
|
313
|
+
// ::text cast covers a non-text labelField (e.g. a number/select column
|
|
314
|
+
// used as label) — Postgres has no ILIKE for those types otherwise.
|
|
315
|
+
const sql =
|
|
316
|
+
`SELECT "id" FROM "${targetTableName}" WHERE ("${labelCol}")::text ILIKE $1${tenantClause} ` +
|
|
317
|
+
`LIMIT ${MAX_REFERENCE_SEARCH_IDS + 1}`;
|
|
318
|
+
const rows = await executeRawQueryRead<{ id: string }>(tenantDbRunner(db), sql, subParams);
|
|
319
|
+
if (rows.length === 0 || rows.length > MAX_REFERENCE_SEARCH_IDS) {
|
|
320
|
+
// skip: no match, or over the cap — drop the clause rather than
|
|
321
|
+
// truncate to an arbitrary slice of matching targets
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
return { ownColumn: descriptor.ownColumn, ids: rows.map((r) => r.id) };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Same lookup, but the target's index is tenant-wide, not per-viewer, so
|
|
328
|
+
// candidate ids get re-checked against the target entity's own read access.
|
|
329
|
+
async function resolveReferenceMatchesViaSearchIndex(
|
|
330
|
+
descriptor: ReferenceSearchDescriptor,
|
|
331
|
+
searchTerm: string,
|
|
332
|
+
targetEntity: EntityDefinition,
|
|
333
|
+
targetTable: Table,
|
|
334
|
+
targetTableName: string,
|
|
335
|
+
user: SessionUser,
|
|
336
|
+
db: TenantDb,
|
|
337
|
+
searchAdapter: SearchAdapter,
|
|
338
|
+
): Promise<{ readonly ownColumn: string; readonly ids: readonly string[] } | undefined> {
|
|
339
|
+
const searchTenantId = targetEntity.systemStream ? SYSTEM_TENANT_ID : user.tenantId;
|
|
340
|
+
const results = await searchAdapter.search(searchTenantId, searchTerm, {
|
|
341
|
+
filterType: descriptor.targetEntityName,
|
|
342
|
+
limit: MAX_REFERENCE_SEARCH_IDS + 1,
|
|
343
|
+
});
|
|
344
|
+
if (results.length === 0 || results.length > MAX_REFERENCE_SEARCH_IDS) {
|
|
345
|
+
// skip: no match, or over the cap — same drop-the-clause policy as the ILIKE path
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const ownership = buildOwnershipClause(user, targetEntity.access?.read, targetTable);
|
|
350
|
+
if (ownership.kind === "empty") return undefined;
|
|
351
|
+
|
|
352
|
+
const candidateIds = results.map((r) => String(r.entityId));
|
|
353
|
+
if (ownership.kind === "pass") {
|
|
354
|
+
return { ownColumn: descriptor.ownColumn, ids: candidateIds };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const params: unknown[] = [...candidateIds];
|
|
358
|
+
const idPlaceholders = candidateIds.map((_, i) => `$${i + 1}`);
|
|
359
|
+
const shifted = shiftParams(
|
|
360
|
+
{ sqlText: ownership.sqlText, params: ownership.params },
|
|
361
|
+
params.length,
|
|
362
|
+
);
|
|
363
|
+
const sql =
|
|
364
|
+
`SELECT "id" FROM "${targetTableName}" WHERE "id" IN (${idPlaceholders.join(", ")}) ` +
|
|
365
|
+
`AND ${shifted.sqlText}`;
|
|
366
|
+
for (const p of shifted.params) params.push(p);
|
|
367
|
+
const rows = await executeRawQueryRead<{ id: string }>(tenantDbRunner(db), sql, params);
|
|
368
|
+
if (rows.length === 0) return undefined;
|
|
369
|
+
return { ownColumn: descriptor.ownColumn, ids: rows.map((r) => r.id) };
|
|
370
|
+
}
|
|
371
|
+
|
|
293
372
|
async function resolveReferenceMatches(
|
|
294
373
|
descriptor: ReferenceSearchDescriptor,
|
|
295
374
|
searchTerm: string,
|
|
296
375
|
resolveEntity: (entityName: string) => EntityDefinition | undefined,
|
|
297
376
|
db: TenantDb,
|
|
377
|
+
user: SessionUser,
|
|
378
|
+
searchAdapter: SearchAdapter,
|
|
298
379
|
): Promise<{ readonly ownColumn: string; readonly ids: readonly string[] } | undefined> {
|
|
299
380
|
const targetEntity = resolveEntity(descriptor.targetEntityName);
|
|
300
381
|
if (targetEntity === undefined) {
|
|
@@ -308,38 +389,36 @@ async function resolveReferenceMatches(
|
|
|
308
389
|
// `name` is a declared field on the tenant entity, not an id/row-meta column
|
|
309
390
|
return undefined;
|
|
310
391
|
}
|
|
311
|
-
if (
|
|
312
|
-
collectEncryptedFieldNames(targetEntity).has(descriptor.labelField) ||
|
|
313
|
-
collectPiiSubjectFields(targetEntity).includes(descriptor.labelField)
|
|
314
|
-
) {
|
|
315
|
-
// skip: encrypted/PII labelField — ILIKE can never match ciphertext,
|
|
316
|
-
// emitting the query would just be a silent, permanent non-match
|
|
317
|
-
return undefined;
|
|
318
|
-
}
|
|
319
392
|
|
|
320
393
|
const targetTable = buildEntityTable(descriptor.targetEntityName, targetEntity);
|
|
321
394
|
const targetTableName = String(
|
|
322
395
|
(targetTable as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL],
|
|
323
396
|
);
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
397
|
+
|
|
398
|
+
if (!isSensitiveLabelField(targetEntity, descriptor.labelField)) {
|
|
399
|
+
return resolveReferenceMatchesViaIlike(
|
|
400
|
+
descriptor,
|
|
401
|
+
searchTerm,
|
|
402
|
+
targetTable,
|
|
403
|
+
targetTableName,
|
|
404
|
+
db,
|
|
405
|
+
);
|
|
330
406
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
`SELECT "id" FROM "${targetTableName}" WHERE ("${labelCol}")::text ILIKE $1${tenantClause} ` +
|
|
335
|
-
`LIMIT ${MAX_REFERENCE_SEARCH_IDS + 1}`;
|
|
336
|
-
const rows = await executeRawQueryRead<{ id: string }>(tenantDbRunner(db), sql, subParams);
|
|
337
|
-
if (rows.length === 0 || rows.length > MAX_REFERENCE_SEARCH_IDS) {
|
|
338
|
-
// skip: no match, or over the cap — drop the clause rather than
|
|
339
|
-
// truncate to an arbitrary slice of matching targets
|
|
407
|
+
if (!hasSearchablePlaintext(targetEntity, descriptor.labelField)) {
|
|
408
|
+
// skip: no plaintext anywhere to match — boot rejects this normally,
|
|
409
|
+
// this is defense-in-depth for a target entity resolved only at runtime.
|
|
340
410
|
return undefined;
|
|
341
411
|
}
|
|
342
|
-
return
|
|
412
|
+
return resolveReferenceMatchesViaSearchIndex(
|
|
413
|
+
descriptor,
|
|
414
|
+
searchTerm,
|
|
415
|
+
targetEntity,
|
|
416
|
+
targetTable,
|
|
417
|
+
targetTableName,
|
|
418
|
+
user,
|
|
419
|
+
db,
|
|
420
|
+
searchAdapter,
|
|
421
|
+
);
|
|
343
422
|
}
|
|
344
423
|
|
|
345
424
|
// Projected alongside the row when a reference sort is active, so the keyset
|
|
@@ -517,7 +596,14 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
517
596
|
? (
|
|
518
597
|
await Promise.all(
|
|
519
598
|
descriptors.map((d) =>
|
|
520
|
-
resolveReferenceMatches(
|
|
599
|
+
resolveReferenceMatches(
|
|
600
|
+
d,
|
|
601
|
+
searchTerm,
|
|
602
|
+
referenceSearch.resolveEntity,
|
|
603
|
+
db,
|
|
604
|
+
user,
|
|
605
|
+
effectiveSearchAdapter,
|
|
606
|
+
),
|
|
521
607
|
),
|
|
522
608
|
)
|
|
523
609
|
).filter(
|
|
@@ -2306,7 +2306,7 @@ describe("boot-validator", () => {
|
|
|
2306
2306
|
function makeFeature(
|
|
2307
2307
|
filter: {
|
|
2308
2308
|
readonly field: string;
|
|
2309
|
-
readonly op: "eq" | "ne" | "lt" | "gt" | "in";
|
|
2309
|
+
readonly op: "eq" | "ne" | "lt" | "gt" | "lte" | "gte" | "in";
|
|
2310
2310
|
readonly value: unknown;
|
|
2311
2311
|
},
|
|
2312
2312
|
fields: Record<string, unknown> = {
|
|
@@ -2365,6 +2365,25 @@ describe("boot-validator", () => {
|
|
|
2365
2365
|
).not.toThrow();
|
|
2366
2366
|
});
|
|
2367
2367
|
|
|
2368
|
+
test("filter.op=lte/gte auf number-Feld → kein Throw (vergleichbar)", () => {
|
|
2369
|
+
const fields = {
|
|
2370
|
+
name: { type: "text", filterable: true },
|
|
2371
|
+
rank: { type: "number", filterable: true },
|
|
2372
|
+
};
|
|
2373
|
+
expect(() =>
|
|
2374
|
+
validateBoot([makeFeature({ field: "rank", op: "lte", value: 5 }, fields)]),
|
|
2375
|
+
).not.toThrow();
|
|
2376
|
+
expect(() =>
|
|
2377
|
+
validateBoot([makeFeature({ field: "rank", op: "gte", value: 5 }, fields)]),
|
|
2378
|
+
).not.toThrow();
|
|
2379
|
+
});
|
|
2380
|
+
|
|
2381
|
+
test("filter.op=lte auf text-Feld → Throw (op-vs-Type-Compat)", () => {
|
|
2382
|
+
expect(() => validateBoot([makeFeature({ field: "status", op: "lte", value: "x" })])).toThrow(
|
|
2383
|
+
/filter\.op "lte" is not allowed on field "status" \(type "text"\)/,
|
|
2384
|
+
);
|
|
2385
|
+
});
|
|
2386
|
+
|
|
2368
2387
|
test('filter.op="in" mit non-array value → Throw', () => {
|
|
2369
2388
|
expect(() =>
|
|
2370
2389
|
validateBoot([makeFeature({ field: "status", op: "in", value: "active" })]),
|
|
@@ -754,6 +754,92 @@ describe("createRegistry", () => {
|
|
|
754
754
|
expect(() => createRegistry([feature])).toThrow(/multiple/);
|
|
755
755
|
});
|
|
756
756
|
|
|
757
|
+
test("throws at boot when a searchable reference targets an encrypted labelField", () => {
|
|
758
|
+
const feature = defineFeature("crm", (r) => {
|
|
759
|
+
r.entity(
|
|
760
|
+
"customer",
|
|
761
|
+
createEntity({
|
|
762
|
+
table: "Customers",
|
|
763
|
+
fields: {
|
|
764
|
+
name: createTextField({ personal: false, reason: "test_fixture", encrypted: true }),
|
|
765
|
+
},
|
|
766
|
+
}),
|
|
767
|
+
);
|
|
768
|
+
r.entity(
|
|
769
|
+
"order",
|
|
770
|
+
createEntity({
|
|
771
|
+
table: "Orders",
|
|
772
|
+
fields: {
|
|
773
|
+
customerId: {
|
|
774
|
+
type: "reference",
|
|
775
|
+
entity: "customer",
|
|
776
|
+
labelField: "name",
|
|
777
|
+
searchable: true,
|
|
778
|
+
},
|
|
779
|
+
},
|
|
780
|
+
}),
|
|
781
|
+
);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
expect(() => createRegistry([feature])).toThrow(/encrypted\/PII/);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
test('throws at boot when a searchable reference targets a PII labelField without find: "fuzzy" (no plaintext index)', () => {
|
|
788
|
+
const feature = defineFeature("crm", (r) => {
|
|
789
|
+
r.entity(
|
|
790
|
+
"contact",
|
|
791
|
+
createEntity({
|
|
792
|
+
table: "Contacts",
|
|
793
|
+
fields: { displayName: createTextField({ personal: "self", find: "exact" }) },
|
|
794
|
+
}),
|
|
795
|
+
);
|
|
796
|
+
r.entity(
|
|
797
|
+
"order",
|
|
798
|
+
createEntity({
|
|
799
|
+
table: "Orders",
|
|
800
|
+
fields: {
|
|
801
|
+
contactId: {
|
|
802
|
+
type: "reference",
|
|
803
|
+
entity: "contact",
|
|
804
|
+
labelField: "displayName",
|
|
805
|
+
searchable: true,
|
|
806
|
+
},
|
|
807
|
+
},
|
|
808
|
+
}),
|
|
809
|
+
);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
expect(() => createRegistry([feature])).toThrow(/encrypted\/PII/);
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
test('does not throw when a searchable reference targets a PII labelField with find: "fuzzy" (derived search index)', () => {
|
|
816
|
+
const feature = defineFeature("crm", (r) => {
|
|
817
|
+
r.entity(
|
|
818
|
+
"contact",
|
|
819
|
+
createEntity({
|
|
820
|
+
table: "Contacts",
|
|
821
|
+
fields: { displayName: createTextField({ personal: "self", find: "fuzzy" }) },
|
|
822
|
+
}),
|
|
823
|
+
);
|
|
824
|
+
r.entity(
|
|
825
|
+
"order",
|
|
826
|
+
createEntity({
|
|
827
|
+
table: "Orders",
|
|
828
|
+
fields: {
|
|
829
|
+
contactId: {
|
|
830
|
+
type: "reference",
|
|
831
|
+
entity: "contact",
|
|
832
|
+
labelField: "displayName",
|
|
833
|
+
searchable: true,
|
|
834
|
+
},
|
|
835
|
+
},
|
|
836
|
+
}),
|
|
837
|
+
);
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
expect(() => createRegistry([feature])).not.toThrow();
|
|
841
|
+
});
|
|
842
|
+
|
|
757
843
|
test("returns sortable reference fields for entity, without leaking them into getSortableFields (fw#2741)", () => {
|
|
758
844
|
const feature = defineFeature("crm", (r) => {
|
|
759
845
|
r.entity(
|
|
@@ -3,7 +3,7 @@ import { createDecimalField, createMoneyField, createNumberField } from "../fact
|
|
|
3
3
|
import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
|
|
4
4
|
import type { ScreenFilterOp } from "../types";
|
|
5
5
|
|
|
6
|
-
const COMPARABLE: ScreenFilterOp[] = ["eq", "ne", "lt", "gt", "in"];
|
|
6
|
+
const COMPARABLE: ScreenFilterOp[] = ["eq", "ne", "lt", "gt", "lte", "gte", "in"];
|
|
7
7
|
|
|
8
8
|
describe("getAllowedFilterOps — decimal is comparable (#343/1)", () => {
|
|
9
9
|
test("decimal yields the full comparable op-set, not the empty default", () => {
|
|
@@ -96,7 +96,7 @@ function validateProjectionListFilterSchemaAcceptance(
|
|
|
96
96
|
if (screen.filter === undefined || schemaAccepts(schema, "filter")) return;
|
|
97
97
|
throw new Error(
|
|
98
98
|
`${prefix}: declares filter but query "${screen.query}" has no "filter" parameter in its Zod ` +
|
|
99
|
-
`schema — add filter: z.object({ field: z.string(), op: z.enum(["eq","ne","lt","gt","in"]), ` +
|
|
99
|
+
`schema — add filter: z.object({ field: z.string(), op: z.enum(["eq","ne","lt","gt","lte","gte","in"]), ` +
|
|
100
100
|
`value: z.unknown() }).optional() (or reuse entityListSchema's shape) to the handler's schema.`,
|
|
101
101
|
);
|
|
102
102
|
}
|
|
@@ -203,7 +203,7 @@ function validateRelatedListParentFilter(
|
|
|
203
203
|
if (!schemaAccepts(schema, "filter")) {
|
|
204
204
|
throw new Error(
|
|
205
205
|
`${prefix}: declares parentFilter but query "${section.query}" has no "filter" parameter in its ` +
|
|
206
|
-
`Zod schema — add filter: z.object({ field: z.string(), op: z.enum(["eq","ne","lt","gt","in"]), ` +
|
|
206
|
+
`Zod schema — add filter: z.object({ field: z.string(), op: z.enum(["eq","ne","lt","gt","lte","gte","in"]), ` +
|
|
207
207
|
`value: z.unknown() }).optional() (or reuse entityListSchema's shape) to the handler's schema.`,
|
|
208
208
|
);
|
|
209
209
|
}
|
|
@@ -102,7 +102,7 @@ type ListPayload = {
|
|
|
102
102
|
// den gleichen Query-Handler.
|
|
103
103
|
filter?: {
|
|
104
104
|
readonly field: string;
|
|
105
|
-
readonly op: "eq" | "ne" | "lt" | "gt" | "in";
|
|
105
|
+
readonly op: "eq" | "ne" | "lt" | "gt" | "lte" | "gte" | "in";
|
|
106
106
|
readonly value: unknown;
|
|
107
107
|
};
|
|
108
108
|
};
|
|
@@ -157,7 +157,7 @@ export const entityListSchema = z.object({
|
|
|
157
157
|
filter: z
|
|
158
158
|
.object({
|
|
159
159
|
field: z.string(),
|
|
160
|
-
op: z.enum(["eq", "ne", "lt", "gt", "in"]),
|
|
160
|
+
op: z.enum(["eq", "ne", "lt", "gt", "lte", "gte", "in"]),
|
|
161
161
|
// Value ist `unknown` zur Compile-Zeit; Server-Side prüft beim
|
|
162
162
|
// Build der WHERE-Clause ob der Type zum Field passt. z.unknown()
|
|
163
163
|
// lässt alles durch; Type-Check kommt im executor.list.
|
|
@@ -170,7 +170,7 @@ export const entityListSchema = z.object({
|
|
|
170
170
|
.array(
|
|
171
171
|
z.object({
|
|
172
172
|
field: z.string(),
|
|
173
|
-
op: z.enum(["eq", "ne", "lt", "gt", "in"]),
|
|
173
|
+
op: z.enum(["eq", "ne", "lt", "gt", "lte", "gte", "in"]),
|
|
174
174
|
value: z.unknown(),
|
|
175
175
|
}),
|
|
176
176
|
)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { configureEventPiiCatalog } from "../crypto/event-pii";
|
|
2
|
+
import { hasSearchablePlaintext, isSensitiveLabelField } from "../db/entity-field-encryption";
|
|
2
3
|
import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch";
|
|
3
4
|
import { resolveName } from "./handler-helpers";
|
|
4
5
|
import type {
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
softDeleteGraceDaysConfig,
|
|
17
18
|
} from "./soft-delete-cleanup";
|
|
18
19
|
import type {
|
|
20
|
+
EntityDefinition,
|
|
19
21
|
EventPiiFields,
|
|
20
22
|
EventUpcastFn,
|
|
21
23
|
FeatureDefinition,
|
|
@@ -206,6 +208,7 @@ function buildSearchableReferenceField(
|
|
|
206
208
|
entityName: string,
|
|
207
209
|
fieldName: string,
|
|
208
210
|
field: ReferenceFieldDef,
|
|
211
|
+
entityMap: ReadonlyMap<string, EntityDefinition>,
|
|
209
212
|
): SearchableReferenceField {
|
|
210
213
|
if (field.labelField === undefined || field.labelField === "id") {
|
|
211
214
|
throw new Error(
|
|
@@ -220,9 +223,26 @@ function buildSearchableReferenceField(
|
|
|
220
223
|
`(array) reference fields — remove "multiple" or "searchable".`,
|
|
221
224
|
);
|
|
222
225
|
}
|
|
226
|
+
const targetEntityName = parseReferenceTargetEntityName(field.entity);
|
|
227
|
+
const targetEntity = entityMap.get(targetEntityName);
|
|
228
|
+
if (targetEntity !== undefined) {
|
|
229
|
+
if (
|
|
230
|
+
isSensitiveLabelField(targetEntity, field.labelField) &&
|
|
231
|
+
!hasSearchablePlaintext(targetEntity, field.labelField)
|
|
232
|
+
) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
`[Entity ${entityName}] field "${fieldName}": searchable reference targets ` +
|
|
235
|
+
`"${targetEntityName}.${field.labelField}", which is encrypted/PII and not itself ` +
|
|
236
|
+
`searchable — there is no plaintext anywhere to match a search term against. Mark ` +
|
|
237
|
+
`"${targetEntityName}.${field.labelField}" searchable: true (add find: "fuzzy" if it ` +
|
|
238
|
+
`also carries a personal annotation, so it lands in the derived search index) or ` +
|
|
239
|
+
`remove searchable from "${fieldName}".`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
223
243
|
return {
|
|
224
244
|
fieldName,
|
|
225
|
-
targetEntityName
|
|
245
|
+
targetEntityName,
|
|
226
246
|
labelField: field.labelField,
|
|
227
247
|
};
|
|
228
248
|
}
|
|
@@ -273,7 +293,9 @@ export function buildSearchableSortableCaches(state: RegistryState): void {
|
|
|
273
293
|
}
|
|
274
294
|
}
|
|
275
295
|
if (field.type === "reference" && field.searchable === true) {
|
|
276
|
-
searchableReferences.push(
|
|
296
|
+
searchableReferences.push(
|
|
297
|
+
buildSearchableReferenceField(name, fieldName, field, state.entityMap),
|
|
298
|
+
);
|
|
277
299
|
}
|
|
278
300
|
if (field.type === "reference" && field.sortable === true) {
|
|
279
301
|
sortableReferences.push(buildSortableReferenceField(name, fieldName, field));
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// compare nutzt der Author selten bewusst); Author kann den
|
|
7
7
|
// Filter dann einfach nicht setzen.
|
|
8
8
|
// - boolean: eq, ne (in/lt/gt sinnlos für 2-Werte-Type).
|
|
9
|
-
// - number/money/decimal/date/timestamp/locatedTimestamp: alle
|
|
9
|
+
// - number/money/decimal/date/timestamp/locatedTimestamp: alle 7
|
|
10
10
|
// Ops — die Felder sind natürlich vergleichbar.
|
|
11
11
|
//
|
|
12
12
|
// Boot-Validator nutzt diese Map um Author-Fehler früh zu fangen
|
|
@@ -17,7 +17,15 @@
|
|
|
17
17
|
import type { FieldDefinition, ScreenFilterOp } from "./types";
|
|
18
18
|
|
|
19
19
|
const EQUALITY_ONLY = ["eq", "ne", "in"] as const satisfies readonly ScreenFilterOp[];
|
|
20
|
-
const COMPARABLE = [
|
|
20
|
+
const COMPARABLE = [
|
|
21
|
+
"eq",
|
|
22
|
+
"ne",
|
|
23
|
+
"lt",
|
|
24
|
+
"gt",
|
|
25
|
+
"lte",
|
|
26
|
+
"gte",
|
|
27
|
+
"in",
|
|
28
|
+
] as const satisfies readonly ScreenFilterOp[];
|
|
21
29
|
const BOOL_OPS = ["eq", "ne"] as const satisfies readonly ScreenFilterOp[];
|
|
22
30
|
|
|
23
31
|
export function getAllowedFilterOps(field: FieldDefinition): readonly ScreenFilterOp[] {
|