@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
|
@@ -4,6 +4,7 @@ import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
|
|
|
4
4
|
import { validateBoot as validateBootRaw } from "../boot-validator";
|
|
5
5
|
import { createTenantConfig } from "../config-helpers";
|
|
6
6
|
import { defineFeature } from "../define-feature";
|
|
7
|
+
import { defineEntityListHandler } from "../entity-handlers";
|
|
7
8
|
import { createDerivedField, createEntity, createTextField } from "../factories";
|
|
8
9
|
import { createRegistry } from "../registry";
|
|
9
10
|
import type { ScreenDefinition } from "../types/screen";
|
|
@@ -555,6 +556,155 @@ describe("r.screen() — registration", () => {
|
|
|
555
556
|
});
|
|
556
557
|
});
|
|
557
558
|
|
|
559
|
+
describe("relatedList parentFilter", () => {
|
|
560
|
+
test("rejects parentFilter combined with parentParam on the same section", () => {
|
|
561
|
+
const features = [
|
|
562
|
+
defineFeature("app", (r) => {
|
|
563
|
+
r.queryHandler("foo:detail", z.object({}), async () => ({}), {
|
|
564
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
565
|
+
});
|
|
566
|
+
r.queryHandler(
|
|
567
|
+
"foo:list",
|
|
568
|
+
z.object({ filter: z.unknown().optional() }),
|
|
569
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
570
|
+
{
|
|
571
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
572
|
+
},
|
|
573
|
+
);
|
|
574
|
+
r.screen({
|
|
575
|
+
id: "x",
|
|
576
|
+
type: "projectionDetail",
|
|
577
|
+
query: "app:query:foo:detail",
|
|
578
|
+
layout: {
|
|
579
|
+
sections: [
|
|
580
|
+
{
|
|
581
|
+
kind: "relatedList",
|
|
582
|
+
title: "s",
|
|
583
|
+
query: "app:query:foo:list",
|
|
584
|
+
columns: ["name"],
|
|
585
|
+
parentParam: "orderId",
|
|
586
|
+
parentFilter: { field: "orderId" },
|
|
587
|
+
},
|
|
588
|
+
],
|
|
589
|
+
},
|
|
590
|
+
});
|
|
591
|
+
}),
|
|
592
|
+
];
|
|
593
|
+
expect(() => validateBoot(features)).toThrow(/declares both parentFilter and parentParam/);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test("rejects parentFilter when the bound query's schema has no filter parameter", () => {
|
|
597
|
+
const features = [
|
|
598
|
+
defineFeature("app", (r) => {
|
|
599
|
+
r.queryHandler("foo:detail", z.object({}), async () => ({}), {
|
|
600
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
601
|
+
});
|
|
602
|
+
r.queryHandler("foo:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
|
|
603
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
604
|
+
});
|
|
605
|
+
r.screen({
|
|
606
|
+
id: "x",
|
|
607
|
+
type: "projectionDetail",
|
|
608
|
+
query: "app:query:foo:detail",
|
|
609
|
+
layout: {
|
|
610
|
+
sections: [
|
|
611
|
+
{
|
|
612
|
+
kind: "relatedList",
|
|
613
|
+
title: "s",
|
|
614
|
+
query: "app:query:foo:list",
|
|
615
|
+
columns: ["name"],
|
|
616
|
+
parentFilter: { field: "orderId" },
|
|
617
|
+
},
|
|
618
|
+
],
|
|
619
|
+
},
|
|
620
|
+
});
|
|
621
|
+
}),
|
|
622
|
+
];
|
|
623
|
+
expect(() => validateBoot(features)).toThrow(
|
|
624
|
+
/declares parentFilter but query.*no "filter" parameter/,
|
|
625
|
+
);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
test("rejects parentFilter.field that is not a declared field on the entity behind the query", () => {
|
|
629
|
+
const orderEntity = createEntity({
|
|
630
|
+
table: "orders",
|
|
631
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
632
|
+
});
|
|
633
|
+
const features = [
|
|
634
|
+
defineFeature("app", (r) => {
|
|
635
|
+
r.entity("order", orderEntity);
|
|
636
|
+
r.queryHandler(
|
|
637
|
+
defineEntityListHandler("order", orderEntity, {
|
|
638
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
639
|
+
}),
|
|
640
|
+
);
|
|
641
|
+
r.screen({
|
|
642
|
+
id: "x",
|
|
643
|
+
type: "projectionDetail",
|
|
644
|
+
query: "app:query:foo:detail",
|
|
645
|
+
layout: {
|
|
646
|
+
sections: [
|
|
647
|
+
{
|
|
648
|
+
kind: "relatedList",
|
|
649
|
+
title: "s",
|
|
650
|
+
query: "app:query:order:list",
|
|
651
|
+
columns: ["name"],
|
|
652
|
+
parentFilter: { field: "unknownField" },
|
|
653
|
+
},
|
|
654
|
+
],
|
|
655
|
+
},
|
|
656
|
+
});
|
|
657
|
+
r.queryHandler("foo:detail", z.object({}), async () => ({}), {
|
|
658
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
659
|
+
});
|
|
660
|
+
}),
|
|
661
|
+
];
|
|
662
|
+
expect(() => validateBoot(features)).toThrow(
|
|
663
|
+
/parentFilter\.field "unknownField" is not a declared field/,
|
|
664
|
+
);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test("accepts a valid parentFilter whose field is a real entity field and the query accepts filter", () => {
|
|
668
|
+
const orderEntity = createEntity({
|
|
669
|
+
table: "orders",
|
|
670
|
+
fields: {
|
|
671
|
+
name: createTextField({ personal: false, reason: "test_fixture" }),
|
|
672
|
+
orderId: createTextField({ personal: false, reason: "test_fixture" }),
|
|
673
|
+
},
|
|
674
|
+
});
|
|
675
|
+
const features = [
|
|
676
|
+
defineFeature("app", (r) => {
|
|
677
|
+
r.entity("order", orderEntity);
|
|
678
|
+
r.queryHandler(
|
|
679
|
+
defineEntityListHandler("order", orderEntity, {
|
|
680
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
681
|
+
}),
|
|
682
|
+
);
|
|
683
|
+
r.screen({
|
|
684
|
+
id: "x",
|
|
685
|
+
type: "projectionDetail",
|
|
686
|
+
query: "app:query:foo:detail",
|
|
687
|
+
layout: {
|
|
688
|
+
sections: [
|
|
689
|
+
{
|
|
690
|
+
kind: "relatedList",
|
|
691
|
+
title: "s",
|
|
692
|
+
query: "app:query:order:list",
|
|
693
|
+
columns: ["name"],
|
|
694
|
+
parentFilter: { field: "orderId" },
|
|
695
|
+
},
|
|
696
|
+
],
|
|
697
|
+
},
|
|
698
|
+
});
|
|
699
|
+
r.queryHandler("foo:detail", z.object({}), async () => ({}), {
|
|
700
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
701
|
+
});
|
|
702
|
+
}),
|
|
703
|
+
];
|
|
704
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
|
|
558
708
|
test("validateBoot rejects a relatedList section on entityEdit (fw#2166)", () => {
|
|
559
709
|
const features = [
|
|
560
710
|
defineFeature("shop", (r) => {
|
|
@@ -58,7 +58,6 @@ function makeCtx(opts: { graceDays?: number; calls: DeleteCall[] }): JobContext
|
|
|
58
58
|
// to this recorder instead of trying to extract real table metadata.
|
|
59
59
|
const fakeDb = {
|
|
60
60
|
tenantId: "t1",
|
|
61
|
-
raw: { unsafe: async () => [] },
|
|
62
61
|
selectMany: async () => [],
|
|
63
62
|
fetchOne: async () => undefined,
|
|
64
63
|
insertOne: async () => undefined,
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import type { EscapeHatchReporter } from "@cosmicdrift/kumiko-types/handlers";
|
|
3
|
+
import type { DbRunner } from "../../db/connection";
|
|
2
4
|
import { table, text, uuid } from "../../db/dialect";
|
|
5
|
+
import { createTenantDb, createUncheckedSystemDb, type TenantDb } from "../../db/tenant-db";
|
|
6
|
+
import { AccessDeniedError } from "../../errors";
|
|
7
|
+
import { testTenantId } from "../../stack";
|
|
3
8
|
import { getStep } from "../define-step";
|
|
4
9
|
import { buildReadFindManyStep } from "../steps/read-find-many";
|
|
5
10
|
import { buildReadFindOneStep } from "../steps/read-find-one";
|
|
11
|
+
import { SYSTEM_TENANT_ID } from "../types/identifiers";
|
|
6
12
|
import type { PipelineCtx } from "../types/step";
|
|
7
13
|
|
|
8
14
|
const testTable = table("test_read", {
|
|
@@ -11,14 +17,16 @@ const testTable = table("test_read", {
|
|
|
11
17
|
label: text("label"),
|
|
12
18
|
});
|
|
13
19
|
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
20
|
+
// fw#2914: read-find-many/one now call selectMany(ctx.db, table, where, opts) —
|
|
21
|
+
// ctx.db is a TenantDb, so selectMany's tenantDbDelegate branch runs
|
|
22
|
+
// TenantDb.selectMany (applies the tenant filter) instead of raw SQL. Mock
|
|
23
|
+
// .unsafe() to feed back rows once the (tenant-filtered) query reaches it.
|
|
17
24
|
const unsafeMock = mock(
|
|
18
25
|
async (_sqlText: string, _params: unknown[]): Promise<Record<string, unknown>[]> => [],
|
|
19
26
|
);
|
|
20
|
-
const rawDb = { unsafe: unsafeMock, begin: mock() };
|
|
21
|
-
const
|
|
27
|
+
const rawDb = { unsafe: unsafeMock, begin: mock() } as DbRunner;
|
|
28
|
+
const ownTenantId = testTenantId(1);
|
|
29
|
+
const ctxDb = createTenantDb(rawDb, ownTenantId);
|
|
22
30
|
|
|
23
31
|
const mockCtx = {
|
|
24
32
|
db: ctxDb,
|
|
@@ -27,6 +35,21 @@ const mockCtx = {
|
|
|
27
35
|
scope: {},
|
|
28
36
|
} as unknown as PipelineCtx;
|
|
29
37
|
|
|
38
|
+
const reportCalls: Array<{ kind: string; reason: string }> = [];
|
|
39
|
+
const recordingReport: EscapeHatchReporter = (kind, reason) => {
|
|
40
|
+
reportCalls.push({ kind, reason });
|
|
41
|
+
};
|
|
42
|
+
const grantedDb = createTenantDb(rawDb, ownTenantId, "tenant", undefined, undefined, undefined, {
|
|
43
|
+
unsafeRaw: { reason: "fw#2914 unit test — declared cross-tenant read" },
|
|
44
|
+
report: recordingReport,
|
|
45
|
+
});
|
|
46
|
+
const grantedCtx = {
|
|
47
|
+
db: grantedDb,
|
|
48
|
+
event: { type: "test", payload: {} },
|
|
49
|
+
steps: {},
|
|
50
|
+
scope: {},
|
|
51
|
+
} as unknown as PipelineCtx;
|
|
52
|
+
|
|
30
53
|
describe("buildReadFindOneStep", () => {
|
|
31
54
|
it("returns a StepInstance with kind read.findOne", () => {
|
|
32
55
|
const step = buildReadFindOneStep("myLookup", {
|
|
@@ -51,6 +74,7 @@ describe("read.findOne run", () => {
|
|
|
51
74
|
beforeEach(() => {
|
|
52
75
|
mock.clearAllMocks();
|
|
53
76
|
unsafeMock.mockResolvedValue([]);
|
|
77
|
+
reportCalls.length = 0;
|
|
54
78
|
});
|
|
55
79
|
|
|
56
80
|
it("returns null when no row is found", async () => {
|
|
@@ -78,7 +102,19 @@ describe("read.findOne run", () => {
|
|
|
78
102
|
expect(result).toEqual(row);
|
|
79
103
|
});
|
|
80
104
|
|
|
81
|
-
it("
|
|
105
|
+
it("filters by the caller's own tenant + SYSTEM_TENANT_ID by default", async () => {
|
|
106
|
+
const stepDef = getStep("read.findOne");
|
|
107
|
+
unsafeMock.mockResolvedValueOnce([]);
|
|
108
|
+
|
|
109
|
+
await stepDef!.run({ name: "lookup", table: testTable, where: { id: "x" } }, mockCtx);
|
|
110
|
+
|
|
111
|
+
expect(unsafeMock).toHaveBeenCalledTimes(1);
|
|
112
|
+
const [sqlText, params] = unsafeMock.mock.calls[0]!;
|
|
113
|
+
expect(sqlText).toMatch(/"tenant_id" IN \(\$\d, \$\d\)/);
|
|
114
|
+
expect(params).toEqual(expect.arrayContaining([ownTenantId, SYSTEM_TENANT_ID]));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("narrows a foreign where.tenantId to the caller's own scope", async () => {
|
|
82
118
|
const stepDef = getStep("read.findOne");
|
|
83
119
|
const whereFn = mock(() => ({ tenantId: "dyn-tenant" }));
|
|
84
120
|
unsafeMock.mockResolvedValueOnce([]);
|
|
@@ -89,8 +125,42 @@ describe("read.findOne run", () => {
|
|
|
89
125
|
expect(unsafeMock).toHaveBeenCalledTimes(1);
|
|
90
126
|
const [sqlText, params] = unsafeMock.mock.calls[0]!;
|
|
91
127
|
expect(sqlText).toMatch(/SELECT \* FROM "test_read"/);
|
|
92
|
-
expect(sqlText).toMatch(/"tenant_id"
|
|
93
|
-
expect(params).toEqual([
|
|
128
|
+
expect(sqlText).toMatch(/"tenant_id" IN \(\$1, \$2\)/);
|
|
129
|
+
expect(params).toEqual([ownTenantId, SYSTEM_TENANT_ID]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("rejects unsafeAllTenants without a grant, without ever calling unsafe()", async () => {
|
|
133
|
+
const stepDef = getStep("read.findOne");
|
|
134
|
+
|
|
135
|
+
await expect(
|
|
136
|
+
stepDef!.run(
|
|
137
|
+
{
|
|
138
|
+
name: "lookup",
|
|
139
|
+
table: testTable,
|
|
140
|
+
where: { id: "x" },
|
|
141
|
+
unsafeAllTenants: { reason: "fw#2914 unit test — no grant" },
|
|
142
|
+
},
|
|
143
|
+
mockCtx,
|
|
144
|
+
),
|
|
145
|
+
).rejects.toThrow(AccessDeniedError);
|
|
146
|
+
|
|
147
|
+
expect(unsafeMock).not.toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("unsafeAllTenants with a grant skips the tenant filter and reports unsafe-raw", async () => {
|
|
151
|
+
const stepDef = getStep("read.findOne");
|
|
152
|
+
unsafeMock.mockResolvedValueOnce([]);
|
|
153
|
+
const reason = "fw#2914 unit test — declared cross-tenant read";
|
|
154
|
+
|
|
155
|
+
await stepDef!.run(
|
|
156
|
+
{ name: "lookup", table: testTable, where: { id: "x" }, unsafeAllTenants: { reason } },
|
|
157
|
+
grantedCtx,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
expect(unsafeMock).toHaveBeenCalledTimes(1);
|
|
161
|
+
const [sqlText] = unsafeMock.mock.calls[0]!;
|
|
162
|
+
expect(sqlText).not.toMatch(/tenant_id/);
|
|
163
|
+
expect(reportCalls).toEqual([{ kind: "unsafe-raw", reason }]);
|
|
94
164
|
});
|
|
95
165
|
});
|
|
96
166
|
|
|
@@ -105,12 +175,23 @@ describe("buildReadFindManyStep", () => {
|
|
|
105
175
|
const step = buildReadFindManyStep("myList", { table: testTable, limit: 10 });
|
|
106
176
|
expect((step.args as { limit: number }).limit).toBe(10);
|
|
107
177
|
});
|
|
178
|
+
|
|
179
|
+
it("stores unsafeAllTenants on step.args and rejects a boolean at the type level", () => {
|
|
180
|
+
const reason = "fw#2914 unit test — unsafeAllTenants stored on step.args";
|
|
181
|
+
const step = buildReadFindManyStep("x", { table: testTable, unsafeAllTenants: { reason } });
|
|
182
|
+
expect(step.args).toMatchObject({ unsafeAllTenants: { reason } });
|
|
183
|
+
|
|
184
|
+
// @ts-expect-error unsafeAllTenants requires { reason: string }, not a boolean
|
|
185
|
+
const rejected = buildReadFindManyStep("x", { table: testTable, unsafeAllTenants: true });
|
|
186
|
+
expect(rejected.args).toMatchObject({ unsafeAllTenants: true });
|
|
187
|
+
});
|
|
108
188
|
});
|
|
109
189
|
|
|
110
190
|
describe("read.findMany run", () => {
|
|
111
191
|
beforeEach(() => {
|
|
112
192
|
mock.clearAllMocks();
|
|
113
193
|
unsafeMock.mockResolvedValue([]);
|
|
194
|
+
reportCalls.length = 0;
|
|
114
195
|
});
|
|
115
196
|
|
|
116
197
|
it("returns an empty array when no rows exist", async () => {
|
|
@@ -133,4 +214,119 @@ describe("read.findMany run", () => {
|
|
|
133
214
|
const [sqlText] = unsafeMock.mock.calls[0]!;
|
|
134
215
|
expect(sqlText).toMatch(/LIMIT 2/);
|
|
135
216
|
});
|
|
217
|
+
|
|
218
|
+
it("filters by the caller's own tenant + SYSTEM_TENANT_ID by default", async () => {
|
|
219
|
+
const stepDef = getStep("read.findMany");
|
|
220
|
+
unsafeMock.mockResolvedValueOnce([]);
|
|
221
|
+
|
|
222
|
+
await stepDef!.run({ name: "list", table: testTable }, mockCtx);
|
|
223
|
+
|
|
224
|
+
expect(unsafeMock).toHaveBeenCalledTimes(1);
|
|
225
|
+
const [sqlText, params] = unsafeMock.mock.calls[0]!;
|
|
226
|
+
expect(sqlText).toMatch(/"tenant_id" IN \(\$1, \$2\)/);
|
|
227
|
+
expect(params).toEqual([ownTenantId, SYSTEM_TENANT_ID]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("rejects unsafeAllTenants without a grant, without ever calling unsafe()", async () => {
|
|
231
|
+
const stepDef = getStep("read.findMany");
|
|
232
|
+
|
|
233
|
+
await expect(
|
|
234
|
+
stepDef!.run(
|
|
235
|
+
{
|
|
236
|
+
name: "list",
|
|
237
|
+
table: testTable,
|
|
238
|
+
unsafeAllTenants: { reason: "fw#2914 unit test — no grant" },
|
|
239
|
+
},
|
|
240
|
+
mockCtx,
|
|
241
|
+
),
|
|
242
|
+
).rejects.toThrow(AccessDeniedError);
|
|
243
|
+
|
|
244
|
+
expect(unsafeMock).not.toHaveBeenCalled();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("unsafeAllTenants with a grant skips the tenant filter and reports unsafe-raw", async () => {
|
|
248
|
+
const stepDef = getStep("read.findMany");
|
|
249
|
+
unsafeMock.mockResolvedValueOnce([]);
|
|
250
|
+
const reason = "fw#2914 unit test — declared cross-tenant read";
|
|
251
|
+
|
|
252
|
+
await stepDef!.run(
|
|
253
|
+
{ name: "list", table: testTable, unsafeAllTenants: { reason } },
|
|
254
|
+
grantedCtx,
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
expect(unsafeMock).toHaveBeenCalledTimes(1);
|
|
258
|
+
const [sqlText] = unsafeMock.mock.calls[0]!;
|
|
259
|
+
expect(sqlText).not.toMatch(/tenant_id/);
|
|
260
|
+
expect(reportCalls).toEqual([{ kind: "unsafe-raw", reason }]);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("read steps in a systemScope handler", () => {
|
|
265
|
+
const systemScopeReportCalls: Array<{ kind: string; reason: string }> = [];
|
|
266
|
+
const systemScopeReporter: EscapeHatchReporter = (kind, reason) => {
|
|
267
|
+
systemScopeReportCalls.push({ kind, reason });
|
|
268
|
+
};
|
|
269
|
+
// Mirrors createSystemScopedDbGuard (pipeline/dispatch-shared.ts) — a
|
|
270
|
+
// systemScope() handler's ctx.db is a Proxy that throws on first touch.
|
|
271
|
+
const throwingDb = new Proxy({} as TenantDb, {
|
|
272
|
+
get(_target, prop) {
|
|
273
|
+
throw new Error(`ctx.db is unavailable in a systemScope() handler (read "${String(prop)}")`);
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
const systemScopeCtx = {
|
|
277
|
+
db: throwingDb,
|
|
278
|
+
systemDb: createUncheckedSystemDb(
|
|
279
|
+
createTenantDb(rawDb, testTenantId(1), "system"),
|
|
280
|
+
undefined,
|
|
281
|
+
systemScopeReporter,
|
|
282
|
+
),
|
|
283
|
+
event: { type: "test", payload: {} },
|
|
284
|
+
steps: {},
|
|
285
|
+
scope: {},
|
|
286
|
+
} as unknown as PipelineCtx;
|
|
287
|
+
|
|
288
|
+
beforeEach(() => {
|
|
289
|
+
mock.clearAllMocks();
|
|
290
|
+
unsafeMock.mockResolvedValue([]);
|
|
291
|
+
systemScopeReportCalls.length = 0;
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("findMany with unsafeAllTenants reads through ctx.systemDb, skips the tenant filter, and reports unsafe-raw", async () => {
|
|
295
|
+
const stepDef = getStep("read.findMany");
|
|
296
|
+
unsafeMock.mockResolvedValueOnce([]);
|
|
297
|
+
const reason = "fw#2914 unit test — system-wide read step";
|
|
298
|
+
|
|
299
|
+
await stepDef!.run(
|
|
300
|
+
{ name: "list", table: testTable, unsafeAllTenants: { reason } },
|
|
301
|
+
systemScopeCtx,
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
expect(unsafeMock).toHaveBeenCalledTimes(1);
|
|
305
|
+
const [sqlText] = unsafeMock.mock.calls[0]!;
|
|
306
|
+
expect(sqlText).not.toMatch(/tenant_id/);
|
|
307
|
+
expect(systemScopeReportCalls).toEqual([{ kind: "unsafe-raw", reason }]);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("findMany without unsafeAllTenants falls through to the throwing ctx.db proxy", async () => {
|
|
311
|
+
const stepDef = getStep("read.findMany");
|
|
312
|
+
|
|
313
|
+
await expect(
|
|
314
|
+
stepDef!.run({ name: "list", table: testTable }, systemScopeCtx),
|
|
315
|
+
).rejects.toThrow();
|
|
316
|
+
|
|
317
|
+
expect(unsafeMock).not.toHaveBeenCalled();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("findMany with a whitespace-only reason is rejected by systemDb.unsafeRaw before any query runs", async () => {
|
|
321
|
+
const stepDef = getStep("read.findMany");
|
|
322
|
+
|
|
323
|
+
await expect(
|
|
324
|
+
stepDef!.run(
|
|
325
|
+
{ name: "list", table: testTable, unsafeAllTenants: { reason: " " } },
|
|
326
|
+
systemScopeCtx,
|
|
327
|
+
),
|
|
328
|
+
).rejects.toThrow("non-empty reason");
|
|
329
|
+
|
|
330
|
+
expect(unsafeMock).not.toHaveBeenCalled();
|
|
331
|
+
});
|
|
136
332
|
});
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import type { DbRunner } from "../../db/connection";
|
|
2
3
|
import { table, text, uuid } from "../../db/dialect";
|
|
4
|
+
import { createTenantDb } from "../../db/tenant-db";
|
|
5
|
+
import { testTenantId } from "../../stack";
|
|
3
6
|
import { getStep } from "../define-step";
|
|
4
7
|
import { buildUnsafeProjectionDeleteStep } from "../steps/unsafe-projection-delete";
|
|
5
8
|
import type { PipelineCtx } from "../types/step";
|
|
@@ -10,11 +13,11 @@ const testTable = table("test_projection", {
|
|
|
10
13
|
label: text("label"),
|
|
11
14
|
});
|
|
12
15
|
|
|
13
|
-
// bun-db path: step calls deleteMany(ctx.db
|
|
14
|
-
// asRawClient(
|
|
16
|
+
// bun-db path: step calls deleteMany(tenantDbRunner(ctx.db), table, where) which
|
|
17
|
+
// lands on asRawClient(runner).unsafe(sqlText, params).
|
|
15
18
|
const unsafeMock = mock(async (_sqlText: string, _params: unknown[]): Promise<unknown[]> => []);
|
|
16
|
-
const rawDb = { unsafe: unsafeMock, begin: mock() };
|
|
17
|
-
const ctxDb =
|
|
19
|
+
const rawDb = { unsafe: unsafeMock, begin: mock() } as DbRunner;
|
|
20
|
+
const ctxDb = createTenantDb(rawDb, testTenantId(1));
|
|
18
21
|
|
|
19
22
|
const mockCtx = {
|
|
20
23
|
db: ctxDb,
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import type { DbRunner } from "../../db/connection";
|
|
2
3
|
import { table, text, uuid } from "../../db/dialect";
|
|
4
|
+
import { createTenantDb } from "../../db/tenant-db";
|
|
5
|
+
import { testTenantId } from "../../stack";
|
|
3
6
|
import { getStep } from "../define-step";
|
|
4
7
|
import { buildUnsafeProjectionUpsertStep } from "../steps/unsafe-projection-upsert";
|
|
5
8
|
import type { PipelineCtx } from "../types/step";
|
|
@@ -11,13 +14,13 @@ const testTable = table("test_projection", {
|
|
|
11
14
|
label: text("label"),
|
|
12
15
|
});
|
|
13
16
|
|
|
14
|
-
// New bun-db path: step uses asRawClient(ctx.db
|
|
17
|
+
// New bun-db path: step uses asRawClient(tenantDbRunner(ctx.db)).unsafe(sqlText, params).
|
|
15
18
|
// Capture the raw SQL string + params per call instead of the old
|
|
16
19
|
// insert/values/onConflictDoUpdate chain.
|
|
17
20
|
const unsafeMock = mock(async (_sqlText: string, _params: unknown[]) => []);
|
|
18
21
|
const beginMock = mock(async (fn: (tx: unknown) => Promise<unknown>) => fn({}));
|
|
19
|
-
const rawDb = { unsafe: unsafeMock, begin: beginMock };
|
|
20
|
-
const ctxDb =
|
|
22
|
+
const rawDb = { unsafe: unsafeMock, begin: beginMock } as DbRunner;
|
|
23
|
+
const ctxDb = createTenantDb(rawDb, testTenantId(1));
|
|
21
24
|
|
|
22
25
|
const mockCtx = {
|
|
23
26
|
db: ctxDb,
|
|
@@ -30,7 +30,21 @@ describe("validateGlobalTenancyEntities", () => {
|
|
|
30
30
|
expect(() => validateGlobalTenancyEntities([feature])).toThrow(/systemStream/);
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
-
test("a tenancy: 'global' entity WITH systemStream: true
|
|
33
|
+
test("a tenancy: 'global' entity WITH systemStream: true but WITHOUT r.systemScope() throws", () => {
|
|
34
|
+
const entity = createEntity({
|
|
35
|
+
table: "read_fw2860_global_no_scope",
|
|
36
|
+
tenancy: "global",
|
|
37
|
+
systemStream: true,
|
|
38
|
+
fields: { name: textField() },
|
|
39
|
+
});
|
|
40
|
+
const feature = defineFeature("fw2860-global-no-scope", (r) => {
|
|
41
|
+
r.entity("fw2860GlobalNoScope", entity);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(() => validateGlobalTenancyEntities([feature])).toThrow(/systemScope/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a tenancy: 'global' entity WITH systemStream: true AND r.systemScope() passes", () => {
|
|
34
48
|
const entity = createEntity({
|
|
35
49
|
table: "read_fw2858_global_with_stream",
|
|
36
50
|
tenancy: "global",
|
|
@@ -38,6 +52,7 @@ describe("validateGlobalTenancyEntities", () => {
|
|
|
38
52
|
fields: { name: textField() },
|
|
39
53
|
});
|
|
40
54
|
const feature = defineFeature("fw2858-global-with-stream", (r) => {
|
|
55
|
+
r.systemScope();
|
|
41
56
|
r.entity("fw2858GlobalWithStream", entity);
|
|
42
57
|
});
|
|
43
58
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { defineFeature } from "../../define-feature";
|
|
4
|
+
import { validateHandlerAccess } from "../entity-handler";
|
|
5
|
+
|
|
6
|
+
describe("validateHandlerAccess — rateLimit: { disabled: true } reason", () => {
|
|
7
|
+
test("an empty reason throws, naming the handler", () => {
|
|
8
|
+
const feature = defineFeature("rl-guard", (r) => {
|
|
9
|
+
r.writeHandler(
|
|
10
|
+
"note:create",
|
|
11
|
+
z.object({ title: z.string() }),
|
|
12
|
+
async () => ({
|
|
13
|
+
isSuccess: true as const,
|
|
14
|
+
data: {},
|
|
15
|
+
}),
|
|
16
|
+
{
|
|
17
|
+
access: { roles: ["Admin"] },
|
|
18
|
+
rateLimit: { disabled: true, reason: "" },
|
|
19
|
+
},
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(() => validateHandlerAccess(feature)).toThrow(/rl-guard:write:note:create/);
|
|
24
|
+
expect(() => validateHandlerAccess(feature)).toThrow(/rateLimit: \{ disabled: true \}/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("a non-empty reason boots fine", () => {
|
|
28
|
+
const feature = defineFeature("rl-guard", (r) => {
|
|
29
|
+
r.writeHandler(
|
|
30
|
+
"note:create",
|
|
31
|
+
z.object({ title: z.string() }),
|
|
32
|
+
async () => ({
|
|
33
|
+
isSuccess: true as const,
|
|
34
|
+
data: {},
|
|
35
|
+
}),
|
|
36
|
+
{
|
|
37
|
+
access: { roles: ["Admin"] },
|
|
38
|
+
rateLimit: { disabled: true, reason: "ops replay path" },
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(() => validateHandlerAccess(feature)).not.toThrow();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { BlurRegion, VariantSpec } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
2
|
+
import { isRateLimitDisabled } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
3
|
import { VARIANT_NAME_PATTERN } from "../../derivatives/variant-key";
|
|
3
4
|
import { parseRefTarget } from "../parse-ref-target";
|
|
4
5
|
import type {
|
|
@@ -147,10 +148,28 @@ export function validateHandlerAccess(feature: FeatureDefinition): void {
|
|
|
147
148
|
);
|
|
148
149
|
}
|
|
149
150
|
validateAnonymousRateLimit(feature.name, kind, name, handler.access, handler.rateLimit);
|
|
151
|
+
validateRateLimitDisabledReason(feature.name, kind, name, handler.rateLimit);
|
|
150
152
|
}
|
|
151
153
|
}
|
|
152
154
|
}
|
|
153
155
|
|
|
156
|
+
export function validateRateLimitDisabledReason(
|
|
157
|
+
featureName: string,
|
|
158
|
+
kind: "write" | "query" | "stream",
|
|
159
|
+
handlerName: string,
|
|
160
|
+
rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"],
|
|
161
|
+
): void {
|
|
162
|
+
// skip: rate limit isn't disabled, so no reason is required
|
|
163
|
+
if (!isRateLimitDisabled(rateLimit)) return;
|
|
164
|
+
if (rateLimit.reason.trim().length === 0) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`${kind} handler "${featureName}:${kind}:${handlerName}" declares rateLimit: { disabled: true } ` +
|
|
167
|
+
`without a reason. Provide { disabled: true, reason: "..." } explaining why this handler must not ` +
|
|
168
|
+
`be rate-limited.`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
154
173
|
export function validateAnonymousRateLimit(
|
|
155
174
|
featureName: string,
|
|
156
175
|
kind: "write" | "query" | "stream",
|
|
@@ -160,6 +179,8 @@ export function validateAnonymousRateLimit(
|
|
|
160
179
|
): void {
|
|
161
180
|
// skip: handler doesn't opt into rate-limit, no user-bucket risk
|
|
162
181
|
if (!rateLimit) return;
|
|
182
|
+
// skip: disabled declarations carry no `.per` to bucket on
|
|
183
|
+
if (isRateLimitDisabled(rateLimit)) return;
|
|
163
184
|
// skip: openToAll handlers don't allow anonymous (hasAccess rejects), so
|
|
164
185
|
// the user-bucket footgun doesn't apply
|
|
165
186
|
if (!("roles" in access)) return;
|
|
@@ -2,16 +2,28 @@ import type { FeatureDefinition } from "../types";
|
|
|
2
2
|
|
|
3
3
|
// Boot-time twin of event-store-executor-context.ts's buildExecutorContext check —
|
|
4
4
|
// surfaces the systemStream invariant once across all features before any dispatch.
|
|
5
|
+
//
|
|
6
|
+
// A "global" entity also requires r.systemScope() — otherwise convention writes
|
|
7
|
+
// dispatch in tenant-mode and a global row could carry the creating tenant's id.
|
|
5
8
|
export function validateGlobalTenancyEntities(features: readonly FeatureDefinition[]): void {
|
|
6
9
|
for (const feature of features) {
|
|
7
10
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
8
|
-
if (entity.tenancy
|
|
11
|
+
if (entity.tenancy !== "global") continue;
|
|
12
|
+
if (entity.systemStream !== true) {
|
|
9
13
|
throw new Error(
|
|
10
14
|
`Feature "${feature.name}" entity "${entityName}": tenancy: "global" entities must ` +
|
|
11
15
|
"also declare systemStream: true — a global aggregate's event stream must live on " +
|
|
12
16
|
"the system tenant, not the creating tenant's.",
|
|
13
17
|
);
|
|
14
18
|
}
|
|
19
|
+
if (!feature.systemScope) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`Feature "${feature.name}" entity "${entityName}": tenancy: "global" entities require ` +
|
|
22
|
+
"their owning feature to declare r.systemScope() — without it, convention writes run " +
|
|
23
|
+
"in tenant-mode and a global row could end up carrying the creating tenant's id " +
|
|
24
|
+
"instead of the system tenant's.",
|
|
25
|
+
);
|
|
26
|
+
}
|
|
15
27
|
}
|
|
16
28
|
}
|
|
17
29
|
}
|
|
@@ -11,7 +11,7 @@ import type { EntityDefinition, FeatureDefinition, QueryHandlerDef } from "../ty
|
|
|
11
11
|
// framework-internal detail() call that doesn't wire it, so the enforcement
|
|
12
12
|
// has to happen loudly here instead, once, at boot.
|
|
13
13
|
|
|
14
|
-
function findEntity(
|
|
14
|
+
export function findEntity(
|
|
15
15
|
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
16
16
|
entityName: string,
|
|
17
17
|
): EntityDefinition | undefined {
|