@cosmicdrift/kumiko-framework 0.270.0 → 0.273.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 +91 -0
- 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/event-store-executor-context.ts +5 -2
- package/src/db/event-store-executor-read.ts +6 -4
- 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 +51 -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.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 +67 -2
- 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 +71 -19
- package/src/engine/feature-entity-handlers.ts +4 -4
- package/src/engine/index.ts +5 -0
- package/src/engine/registry-validate.ts +12 -4
- package/src/engine/schema-builder.ts +6 -0
- 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,19 +1,23 @@
|
|
|
1
|
-
// fw#2650 — crossTenant on
|
|
2
|
-
// row that lives in a different tenant
|
|
3
|
-
// two things: (1) an unfiltered db
|
|
4
|
-
// one (so the row is even visible),
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
1
|
+
// fw#2650/fw#2915 — crossTenant / escapeHatch on entity convention handlers. A
|
|
2
|
+
// SystemAdmin operator reads/writes a row that lives in a different tenant
|
|
3
|
+
// than their own session. That needs two things: (1) an unfiltered db
|
|
4
|
+
// instead of the caller's tenant-scoped one (so the row is even visible),
|
|
5
|
+
// and (2) the event-store stream AND the entity's tenant-based
|
|
6
|
+
// write-ownership rule — both keyed off the ACTING user, not the db — so the
|
|
7
|
+
// acting user's tenantId must be rewritten to the target row's tenant before
|
|
8
|
+
// the write, not just handed an unfiltered db.
|
|
8
9
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
10
|
+
// Three stacks on the SAME Postgres database: "none" (no cross-tenant
|
|
11
|
+
// option), "legacy" (`crossTenant: true`), "escapeHatch"
|
|
12
|
+
// (`escapeHatch: { reason }`) — a clean A/B/C on the one option, no
|
|
13
|
+
// handler-name collisions to work around.
|
|
12
14
|
|
|
13
15
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
16
|
+
import type { EscapeHatchUseEvent } from "@cosmicdrift/kumiko-types/handlers";
|
|
14
17
|
import { selectMany } from "../../db/query";
|
|
15
18
|
import { buildEntityTable } from "../../db/table-builder";
|
|
16
19
|
import { eventsTable } from "../../event-store";
|
|
20
|
+
import type { Logger } from "../../logging/types";
|
|
17
21
|
import {
|
|
18
22
|
setupTestStack,
|
|
19
23
|
type TestStack,
|
|
@@ -21,10 +25,12 @@ import {
|
|
|
21
25
|
testTenantId,
|
|
22
26
|
unsafeCreateEntityTable,
|
|
23
27
|
} from "../../stack";
|
|
28
|
+
import { DEPRECATED_CROSS_TENANT_SIGNAL } from "../entity-handlers";
|
|
24
29
|
import {
|
|
25
30
|
createEntity,
|
|
26
31
|
createTextField,
|
|
27
32
|
defineEntityCreateHandler,
|
|
33
|
+
defineEntityListHandler,
|
|
28
34
|
defineEntityUpdateHandler,
|
|
29
35
|
defineFeature,
|
|
30
36
|
from,
|
|
@@ -32,9 +38,9 @@ import {
|
|
|
32
38
|
|
|
33
39
|
// "Admin" passes unconditionally (any tenant may create its own rows).
|
|
34
40
|
// "SystemAdmin" is tenant-scoped — this is the entity-level rule the
|
|
35
|
-
// crossTenant acting-user rewrite must satisfy for the operator
|
|
36
|
-
// go through; without the rewrite the acting user's own tenant
|
|
37
|
-
// match a foreign row's tenantId here.
|
|
41
|
+
// crossTenant/escapeHatch acting-user rewrite must satisfy for the operator
|
|
42
|
+
// write to go through; without the rewrite the acting user's own tenant
|
|
43
|
+
// would never match a foreign row's tenantId here.
|
|
38
44
|
const thingEntity = createEntity({
|
|
39
45
|
table: "ctwrite_things",
|
|
40
46
|
fields: {
|
|
@@ -44,7 +50,22 @@ const thingEntity = createEntity({
|
|
|
44
50
|
});
|
|
45
51
|
const thingTable = buildEntityTable("thing", thingEntity);
|
|
46
52
|
|
|
47
|
-
|
|
53
|
+
const ESCAPE_HATCH_REASON = "fw#2915 integration test — operator cross-tenant scan";
|
|
54
|
+
|
|
55
|
+
type CrossTenantMode = "none" | "legacy" | "escapeHatch";
|
|
56
|
+
|
|
57
|
+
function crossTenantOptionsFor(mode: CrossTenantMode) {
|
|
58
|
+
switch (mode) {
|
|
59
|
+
case "legacy":
|
|
60
|
+
return { crossTenant: true as const };
|
|
61
|
+
case "escapeHatch":
|
|
62
|
+
return { escapeHatch: { reason: ESCAPE_HATCH_REASON } };
|
|
63
|
+
case "none":
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildFeature(mode: CrossTenantMode) {
|
|
48
69
|
return defineFeature("ctwrite", (r) => {
|
|
49
70
|
r.entity("thing", thingEntity);
|
|
50
71
|
r.writeHandler(
|
|
@@ -55,7 +76,13 @@ function buildFeature(crossTenant: boolean) {
|
|
|
55
76
|
r.writeHandler(
|
|
56
77
|
defineEntityUpdateHandler("thing", thingEntity, {
|
|
57
78
|
access: { roles: ["SystemAdmin"] },
|
|
58
|
-
...(
|
|
79
|
+
...crossTenantOptionsFor(mode),
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
r.queryHandler(
|
|
83
|
+
defineEntityListHandler("thing", thingEntity, {
|
|
84
|
+
access: { roles: ["SystemAdmin"] },
|
|
85
|
+
...crossTenantOptionsFor(mode),
|
|
59
86
|
}),
|
|
60
87
|
);
|
|
61
88
|
});
|
|
@@ -63,48 +90,81 @@ function buildFeature(crossTenant: boolean) {
|
|
|
63
90
|
|
|
64
91
|
const dbName = `kumiko_test_ctwrite_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`;
|
|
65
92
|
|
|
66
|
-
|
|
67
|
-
|
|
93
|
+
function recordingSink(events: EscapeHatchUseEvent[]) {
|
|
94
|
+
return async (event: EscapeHatchUseEvent) => {
|
|
95
|
+
events.push(event);
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function recordingLogger(sink: Array<{ msg: string; data?: Record<string, unknown> }>): Logger {
|
|
100
|
+
const log: Logger = {
|
|
101
|
+
info() {},
|
|
102
|
+
debug() {},
|
|
103
|
+
warn(msg, data) {
|
|
104
|
+
sink.push({ msg, data });
|
|
105
|
+
},
|
|
106
|
+
error() {},
|
|
107
|
+
child() {
|
|
108
|
+
return log;
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
return log;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let noneStack: TestStack;
|
|
115
|
+
let legacyStack: TestStack;
|
|
116
|
+
let escapeHatchStack: TestStack;
|
|
117
|
+
|
|
118
|
+
const legacyWarnCalls: Array<{ msg: string; data?: Record<string, unknown> }> = [];
|
|
119
|
+
const escapeHatchWarnCalls: Array<{ msg: string; data?: Record<string, unknown> }> = [];
|
|
120
|
+
const escapeHatchAuditEvents: EscapeHatchUseEvent[] = [];
|
|
68
121
|
|
|
69
122
|
beforeAll(async () => {
|
|
70
|
-
|
|
71
|
-
await unsafeCreateEntityTable(
|
|
72
|
-
// Second
|
|
73
|
-
// cleanup() only closes the pool;
|
|
74
|
-
// flag) owns the actual DROP DATABASE and must clean up last.
|
|
75
|
-
|
|
76
|
-
features: [buildFeature(
|
|
123
|
+
noneStack = await setupTestStack({ features: [buildFeature("none")], dbName });
|
|
124
|
+
await unsafeCreateEntityTable(noneStack.db, thingEntity, "thing");
|
|
125
|
+
// Second and third connections to the SAME database — persistentDb:true
|
|
126
|
+
// means their cleanup() only closes the pool; noneStack (created without
|
|
127
|
+
// that flag) owns the actual DROP DATABASE and must clean up last.
|
|
128
|
+
legacyStack = await setupTestStack({
|
|
129
|
+
features: [buildFeature("legacy")],
|
|
130
|
+
dbName,
|
|
131
|
+
persistentDb: true,
|
|
132
|
+
extraContext: { log: recordingLogger(legacyWarnCalls) },
|
|
133
|
+
});
|
|
134
|
+
escapeHatchStack = await setupTestStack({
|
|
135
|
+
features: [buildFeature("escapeHatch")],
|
|
77
136
|
dbName,
|
|
78
137
|
persistentDb: true,
|
|
138
|
+
extraContext: {
|
|
139
|
+
_escapeHatchAuditSink: recordingSink(escapeHatchAuditEvents),
|
|
140
|
+
log: recordingLogger(escapeHatchWarnCalls),
|
|
141
|
+
},
|
|
79
142
|
});
|
|
80
143
|
});
|
|
81
144
|
|
|
82
145
|
afterAll(async () => {
|
|
83
|
-
await
|
|
84
|
-
await
|
|
146
|
+
await escapeHatchStack.cleanup();
|
|
147
|
+
await legacyStack.cleanup();
|
|
148
|
+
await noneStack.cleanup();
|
|
85
149
|
});
|
|
86
150
|
|
|
87
151
|
async function updatedEventTenantId(aggregateId: string): Promise<string | undefined> {
|
|
88
|
-
const rows = await selectMany<{ type: string; tenantId: string }>(
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
{
|
|
92
|
-
aggregateId,
|
|
93
|
-
},
|
|
94
|
-
);
|
|
152
|
+
const rows = await selectMany<{ type: string; tenantId: string }>(noneStack.db, eventsTable, {
|
|
153
|
+
aggregateId,
|
|
154
|
+
});
|
|
95
155
|
const updatedRow = rows.find((r) => r.type.includes("updated"));
|
|
96
156
|
return updatedRow?.tenantId;
|
|
97
157
|
}
|
|
98
158
|
|
|
99
|
-
describe("entity write handlers: crossTenant
|
|
100
|
-
test("crossTenant handler: SystemAdmin updates a row owned by another tenant", async () => {
|
|
101
|
-
const created = await
|
|
159
|
+
describe("entity write/list handlers: crossTenant vs. escapeHatch (fw#2650/fw#2915)", () => {
|
|
160
|
+
test("legacy crossTenant handler: SystemAdmin updates a row owned by another tenant", async () => {
|
|
161
|
+
const created = await noneStack.http.writeOk<{ id: string }>(
|
|
102
162
|
"ctwrite:write:thing:create",
|
|
103
163
|
{ label: "foreign" },
|
|
104
164
|
TestUsers.otherTenant,
|
|
105
165
|
);
|
|
106
166
|
|
|
107
|
-
const updated = await
|
|
167
|
+
const updated = await legacyStack.http.writeOk<{ id: string; data: { label: string } }>(
|
|
108
168
|
"ctwrite:write:thing:update",
|
|
109
169
|
{ id: created.id, version: 1, changes: { label: "touched by operator" } },
|
|
110
170
|
TestUsers.systemAdmin,
|
|
@@ -113,7 +173,7 @@ describe("entity write handlers: crossTenant option (fw#2650)", () => {
|
|
|
113
173
|
|
|
114
174
|
// Row stays on its original tenant — crossTenant reaches across the
|
|
115
175
|
// tenant boundary, it does not migrate the row.
|
|
116
|
-
const rows = await selectMany(
|
|
176
|
+
const rows = await selectMany(noneStack.db, thingTable, { id: created.id });
|
|
117
177
|
expect(rows[0]?.["tenantId"]).toBe(testTenantId(2));
|
|
118
178
|
|
|
119
179
|
// The append must land on the row's own tenant stream, not the acting
|
|
@@ -123,36 +183,135 @@ describe("entity write handlers: crossTenant option (fw#2650)", () => {
|
|
|
123
183
|
expect(await updatedEventTenantId(created.id)).toBe(testTenantId(2));
|
|
124
184
|
});
|
|
125
185
|
|
|
186
|
+
test("escapeHatch handler: SystemAdmin updates a row owned by another tenant, reported as acknowledge-cross-tenant", async () => {
|
|
187
|
+
escapeHatchAuditEvents.length = 0;
|
|
188
|
+
const created = await noneStack.http.writeOk<{ id: string }>(
|
|
189
|
+
"ctwrite:write:thing:create",
|
|
190
|
+
{ label: "foreign-escape-hatch" },
|
|
191
|
+
TestUsers.otherTenant,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const updated = await escapeHatchStack.http.writeOk<{ id: string; data: { label: string } }>(
|
|
195
|
+
"ctwrite:write:thing:update",
|
|
196
|
+
{ id: created.id, version: 1, changes: { label: "touched by operator via escapeHatch" } },
|
|
197
|
+
TestUsers.systemAdmin,
|
|
198
|
+
);
|
|
199
|
+
expect(updated.data.label).toBe("touched by operator via escapeHatch");
|
|
200
|
+
|
|
201
|
+
const rows = await selectMany(noneStack.db, thingTable, { id: created.id });
|
|
202
|
+
expect(rows[0]?.["tenantId"]).toBe(testTenantId(2));
|
|
203
|
+
expect(await updatedEventTenantId(created.id)).toBe(testTenantId(2));
|
|
204
|
+
|
|
205
|
+
const matches = escapeHatchAuditEvents.filter((e) => e.kind === "acknowledge-cross-tenant");
|
|
206
|
+
expect(matches).toEqual([
|
|
207
|
+
{
|
|
208
|
+
handler: "ctwrite:write:thing:update",
|
|
209
|
+
kind: "acknowledge-cross-tenant",
|
|
210
|
+
reason: ESCAPE_HATCH_REASON,
|
|
211
|
+
tenantId: TestUsers.systemAdmin.tenantId,
|
|
212
|
+
actor: TestUsers.systemAdmin.id,
|
|
213
|
+
},
|
|
214
|
+
]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("escapeHatch list: SystemAdmin sees rows from every tenant, reported as acknowledge-cross-tenant", async () => {
|
|
218
|
+
escapeHatchAuditEvents.length = 0;
|
|
219
|
+
await noneStack.http.writeOk(
|
|
220
|
+
"ctwrite:write:thing:create",
|
|
221
|
+
{ label: "own-tenant-row" },
|
|
222
|
+
TestUsers.admin,
|
|
223
|
+
);
|
|
224
|
+
await noneStack.http.writeOk(
|
|
225
|
+
"ctwrite:write:thing:create",
|
|
226
|
+
{ label: "other-tenant-row" },
|
|
227
|
+
TestUsers.otherTenant,
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const list = await escapeHatchStack.http.queryOk<{
|
|
231
|
+
rows: ReadonlyArray<{ tenantId: string }>;
|
|
232
|
+
}>("ctwrite:query:thing:list", {}, TestUsers.systemAdmin);
|
|
233
|
+
const tenantIds = new Set(list.rows.map((r) => r.tenantId));
|
|
234
|
+
expect(tenantIds.has(testTenantId(1))).toBe(true);
|
|
235
|
+
expect(tenantIds.has(testTenantId(2))).toBe(true);
|
|
236
|
+
|
|
237
|
+
const matches = escapeHatchAuditEvents.filter((e) => e.kind === "acknowledge-cross-tenant");
|
|
238
|
+
expect(matches).toEqual([
|
|
239
|
+
{
|
|
240
|
+
handler: "ctwrite:query:thing:list",
|
|
241
|
+
kind: "acknowledge-cross-tenant",
|
|
242
|
+
reason: ESCAPE_HATCH_REASON,
|
|
243
|
+
tenantId: TestUsers.systemAdmin.tenantId,
|
|
244
|
+
actor: TestUsers.systemAdmin.id,
|
|
245
|
+
},
|
|
246
|
+
]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("none: list returns only the caller's own tenant rows", async () => {
|
|
250
|
+
const created = await noneStack.http.writeOk<{ id: string }>(
|
|
251
|
+
"ctwrite:write:thing:create",
|
|
252
|
+
{ label: "none-mode-own-tenant" },
|
|
253
|
+
TestUsers.admin,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const list = await noneStack.http.queryOk<{
|
|
257
|
+
rows: ReadonlyArray<{ id: string; tenantId: string }>;
|
|
258
|
+
}>("ctwrite:query:thing:list", {}, TestUsers.systemAdmin);
|
|
259
|
+
expect(list.rows.some((r) => r.id === created.id)).toBe(true);
|
|
260
|
+
expect(list.rows.every((r) => r.tenantId === testTenantId(1))).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
|
|
126
263
|
test("normal (non-crossTenant) handler cannot reach a row in another tenant", async () => {
|
|
127
|
-
const created = await
|
|
264
|
+
const created = await noneStack.http.writeOk<{ id: string }>(
|
|
128
265
|
"ctwrite:write:thing:create",
|
|
129
266
|
{ label: "foreign-untouched" },
|
|
130
267
|
TestUsers.otherTenant,
|
|
131
268
|
);
|
|
132
269
|
|
|
133
|
-
const err = await
|
|
270
|
+
const err = await noneStack.http.writeErr(
|
|
134
271
|
"ctwrite:write:thing:update",
|
|
135
272
|
{ id: created.id, version: 1, changes: { label: "nope" } },
|
|
136
273
|
TestUsers.systemAdmin,
|
|
137
274
|
);
|
|
138
275
|
expect(err.code).toBe("not_found");
|
|
139
276
|
|
|
140
|
-
const rows = await selectMany(
|
|
277
|
+
const rows = await selectMany(noneStack.db, thingTable, { id: created.id });
|
|
141
278
|
expect(rows[0]?.["label"]).toBe("foreign-untouched");
|
|
142
279
|
});
|
|
143
280
|
|
|
144
|
-
test("crossTenant still
|
|
145
|
-
const
|
|
281
|
+
test("crossTenant/escapeHatch still gate on access — a role without SystemAdmin is denied", async () => {
|
|
282
|
+
const createdLegacy = await noneStack.http.writeOk<{ id: string }>(
|
|
146
283
|
"ctwrite:write:thing:create",
|
|
147
|
-
{ label: "foreign-denied" },
|
|
284
|
+
{ label: "foreign-denied-legacy" },
|
|
148
285
|
TestUsers.otherTenant,
|
|
149
286
|
);
|
|
287
|
+
const errLegacy = await legacyStack.http.writeErr(
|
|
288
|
+
"ctwrite:write:thing:update",
|
|
289
|
+
{ id: createdLegacy.id, version: 1, changes: { label: "nope" } },
|
|
290
|
+
TestUsers.admin,
|
|
291
|
+
);
|
|
292
|
+
expect(errLegacy.code).toBe("access_denied");
|
|
150
293
|
|
|
151
|
-
const
|
|
294
|
+
const createdEscapeHatch = await noneStack.http.writeOk<{ id: string }>(
|
|
295
|
+
"ctwrite:write:thing:create",
|
|
296
|
+
{ label: "foreign-denied-escape-hatch" },
|
|
297
|
+
TestUsers.otherTenant,
|
|
298
|
+
);
|
|
299
|
+
const errEscapeHatch = await escapeHatchStack.http.writeErr(
|
|
152
300
|
"ctwrite:write:thing:update",
|
|
153
|
-
{ id:
|
|
301
|
+
{ id: createdEscapeHatch.id, version: 1, changes: { label: "nope" } },
|
|
154
302
|
TestUsers.admin,
|
|
155
303
|
);
|
|
156
|
-
expect(
|
|
304
|
+
expect(errEscapeHatch.code).toBe("access_denied");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("legacy stack warns once per deprecated crossTenant handler at boot; the escapeHatch stack does not", () => {
|
|
308
|
+
const legacyMatches = legacyWarnCalls.filter((c) => c.msg === DEPRECATED_CROSS_TENANT_SIGNAL);
|
|
309
|
+
const legacyHandlers = legacyMatches.map((c) => c.data?.["handler"]).sort();
|
|
310
|
+
expect(legacyHandlers).toEqual(["ctwrite:query:thing:list", "ctwrite:write:thing:update"]);
|
|
311
|
+
|
|
312
|
+
const escapeHatchMatches = escapeHatchWarnCalls.filter(
|
|
313
|
+
(c) => c.msg === DEPRECATED_CROSS_TENANT_SIGNAL,
|
|
314
|
+
);
|
|
315
|
+
expect(escapeHatchMatches).toEqual([]);
|
|
157
316
|
});
|
|
158
317
|
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// fw#2914 — r.step.read.findOne/findMany now apply the caller's tenant
|
|
2
|
+
// filter by default (own tenant + SYSTEM_TENANT_ID reference rows), like
|
|
3
|
+
// ctx.db method-form reads. Cross-tenant reads need `unsafeAllTenants:
|
|
4
|
+
// { reason }` on the step AND `escapeHatch: { reason }` on the handler,
|
|
5
|
+
// reported as an "unsafe-raw" escape-hatch audit event. Real HTTP calls +
|
|
6
|
+
// setupTestStack — never createTestDispatcher. Modelled on the M.1.5
|
|
7
|
+
// section of pipeline-handler.integration.test.ts and
|
|
8
|
+
// escape-hatch-audit.integration.test.ts.
|
|
9
|
+
|
|
10
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { table, text, uuid } from "../../db/dialect";
|
|
13
|
+
import { insertOne } from "../../db/query";
|
|
14
|
+
import { defineFeature, defineWriteHandler } from "../../engine";
|
|
15
|
+
import { setupTestStack, type TestStack, TestUsers, unsafePushTables } from "../../stack";
|
|
16
|
+
import { stepsPipeline } from "../pipeline";
|
|
17
|
+
import type { EscapeHatchUseEvent } from "../types";
|
|
18
|
+
|
|
19
|
+
const readFilterTable = table("fw2914_read_filter_items", {
|
|
20
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
21
|
+
tenantId: uuid("tenant_id").notNull(),
|
|
22
|
+
label: text("label").notNull(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const UNDECLARED_REASON =
|
|
26
|
+
"fw#2914 integration test — cross-tenant read step without a handler grant";
|
|
27
|
+
const ESCAPED_REASON = "fw#2914 integration test — cross-tenant widget count for read step";
|
|
28
|
+
|
|
29
|
+
const listSchema = z.object({});
|
|
30
|
+
const listHandler = defineWriteHandler({
|
|
31
|
+
name: "list",
|
|
32
|
+
schema: listSchema,
|
|
33
|
+
access: { roles: ["Admin"] },
|
|
34
|
+
perform: stepsPipeline<z.infer<typeof listSchema>, { count: number; ids: string[] }>(({ r }) => [
|
|
35
|
+
r.step.read.findMany("items", { table: readFilterTable }),
|
|
36
|
+
r.step.return(({ steps }) => {
|
|
37
|
+
const rows = steps["items"] as readonly { id: string }[];
|
|
38
|
+
return {
|
|
39
|
+
isSuccess: true as const,
|
|
40
|
+
data: { count: rows.length, ids: rows.map((row) => row.id) },
|
|
41
|
+
};
|
|
42
|
+
}),
|
|
43
|
+
]),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const getSchema = z.object({ id: z.uuid() });
|
|
47
|
+
const getHandler = defineWriteHandler({
|
|
48
|
+
name: "get",
|
|
49
|
+
schema: getSchema,
|
|
50
|
+
access: { roles: ["Admin"] },
|
|
51
|
+
perform: stepsPipeline<z.infer<typeof getSchema>, { found: boolean; label: string | null }>(
|
|
52
|
+
({ event, r }) => [
|
|
53
|
+
r.step.read.findOne("item", {
|
|
54
|
+
table: readFilterTable,
|
|
55
|
+
where: () => ({ id: event.payload.id }),
|
|
56
|
+
}),
|
|
57
|
+
r.step.return(({ steps }) => {
|
|
58
|
+
const row = steps["item"] as { label?: string } | null;
|
|
59
|
+
return {
|
|
60
|
+
isSuccess: true as const,
|
|
61
|
+
data: { found: row !== null, label: row?.label ?? null },
|
|
62
|
+
};
|
|
63
|
+
}),
|
|
64
|
+
],
|
|
65
|
+
),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const listUnsafeUndeclaredSchema = z.object({});
|
|
69
|
+
const listUnsafeUndeclaredHandler = defineWriteHandler({
|
|
70
|
+
name: "list-unsafe-undeclared",
|
|
71
|
+
schema: listUnsafeUndeclaredSchema,
|
|
72
|
+
access: { roles: ["Admin"] },
|
|
73
|
+
perform: stepsPipeline<z.infer<typeof listUnsafeUndeclaredSchema>, { count: number }>(({ r }) => [
|
|
74
|
+
r.step.read.findMany("items", {
|
|
75
|
+
table: readFilterTable,
|
|
76
|
+
unsafeAllTenants: { reason: UNDECLARED_REASON },
|
|
77
|
+
}),
|
|
78
|
+
r.step.return(({ steps }) => ({
|
|
79
|
+
isSuccess: true as const,
|
|
80
|
+
data: { count: (steps["items"] as readonly unknown[]).length },
|
|
81
|
+
})),
|
|
82
|
+
]),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const listEscapedSchema = z.object({});
|
|
86
|
+
const listEscapedHandler = defineWriteHandler({
|
|
87
|
+
name: "list-escaped",
|
|
88
|
+
schema: listEscapedSchema,
|
|
89
|
+
access: { roles: ["Admin"] },
|
|
90
|
+
escapeHatch: { reason: ESCAPED_REASON },
|
|
91
|
+
perform: stepsPipeline<z.infer<typeof listEscapedSchema>, { count: number }>(({ r }) => [
|
|
92
|
+
r.step.read.findMany("items", {
|
|
93
|
+
table: readFilterTable,
|
|
94
|
+
unsafeAllTenants: { reason: ESCAPED_REASON },
|
|
95
|
+
}),
|
|
96
|
+
r.step.return(({ steps }) => ({
|
|
97
|
+
isSuccess: true as const,
|
|
98
|
+
data: { count: (steps["items"] as readonly unknown[]).length },
|
|
99
|
+
})),
|
|
100
|
+
]),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const readStepsTenantFilterFeature = defineFeature("read-steps-tenant-filter", (r) => {
|
|
104
|
+
r.requires.projection("fw2914_read_filter_items");
|
|
105
|
+
r.writeHandler(listHandler);
|
|
106
|
+
r.writeHandler(getHandler);
|
|
107
|
+
r.writeHandler(listUnsafeUndeclaredHandler);
|
|
108
|
+
r.writeHandler(listEscapedHandler);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const admin = TestUsers.admin;
|
|
112
|
+
const otherTenant = TestUsers.otherTenant;
|
|
113
|
+
|
|
114
|
+
describe("r.step.read.findOne/findMany tenant-filtering (fw#2914)", () => {
|
|
115
|
+
let stack: TestStack;
|
|
116
|
+
const events: EscapeHatchUseEvent[] = [];
|
|
117
|
+
let adminRowId: string;
|
|
118
|
+
let otherTenantRowId: string;
|
|
119
|
+
|
|
120
|
+
beforeAll(async () => {
|
|
121
|
+
stack = await setupTestStack({
|
|
122
|
+
features: [readStepsTenantFilterFeature],
|
|
123
|
+
extraContext: {
|
|
124
|
+
_escapeHatchAuditSink: async (event: EscapeHatchUseEvent) => {
|
|
125
|
+
events.push(event);
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
await unsafePushTables(stack.db, { fw2914_read_filter_items: readFilterTable });
|
|
130
|
+
|
|
131
|
+
const adminRow = await insertOne<{ id: string }>(stack.db, readFilterTable, {
|
|
132
|
+
tenantId: admin.tenantId,
|
|
133
|
+
label: "admin-row",
|
|
134
|
+
});
|
|
135
|
+
const otherRow = await insertOne<{ id: string }>(stack.db, readFilterTable, {
|
|
136
|
+
tenantId: otherTenant.tenantId,
|
|
137
|
+
label: "other-tenant-row",
|
|
138
|
+
});
|
|
139
|
+
adminRowId = adminRow!.id;
|
|
140
|
+
otherTenantRowId = otherRow!.id;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
afterAll(async () => {
|
|
144
|
+
await stack.cleanup();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("read.findMany returns only the caller's own tenant rows", async () => {
|
|
148
|
+
const data = await stack.http.writeOk<{ count: number; ids: string[] }>(
|
|
149
|
+
"read-steps-tenant-filter:write:list",
|
|
150
|
+
{},
|
|
151
|
+
admin,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
expect(data.count).toBe(1);
|
|
155
|
+
expect(data.ids).toEqual([adminRowId]);
|
|
156
|
+
expect(data.ids).not.toContain(otherTenantRowId);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("read.findOne returns the row when it belongs to the caller's tenant", async () => {
|
|
160
|
+
const data = await stack.http.writeOk<{ found: boolean; label: string | null }>(
|
|
161
|
+
"read-steps-tenant-filter:write:get",
|
|
162
|
+
{ id: adminRowId },
|
|
163
|
+
admin,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
expect(data).toEqual({ found: true, label: "admin-row" });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("read.findOne returns null for a row belonging to a different tenant", async () => {
|
|
170
|
+
const data = await stack.http.writeOk<{ found: boolean; label: string | null }>(
|
|
171
|
+
"read-steps-tenant-filter:write:get",
|
|
172
|
+
{ id: otherTenantRowId },
|
|
173
|
+
admin,
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
expect(data).toEqual({ found: false, label: null });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("unsafeAllTenants without escapeHatch on the handler is denied and reports nothing", async () => {
|
|
180
|
+
events.length = 0;
|
|
181
|
+
|
|
182
|
+
const err = await stack.http.writeErr(
|
|
183
|
+
"read-steps-tenant-filter:write:list-unsafe-undeclared",
|
|
184
|
+
{},
|
|
185
|
+
admin,
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
expect(err.httpStatus).toBe(403);
|
|
189
|
+
expect(err.code).toBe("access_denied");
|
|
190
|
+
expect(events.filter((e) => e.kind === "unsafe-raw")).toEqual([]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("unsafeAllTenants with escapeHatch on the handler sees rows across tenants and reports unsafe-raw", async () => {
|
|
194
|
+
events.length = 0;
|
|
195
|
+
|
|
196
|
+
const data = await stack.http.writeOk<{ count: number }>(
|
|
197
|
+
"read-steps-tenant-filter:write:list-escaped",
|
|
198
|
+
{},
|
|
199
|
+
admin,
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
expect(data.count).toBeGreaterThanOrEqual(2);
|
|
203
|
+
|
|
204
|
+
const matches = events.filter((e) => e.kind === "unsafe-raw");
|
|
205
|
+
expect(matches).toEqual([
|
|
206
|
+
{
|
|
207
|
+
handler: "read-steps-tenant-filter:write:list-escaped",
|
|
208
|
+
kind: "unsafe-raw",
|
|
209
|
+
reason: ESCAPED_REASON,
|
|
210
|
+
tenantId: admin.tenantId,
|
|
211
|
+
actor: admin.id,
|
|
212
|
+
},
|
|
213
|
+
]);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
@@ -44,6 +44,22 @@ describe("requiredKeysFromScreen", () => {
|
|
|
44
44
|
expect(keys).toContain(fieldLabelKey("publicstatus", ACTION_FORM_ENTITY, "title"));
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
+
test("actionForm honors a fieldLabels override, falls back to ACTION_FORM_ENTITY otherwise", () => {
|
|
48
|
+
const keys = requiredKeysFromScreen("publicstatus", {
|
|
49
|
+
id: "reschedule-form",
|
|
50
|
+
type: "actionForm",
|
|
51
|
+
handler: "publicstatus:write:incident:reschedule",
|
|
52
|
+
fields: { dueAt: { type: "date" }, note: { type: "text" } },
|
|
53
|
+
fieldLabels: { dueAt: "publicstatus:override.dueAt" },
|
|
54
|
+
layout: {
|
|
55
|
+
sections: [{ fields: ["dueAt", "note"] }],
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
expect(keys).toContain("publicstatus:override.dueAt");
|
|
59
|
+
expect(keys).not.toContain(fieldLabelKey("publicstatus", ACTION_FORM_ENTITY, "dueAt"));
|
|
60
|
+
expect(keys).toContain(fieldLabelKey("publicstatus", ACTION_FORM_ENTITY, "note"));
|
|
61
|
+
});
|
|
62
|
+
|
|
47
63
|
test("entityEdit emits submitLabel + section titles + field labels (override honored)", () => {
|
|
48
64
|
const screen: EntityEditScreenDefinition = {
|
|
49
65
|
id: "component-edit",
|