@cosmicdrift/kumiko-framework 0.270.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/__tests__/fixtures/migrate-cross-tenant/manual-only.expected.ts +44 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.input.ts +44 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.expected.ts +41 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.input.ts +41 -0
- package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
- package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
- package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
- package/src/api/server.ts +7 -20
- package/src/bun-db/__tests__/query-guards.test.ts +60 -3
- package/src/bun-db/query.ts +24 -21
- package/src/changes.json +105 -0
- 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/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
- package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
- package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
- package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
- package/src/db/__tests__/tenant-db-global.test.ts +92 -0
- package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
- package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
- package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
- package/src/db/apply-entity-event.ts +1 -1
- package/src/db/assert-exists-in.ts +1 -1
- package/src/db/entity-field-encryption.ts +18 -0
- package/src/db/event-store-executor-context.ts +12 -5
- package/src/db/event-store-executor-read.ts +125 -37
- package/src/db/event-store-executor-write.ts +24 -18
- package/src/db/queries/shadow-swap.ts +36 -0
- package/src/db/replay-migration-sql.ts +5 -5
- package/src/db/tenant-db-runner.ts +23 -0
- package/src/db/tenant-db.ts +97 -24
- package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
- package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +91 -0
- package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +143 -0
- package/src/engine/__tests__/boot-validator.test.ts +71 -1
- package/src/engine/__tests__/engine.test.ts +86 -0
- package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
- package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
- package/src/engine/__tests__/entity-handlers.test.ts +28 -0
- package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
- package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
- package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
- package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
- package/src/engine/__tests__/screen-filter-ops.test.ts +1 -1
- package/src/engine/__tests__/screen.test.ts +150 -0
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
- package/src/engine/__tests__/steps-read.test.ts +204 -8
- package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
- package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
- package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
- package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
- package/src/engine/boot-validator/entity-handler.ts +21 -0
- package/src/engine/boot-validator/global-tenancy.ts +13 -1
- package/src/engine/boot-validator/parent-ref.ts +1 -1
- package/src/engine/boot-validator/projection-list-screens.ts +68 -3
- package/src/engine/boot-validator/query-output-columns.ts +33 -0
- package/src/engine/boot-validator/screens.ts +154 -31
- package/src/engine/build-config-feature-schema.ts +7 -4
- package/src/engine/entity-handlers.ts +74 -22
- package/src/engine/feature-entity-handlers.ts +4 -4
- package/src/engine/index.ts +5 -0
- package/src/engine/registry-validate.ts +36 -6
- package/src/engine/schema-builder.ts +6 -0
- package/src/engine/screen-filter-ops.ts +10 -2
- package/src/engine/screen-helpers.ts +52 -0
- package/src/engine/steps/_read-source.ts +12 -0
- package/src/engine/steps/read-find-many.ts +8 -2
- package/src/engine/steps/read-find-one.ts +11 -8
- package/src/engine/steps/unsafe-projection-delete.ts +2 -1
- package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
- package/src/engine/types/index.ts +8 -1
- package/src/errors/i18n/de.yaml +14 -0
- package/src/errors/i18n/en.yaml +13 -0
- package/src/errors/reasons.ts +4 -0
- package/src/i18n/required-surface-keys.ts +6 -2
- package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
- package/src/jobs/__tests__/jobs.integration.test.ts +41 -0
- package/src/jobs/job-runner.ts +35 -5
- package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
- package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
- package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
- package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
- package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
- package/src/pipeline/__tests__/query-projection.integration.test.ts +169 -14
- package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
- package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
- package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
- package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
- package/src/pipeline/dispatch-query.ts +27 -8
- package/src/pipeline/dispatch-shared.ts +141 -57
- package/src/pipeline/dispatch-stream.ts +1 -3
- package/src/pipeline/dispatch-write.ts +62 -14
- package/src/pipeline/dispatcher.ts +20 -0
- package/src/pipeline/escape-hatch-report.ts +127 -0
- package/src/pipeline/index.ts +8 -0
- package/src/pipeline/member-read-only-transaction.ts +75 -0
- package/src/pipeline/msp-rebuild.ts +2 -0
- package/src/pipeline/projection-rebuild.ts +2 -0
- package/src/pipeline/projections-runner.ts +15 -13
- package/src/pipeline/system-identity-switch.ts +121 -9
- package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
- package/src/scripts/codemod/migrate-db-raw.ts +19 -6
- package/src/scripts/codemod/migrate-open-to-all.ts +2 -1
- package/src/ui-types/index.ts +3 -0
- package/src/utils/__tests__/ids.test.ts +29 -1
- package/src/utils/ids.ts +10 -1
- package/src/utils/index.ts +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type { EntityTableMeta } from "@cosmicdrift/kumiko-types/entity-table-meta-types";
|
|
3
|
+
import type { EscapeHatchReporter, EscapeHatchTarget } from "@cosmicdrift/kumiko-types/handlers";
|
|
3
4
|
import type { TenancyBrand } from "@cosmicdrift/kumiko-types/tenancy-brand";
|
|
4
5
|
import { createEntity, createTextField } from "../../engine";
|
|
5
6
|
import { AccessDeniedError } from "../../errors";
|
|
@@ -69,6 +70,19 @@ const explicitGlobalLiteralTable = buildEntityTable("globalGuardExplicitGlobal",
|
|
|
69
70
|
|
|
70
71
|
const own = testTenantId(1);
|
|
71
72
|
|
|
73
|
+
function recordingReporter(): {
|
|
74
|
+
readonly report: EscapeHatchReporter;
|
|
75
|
+
readonly calls: Array<{ kind: string; reason: string; target: EscapeHatchTarget | undefined }>;
|
|
76
|
+
} {
|
|
77
|
+
const calls: Array<{ kind: string; reason: string; target: EscapeHatchTarget | undefined }> = [];
|
|
78
|
+
return {
|
|
79
|
+
report: (kind, reason, target) => {
|
|
80
|
+
calls.push({ kind, reason, target });
|
|
81
|
+
},
|
|
82
|
+
calls,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
72
86
|
// Type-only assertions — never invoked, checked by `tsc --build` only.
|
|
73
87
|
function typeAssertions(tdb: TenantDb): void {
|
|
74
88
|
// @ts-expect-error db.global() on a "tenant"-tenancy table (default tenancy) is a type error.
|
|
@@ -157,6 +171,41 @@ describe("TenantDb.global()", () => {
|
|
|
157
171
|
expect(captured).toHaveLength(1);
|
|
158
172
|
});
|
|
159
173
|
|
|
174
|
+
test("write with an escapeHatch reports global-write once with the grant's reason", async () => {
|
|
175
|
+
const db: DbRunner = {
|
|
176
|
+
unsafe: async () => [{ id: "x" }],
|
|
177
|
+
begin: async () => {
|
|
178
|
+
throw new Error("begin not used in this test");
|
|
179
|
+
},
|
|
180
|
+
} as DbRunner;
|
|
181
|
+
const { report, calls } = recordingReporter();
|
|
182
|
+
const tdb = createTenantDb(db, own, "tenant", undefined, undefined, undefined, {
|
|
183
|
+
globalWrites: { reason: "cross-tenant backfill job" },
|
|
184
|
+
report,
|
|
185
|
+
});
|
|
186
|
+
await tdb.global(globalUnmanagedTable).insertOne({ name: "x" });
|
|
187
|
+
expect(calls).toEqual([
|
|
188
|
+
{ kind: "global-write", reason: "cross-tenant backfill job", target: undefined },
|
|
189
|
+
]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("denied global write (no grant) reports nothing", async () => {
|
|
193
|
+
const { report, calls } = recordingReporter();
|
|
194
|
+
const tdb = createTenantDb(
|
|
195
|
+
unreachableRunner(),
|
|
196
|
+
own,
|
|
197
|
+
"tenant",
|
|
198
|
+
undefined,
|
|
199
|
+
undefined,
|
|
200
|
+
undefined,
|
|
201
|
+
{
|
|
202
|
+
report,
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
await expect(tdb.global(globalUnmanagedTable).insertOne({ name: "x" })).rejects.toThrow();
|
|
206
|
+
expect(calls).toEqual([]);
|
|
207
|
+
});
|
|
208
|
+
|
|
160
209
|
test("write with an explicit non-SYSTEM tenantId rejects even with a valid escapeHatch", async () => {
|
|
161
210
|
const tdb = createTenantDb(
|
|
162
211
|
unreachableRunner(),
|
|
@@ -214,6 +263,41 @@ describe("TenantDb.unsafeRaw()", () => {
|
|
|
214
263
|
});
|
|
215
264
|
expect(tdb.unsafeRaw("some reason")).toBe(rawDb);
|
|
216
265
|
});
|
|
266
|
+
|
|
267
|
+
test("with a valid grant reports unsafe-raw once with the reason", () => {
|
|
268
|
+
const { report, calls } = recordingReporter();
|
|
269
|
+
const tdb = createTenantDb(
|
|
270
|
+
unreachableRunner(),
|
|
271
|
+
own,
|
|
272
|
+
"tenant",
|
|
273
|
+
undefined,
|
|
274
|
+
undefined,
|
|
275
|
+
undefined,
|
|
276
|
+
{
|
|
277
|
+
unsafeRaw: { reason: "granted" },
|
|
278
|
+
report,
|
|
279
|
+
},
|
|
280
|
+
);
|
|
281
|
+
tdb.unsafeRaw("cleanup read");
|
|
282
|
+
expect(calls).toEqual([{ kind: "unsafe-raw", reason: "cleanup read", target: undefined }]);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("without a grant reports nothing", () => {
|
|
286
|
+
const { report, calls } = recordingReporter();
|
|
287
|
+
const tdb = createTenantDb(
|
|
288
|
+
unreachableRunner(),
|
|
289
|
+
own,
|
|
290
|
+
"tenant",
|
|
291
|
+
undefined,
|
|
292
|
+
undefined,
|
|
293
|
+
undefined,
|
|
294
|
+
{
|
|
295
|
+
report,
|
|
296
|
+
},
|
|
297
|
+
);
|
|
298
|
+
expect(() => tdb.unsafeRaw("some reason")).toThrow(AccessDeniedError);
|
|
299
|
+
expect(calls).toEqual([]);
|
|
300
|
+
});
|
|
217
301
|
});
|
|
218
302
|
|
|
219
303
|
describe("withUnsafeRawGrant", () => {
|
|
@@ -257,4 +341,12 @@ describe("UncheckedSystemDb.unsafeRaw", () => {
|
|
|
257
341
|
const sysDb = createUncheckedSystemDb(tdb);
|
|
258
342
|
expect(sysDb.unsafeRaw("cleanup job")).toBe(rawDb);
|
|
259
343
|
});
|
|
344
|
+
|
|
345
|
+
test("reports unsafe-raw once with the reason", () => {
|
|
346
|
+
const tdb = createTenantDb(unreachableRunner(), own);
|
|
347
|
+
const { report, calls } = recordingReporter();
|
|
348
|
+
const sysDb = createUncheckedSystemDb(tdb, undefined, report);
|
|
349
|
+
sysDb.unsafeRaw("cleanup job");
|
|
350
|
+
expect(calls).toEqual([{ kind: "unsafe-raw", reason: "cleanup job", target: undefined }]);
|
|
351
|
+
});
|
|
260
352
|
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { TenantDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
3
|
+
import { InternalError } from "../../errors";
|
|
4
|
+
import { testTenantId } from "../../stack";
|
|
5
|
+
import type { DbRunner } from "../connection";
|
|
6
|
+
import { createTenantDb, createUncheckedSystemDb, withUnsafeRawGrant } from "../tenant-db";
|
|
7
|
+
import { tenantDbRunner } from "../tenant-db-runner";
|
|
8
|
+
|
|
9
|
+
// TenantDb.raw is gone from the type; framework-internal callers resolve
|
|
10
|
+
// the bound DbRunner through tenant-db-runner.ts's WeakMap instead.
|
|
11
|
+
|
|
12
|
+
const tenantId = testTenantId(1);
|
|
13
|
+
|
|
14
|
+
function fakeRunner(): DbRunner {
|
|
15
|
+
return {
|
|
16
|
+
unsafe: async () => [],
|
|
17
|
+
begin: async () => {
|
|
18
|
+
throw new Error("begin not used in these tests");
|
|
19
|
+
},
|
|
20
|
+
} as unknown as DbRunner;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("TenantDb has no .raw", () => {
|
|
24
|
+
test("compile-time: .raw is not a property of TenantDb", () => {
|
|
25
|
+
const tdb = createTenantDb(fakeRunner(), tenantId);
|
|
26
|
+
// @ts-expect-error TenantDb no longer exposes `raw`.
|
|
27
|
+
expect(tdb.raw).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("tenantDbRunner", () => {
|
|
32
|
+
test("returns the runner bound at createTenantDb() (identity)", () => {
|
|
33
|
+
const runner = fakeRunner();
|
|
34
|
+
const tdb = createTenantDb(runner, tenantId);
|
|
35
|
+
expect(tenantDbRunner(tdb)).toBe(runner);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("withUnsafeRawGrant rebinds also resolve to the original runner", () => {
|
|
39
|
+
const runner = fakeRunner();
|
|
40
|
+
const tdb = createTenantDb(runner, tenantId);
|
|
41
|
+
const regranted = withUnsafeRawGrant(tdb, { reason: "test grant" });
|
|
42
|
+
expect(regranted).not.toBe(tdb);
|
|
43
|
+
expect(tenantDbRunner(regranted)).toBe(runner);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("throws InternalError for a hand-built object not built by createTenantDb", () => {
|
|
47
|
+
const handBuilt = { tenantId, mode: "tenant" } as unknown as TenantDb;
|
|
48
|
+
expect(() => tenantDbRunner(handBuilt)).toThrow(InternalError);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("throws InternalError for a throwing guard Proxy, without the proxy's get trap firing", () => {
|
|
52
|
+
let getTrapFired = false;
|
|
53
|
+
const guardProxy = new Proxy(
|
|
54
|
+
{},
|
|
55
|
+
{
|
|
56
|
+
get() {
|
|
57
|
+
getTrapFired = true;
|
|
58
|
+
throw new Error("systemScope guard: property access denied");
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
) as unknown as TenantDb;
|
|
62
|
+
|
|
63
|
+
expect(() => tenantDbRunner(guardProxy)).toThrow(InternalError);
|
|
64
|
+
expect(getTrapFired).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("createUncheckedSystemDb(...).unsafeRaw", () => {
|
|
69
|
+
test("resolves to the runner bound at createTenantDb()", () => {
|
|
70
|
+
const runner = fakeRunner();
|
|
71
|
+
const tdb = createTenantDb(runner, tenantId, "system");
|
|
72
|
+
const systemDb = createUncheckedSystemDb(tdb);
|
|
73
|
+
expect(systemDb.unsafeRaw("test reason")).toBe(runner);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -110,7 +110,9 @@ describe("scoped mode (default)", () => {
|
|
|
110
110
|
|
|
111
111
|
await tdb.insertOne(table, { name: "ColSelect" });
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
// This probes a custom column projection, not tenant-scoping, so it goes
|
|
114
|
+
// straight at the underlying runner tdb was built from.
|
|
115
|
+
const rows = await asRawClient(testDb.db).unsafe<Record<string, unknown>>(
|
|
114
116
|
`SELECT id, name FROM "tenant_db_items" WHERE name = $1 LIMIT 10`,
|
|
115
117
|
["ColSelect"],
|
|
116
118
|
);
|
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { EscapeHatchReporter, EscapeHatchTarget } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
3
|
import { SYSTEM_TENANT_ID } from "../../engine";
|
|
3
4
|
import { testTenantId } from "../../stack";
|
|
4
5
|
import type { DbRunner } from "../connection";
|
|
5
6
|
import { createTenantDb, createUncheckedSystemDb, SYSTEM_SCOPE_CHECK_BRAND } from "../tenant-db";
|
|
6
7
|
|
|
8
|
+
function recordingReporter(): {
|
|
9
|
+
readonly report: EscapeHatchReporter;
|
|
10
|
+
readonly calls: Array<{ kind: string; reason: string; target: EscapeHatchTarget | undefined }>;
|
|
11
|
+
} {
|
|
12
|
+
const calls: Array<{ kind: string; reason: string; target: EscapeHatchTarget | undefined }> = [];
|
|
13
|
+
return {
|
|
14
|
+
report: (kind, reason, target) => {
|
|
15
|
+
calls.push({ kind, reason, target });
|
|
16
|
+
},
|
|
17
|
+
calls,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
7
21
|
// createUncheckedSystemDb wraps a "system"-mode TenantDb (r.systemScope())
|
|
8
22
|
// so a handler must explicitly clear a self-check before using it — none of
|
|
9
23
|
// these checks execute a query, so a runner that always throws is enough to
|
|
@@ -44,6 +58,16 @@ describe("createUncheckedSystemDb", () => {
|
|
|
44
58
|
|
|
45
59
|
expect(() => unchecked.assertTenantMatch(foreign)).toThrow(/tenant self-check failed/);
|
|
46
60
|
});
|
|
61
|
+
|
|
62
|
+
test("does not report — it is a tenant-safe check", () => {
|
|
63
|
+
const systemDb = createTenantDb(unusedRunner(), own, "system");
|
|
64
|
+
const { report, calls } = recordingReporter();
|
|
65
|
+
const unchecked = createUncheckedSystemDb(systemDb, undefined, report);
|
|
66
|
+
|
|
67
|
+
unchecked.assertTenantMatch(own);
|
|
68
|
+
expect(() => unchecked.assertTenantMatch(foreign)).toThrow();
|
|
69
|
+
expect(calls).toEqual([]);
|
|
70
|
+
});
|
|
47
71
|
});
|
|
48
72
|
|
|
49
73
|
describe("assertRowsTenant", () => {
|
|
@@ -88,6 +112,16 @@ describe("createUncheckedSystemDb", () => {
|
|
|
88
112
|
|
|
89
113
|
expect(unchecked.assertRowsTenant(rows, "tenantId")).toBe(rows);
|
|
90
114
|
});
|
|
115
|
+
|
|
116
|
+
test("does not report — it is a tenant-safe check", () => {
|
|
117
|
+
const systemDb = createTenantDb(unusedRunner(), own, "system");
|
|
118
|
+
const { report, calls } = recordingReporter();
|
|
119
|
+
const unchecked = createUncheckedSystemDb(systemDb, undefined, report);
|
|
120
|
+
const rows = [{ tenantId: own, name: "a" }];
|
|
121
|
+
|
|
122
|
+
unchecked.assertRowsTenant(rows, "tenantId");
|
|
123
|
+
expect(calls).toEqual([]);
|
|
124
|
+
});
|
|
91
125
|
});
|
|
92
126
|
|
|
93
127
|
describe("acknowledgeCrossTenant", () => {
|
|
@@ -113,6 +147,30 @@ describe("createUncheckedSystemDb", () => {
|
|
|
113
147
|
|
|
114
148
|
expect(() => unchecked.acknowledgeCrossTenant(" ")).toThrow(/non-empty reason/);
|
|
115
149
|
});
|
|
150
|
+
|
|
151
|
+
test("reports acknowledge-cross-tenant once with the reason", () => {
|
|
152
|
+
const systemDb = createTenantDb(unusedRunner(), own, "system");
|
|
153
|
+
const { report, calls } = recordingReporter();
|
|
154
|
+
const unchecked = createUncheckedSystemDb(systemDb, undefined, report);
|
|
155
|
+
|
|
156
|
+
unchecked.acknowledgeCrossTenant("user feature is cross-tenant by design");
|
|
157
|
+
expect(calls).toEqual([
|
|
158
|
+
{
|
|
159
|
+
kind: "acknowledge-cross-tenant",
|
|
160
|
+
reason: "user feature is cross-tenant by design",
|
|
161
|
+
target: undefined,
|
|
162
|
+
},
|
|
163
|
+
]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("an empty reason throws and does not report", () => {
|
|
167
|
+
const systemDb = createTenantDb(unusedRunner(), own, "system");
|
|
168
|
+
const { report, calls } = recordingReporter();
|
|
169
|
+
const unchecked = createUncheckedSystemDb(systemDb, undefined, report);
|
|
170
|
+
|
|
171
|
+
expect(() => unchecked.acknowledgeCrossTenant("")).toThrow();
|
|
172
|
+
expect(calls).toEqual([]);
|
|
173
|
+
});
|
|
116
174
|
});
|
|
117
175
|
|
|
118
176
|
describe("outsideTransaction", () => {
|
|
@@ -178,6 +236,24 @@ describe("createUncheckedSystemDb", () => {
|
|
|
178
236
|
/no outside-transaction database source is configured/,
|
|
179
237
|
);
|
|
180
238
|
});
|
|
239
|
+
|
|
240
|
+
test("reports acknowledge-cross-tenant once with the reason", () => {
|
|
241
|
+
const systemDb = createTenantDb(unusedRunner(), own, "system");
|
|
242
|
+
const outsideTxDb = createTenantDb(unusedRunner(), own, "system");
|
|
243
|
+
const { report, calls } = recordingReporter();
|
|
244
|
+
const unchecked = createUncheckedSystemDb(systemDb, outsideTxDb, report);
|
|
245
|
+
|
|
246
|
+
unchecked.outsideTransaction.acknowledgeCrossTenant(
|
|
247
|
+
"durability write is cross-tenant by design",
|
|
248
|
+
);
|
|
249
|
+
expect(calls).toEqual([
|
|
250
|
+
{
|
|
251
|
+
kind: "acknowledge-cross-tenant",
|
|
252
|
+
reason: "durability write is cross-tenant by design",
|
|
253
|
+
target: undefined,
|
|
254
|
+
},
|
|
255
|
+
]);
|
|
256
|
+
});
|
|
181
257
|
});
|
|
182
258
|
});
|
|
183
259
|
});
|
|
@@ -89,7 +89,7 @@ export async function applyEntityEvent(
|
|
|
89
89
|
switch (verb) {
|
|
90
90
|
case "created": {
|
|
91
91
|
// tenantId-Resolution explizit, nicht via Spread-Reihenfolge:
|
|
92
|
-
// Live-Pfad nutzt tx=db
|
|
92
|
+
// Live-Pfad nutzt tx=tenantDbRunner(db) (kein TenantDb-Wrapper-Auto-Inject),
|
|
93
93
|
// beim Replay erst recht keiner. Default = event.tenantId; payload
|
|
94
94
|
// gewinnt NUR wenn gültig string mit length > 0 (seedTenantMembership-
|
|
95
95
|
// Pfad: Operator schreibt im Ziel-Tenant, Event im Operator-Tenant).
|
|
@@ -5,7 +5,7 @@ import type { DbConnection } from "./connection";
|
|
|
5
5
|
import type { TenantDb } from "./tenant-db";
|
|
6
6
|
|
|
7
7
|
function isTenantDb(db: DbConnection | TenantDb): db is TenantDb {
|
|
8
|
-
return
|
|
8
|
+
return "tenantId" in db && "fetchOne" in db && typeof db.fetchOne === "function";
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -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 {
|
|
@@ -38,6 +38,7 @@ import type { EventStoreExecutorOptions } from "./event-store-executor";
|
|
|
38
38
|
import { constraintOf, isUniqueViolation } from "./pg-error";
|
|
39
39
|
import { toSnakeCase } from "./table-builder";
|
|
40
40
|
import type { TenantDb } from "./tenant-db";
|
|
41
|
+
import { tenantDbRunner } from "./tenant-db-runner";
|
|
41
42
|
|
|
42
43
|
// Shared context-building for the event-store-executor CRUD verbs (create/
|
|
43
44
|
// update/delete/forget/restore/list/detail — see event-store-executor-write.ts
|
|
@@ -50,11 +51,11 @@ export type Table = TableColumns<any>;
|
|
|
50
51
|
|
|
51
52
|
// Screen-Filter (Tier 2.7c) — Op-Mapping als Where-Operator. Boot-
|
|
52
53
|
// Validator pinst field-Existenz + filterable + op-vs-Type-Compat.
|
|
53
|
-
// `op` ist auf {eq,ne,lt,gt,in} normalisiert; "in" mit
|
|
54
|
-
// explizit no-match.
|
|
54
|
+
// `op` ist auf {eq,ne,lt,gt,lte,gte,in} normalisiert; "in" mit
|
|
55
|
+
// empty-array ist explizit no-match.
|
|
55
56
|
export function buildFilterWhere(
|
|
56
57
|
field: string,
|
|
57
|
-
op: "eq" | "ne" | "lt" | "gt" | "in",
|
|
58
|
+
op: "eq" | "ne" | "lt" | "gt" | "lte" | "gte" | "in",
|
|
58
59
|
value: unknown,
|
|
59
60
|
): WhereObject | null {
|
|
60
61
|
switch (op) {
|
|
@@ -66,6 +67,10 @@ export function buildFilterWhere(
|
|
|
66
67
|
return { [field]: { lt: value } };
|
|
67
68
|
case "gt":
|
|
68
69
|
return { [field]: { gt: value } };
|
|
70
|
+
case "lte":
|
|
71
|
+
return { [field]: { lte: value } };
|
|
72
|
+
case "gte":
|
|
73
|
+
return { [field]: { gte: value } };
|
|
69
74
|
case "in":
|
|
70
75
|
if (Array.isArray(value) && value.length > 0) {
|
|
71
76
|
return { [field]: value };
|
|
@@ -358,7 +363,7 @@ export function buildExecutorContext(
|
|
|
358
363
|
id: EntityId,
|
|
359
364
|
tenantId: TenantId,
|
|
360
365
|
): Promise<void> {
|
|
361
|
-
if (await isStreamArchived(db
|
|
366
|
+
if (await isStreamArchived(tenantDbRunner(db), tenantId, String(id))) {
|
|
362
367
|
throw new ArchivedStreamError(tenantId, String(id));
|
|
363
368
|
}
|
|
364
369
|
}
|
|
@@ -401,7 +406,9 @@ export function buildExecutorContext(
|
|
|
401
406
|
whereParts.push(shifted.sqlText);
|
|
402
407
|
for (const p of shifted.params) params.push(p);
|
|
403
408
|
const sqlText = `SELECT * FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`;
|
|
404
|
-
return [
|
|
409
|
+
return [
|
|
410
|
+
...(await executeRawQueryRead<Record<string, unknown>>(tenantDbRunner(db), sqlText, params)),
|
|
411
|
+
];
|
|
405
412
|
}
|
|
406
413
|
|
|
407
414
|
return {
|