@cosmicdrift/kumiko-framework 0.273.0 → 0.275.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 +28 -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__/dedupe-features.test.ts +85 -0
- package/src/engine/__tests__/engine.test.ts +86 -0
- package/src/engine/__tests__/screen-filter-ops.test.ts +1 -1
- package/src/engine/__tests__/soft-delete-cleanup-job-tenant-db.integration.test.ts +82 -0
- package/src/engine/boot-validator/index.ts +3 -1
- package/src/engine/boot-validator/projection-list-screens.ts +2 -2
- package/src/engine/create-app.ts +6 -4
- package/src/engine/dedupe-features.ts +46 -0
- package/src/engine/define-feature.ts +6 -0
- package/src/engine/entity-handlers.ts +3 -3
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +2 -2
- package/src/engine/feature-config-events-jobs.ts +11 -2
- package/src/engine/index.ts +1 -0
- package/src/engine/registry-validate.ts +24 -2
- package/src/engine/registry.ts +3 -1
- package/src/engine/screen-filter-ops.ts +10 -2
- package/src/engine/soft-delete-cleanup.ts +4 -3
- package/src/jobs/__tests__/job-systemdb.integration.test.ts +1 -1
- package/src/jobs/__tests__/job-tenant-db.integration.test.ts +162 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +3 -2
- package/src/jobs/job-runner.ts +18 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.275.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.275.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.275.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,32 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.275.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "JobContext.db is a tenant-filtered TenantDb; unfiltered job access needs r.job({ escapeHatch }) or r.systemScope() (fw#2914).",
|
|
6
|
+
"detail": "Job handlers no longer receive the unfiltered boot `DbConnection` as `ctx.db`. `JobContext.db` is now a `TenantDb` in \"tenant\" mode, bound to the job's resolved tenant (`_tenantId`, `payload.tenantId`, or `SYSTEM_TENANT_ID` for tenant-less cron jobs). Reads see that tenant plus `SYSTEM_TENANT_ID` reference rows, and writes are scoped to it. `JobDefinition` gains `escapeHatch?: { reason }`: it grants `ctx.db.unsafeRaw(reason)` for that job and reports an `\"unsafe-raw\"` escape-hatch audit event through `_escapeHatchAuditSink` (deduplicated like handler grants). Without it, `ctx.db.unsafeRaw` throws `AccessDeniedError`. `r.systemScope()` features keep `ctx.systemDb` unchanged. `r.job` throws at registration for an `escapeHatch` with an empty reason. Framework `soft-delete` cleanup jobs run on the tenant-filtered `TenantDb` without a grant. Bundled cross-tenant jobs now declare `escapeHatch`: auth-mfa reencrypt, sessions cleanup, form-draft cleanup, secrets rotate, files-tenant-data sweep-orphaned-derivatives, data-retention retention-cleanup, inbound-mail-retention, tenant-lifecycle run-tenant-destruction, and user-data-rights run-export-jobs/run-forget-cleanup. The systemScope features config, jobs and workflow-runner use `ctx.systemDb.unsafeRaw`. The exported `rotateJob` (secrets) and `sweepOrphanedDerivativesJob` (files-tenant-data) take the raw `DbConnection` as a third argument.",
|
|
7
|
+
"migration": "Jobs that only read and write their own tenant through `ctx.db` method calls or `selectMany/fetchOne/insertOne/updateMany/deleteMany(ctx.db, ...)` need no change. A job that treated `ctx.db` as a `DbConnection` (casts, `asRawClient`, `countWhere`, `upsert*`, `incrementCounter`, `createTenantDb(ctx.db, ...)`, `runProjectionsForEvent(..., ctx.db)`, or `\"raw\" in db ? db.raw : db`) must declare the grant and fetch the runner explicitly. Use `r.job({ name, trigger, escapeHatch: { reason: \"<why>\" }, handler: async (payload, ctx) => work(ctx.db.unsafeRaw(\"<why>\")) })`; keep the `unsafeRaw` call inside the inline `handler` next to `escapeHatch` so `guard-escape-hatch-declared` recognizes the declaration. `r.systemScope()` features use `ctx.systemDb.unsafeRaw(reason)` instead. WARNING: a job that is not migrated but passes a foreign `where.tenantId` through `ctx.db` does not fail; the filter silently narrows it to the job's own tenant (`SYSTEM_TENANT_ID` for tenant-less cron jobs), so cross-tenant sweeps silently read nothing. Audit every cross-tenant job. Hand-built `JobContext` objects in tests need `db: createTenantDb(conn, tenantId)` (plus `systemDb` for systemScope jobs). Direct callers of `rotateJob`/`sweepOrphanedDerivativesJob` pass the raw connection as the third argument."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.274.0",
|
|
11
|
+
"type": "breaking",
|
|
12
|
+
"title": "A searchable reference field targeting an encrypted/PII labelField now fails boot unless that label is itself searchable.",
|
|
13
|
+
"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.",
|
|
14
|
+
"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."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "0.274.0",
|
|
18
|
+
"type": "improvement",
|
|
19
|
+
"title": "Entity-list filters gain lte/gte comparison operators.",
|
|
20
|
+
"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`.",
|
|
21
|
+
"migration": "No action required — purely additive; existing filters and their allowed operators are unchanged."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"version": "0.274.0",
|
|
25
|
+
"type": "improvement",
|
|
26
|
+
"title": "Entity create handlers accept a caller-chosen id for idempotent creates from jobs/hooks.",
|
|
27
|
+
"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.",
|
|
28
|
+
"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\"."
|
|
29
|
+
},
|
|
2
30
|
{
|
|
3
31
|
"version": "0.272.0",
|
|
4
32
|
"type": "breaking",
|
|
@@ -27,13 +55,6 @@
|
|
|
27
55
|
"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
56
|
"codemod": "scripts/codemod/migrate-cross-tenant.ts"
|
|
29
57
|
},
|
|
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
58
|
{
|
|
38
59
|
"version": "0.271.0",
|
|
39
60
|
"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 };
|