@cosmicdrift/kumiko-framework 0.266.0 → 0.269.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/api/__tests__/api.test.ts +6 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +6 -0
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +6 -0
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +6 -0
- package/src/api/__tests__/jwt.test.ts +8 -0
- package/src/api/__tests__/stream-escape-hatch.integration.test.ts +203 -0
- package/src/api/jwt.ts +8 -0
- package/src/changes.json +28 -0
- package/src/db/__tests__/event-store-executor-global-tenancy.integration.test.ts +73 -0
- package/src/db/__tests__/tenant-db-global.integration.test.ts +63 -26
- package/src/db/__tests__/tenant-db-global.test.ts +95 -5
- package/src/db/entity-table-meta.ts +7 -0
- package/src/db/event-store-executor-context.ts +19 -0
- package/src/db/event-store-executor-write.ts +16 -0
- package/src/db/index.ts +3 -1
- package/src/db/table-builder.ts +27 -0
- package/src/db/tenant-db.ts +83 -10
- package/src/engine/__tests__/extends-registrar.test.ts +1 -0
- package/src/engine/__tests__/hook-identity-switch-grant.test.ts +4 -2
- package/src/engine/active-membership.ts +15 -1
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +37 -0
- package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +58 -0
- package/src/engine/boot-validator/access-declarations.ts +6 -1
- package/src/engine/boot-validator/global-tenancy.ts +17 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/feature-entity-handlers.ts +7 -1
- package/src/engine/feature-ui-extensions.ts +2 -2
- package/src/engine/index.ts +3 -0
- package/src/engine/registry-validate.ts +6 -6
- package/src/engine/steps/call-feature.ts +1 -1
- package/src/engine/types/index.ts +2 -0
- package/src/entrypoint/index.ts +2 -13
- package/src/errors/i18n/de.yaml +36 -0
- package/src/errors/i18n/en.yaml +34 -0
- package/src/errors/reasons.ts +13 -0
- package/src/jobs/job-runner.ts +14 -0
- package/src/pipeline/__tests__/dispatcher-utils.test.ts +17 -0
- package/src/pipeline/__tests__/escape-hatch.integration.test.ts +0 -3
- package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +201 -8
- package/src/pipeline/__tests__/system-identity-switch.test.ts +244 -33
- package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +103 -0
- package/src/pipeline/active-membership.ts +3 -1
- package/src/pipeline/dispatch-shared.ts +107 -20
- package/src/pipeline/dispatch-stream.ts +9 -5
- package/src/pipeline/dispatch-write.ts +7 -0
- package/src/pipeline/dispatcher-utils.ts +1 -0
- package/src/pipeline/dispatcher.ts +18 -0
- package/src/pipeline/index.ts +1 -1
- package/src/pipeline/member-reader.ts +108 -0
- package/src/pipeline/system-identity-switch.ts +142 -25
- package/src/stack/test-stack.ts +10 -1
- package/src/testing/handler-context.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.269.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-types": "0.269.0",
|
|
202
202
|
"bullmq": "^5.76.7",
|
|
203
203
|
"bun-types": "^1.3.13",
|
|
204
204
|
"hono": "^4.13.1",
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
"zod": "^4.4.3"
|
|
215
215
|
},
|
|
216
216
|
"devDependencies": {
|
|
217
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
217
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.269.0",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
|
@@ -640,6 +640,12 @@ describe("POST /api/stream pre-pull race", () => {
|
|
|
640
640
|
async resolveActiveMembership() {
|
|
641
641
|
return { kind: "rejected", reason: "not_a_member" };
|
|
642
642
|
},
|
|
643
|
+
// Not exercised by this route.
|
|
644
|
+
createMemberReader() {
|
|
645
|
+
return async () => {
|
|
646
|
+
throw new Error("createMemberReader not available in this stub");
|
|
647
|
+
};
|
|
648
|
+
},
|
|
643
649
|
};
|
|
644
650
|
}
|
|
645
651
|
|
|
@@ -64,6 +64,12 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
|
|
|
64
64
|
}
|
|
65
65
|
return { kind: "active", membership: { tenantId, roles: membership.roles } };
|
|
66
66
|
},
|
|
67
|
+
// Not exercised by these cookie-only tests.
|
|
68
|
+
createMemberReader() {
|
|
69
|
+
return async () => {
|
|
70
|
+
throw new Error("createMemberReader not available in this stub");
|
|
71
|
+
};
|
|
72
|
+
},
|
|
67
73
|
...overrides,
|
|
68
74
|
};
|
|
69
75
|
return merged;
|
|
@@ -39,6 +39,12 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
|
|
|
39
39
|
},
|
|
40
40
|
// Stream API added with r.streamHandler (#1446) — stub unused in these routes.
|
|
41
41
|
async *stream(): AsyncGenerator<unknown> {},
|
|
42
|
+
// Not exercised by these routes.
|
|
43
|
+
createMemberReader() {
|
|
44
|
+
return async () => {
|
|
45
|
+
throw new Error("createMemberReader not available in this stub");
|
|
46
|
+
};
|
|
47
|
+
},
|
|
42
48
|
};
|
|
43
49
|
return { ...base, ...overrides };
|
|
44
50
|
}
|
|
@@ -49,6 +49,12 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
|
|
|
49
49
|
async resolveActiveMembership() {
|
|
50
50
|
return { kind: "rejected", reason: "not_a_member" };
|
|
51
51
|
},
|
|
52
|
+
// Not exercised by these routes.
|
|
53
|
+
createMemberReader() {
|
|
54
|
+
return async () => {
|
|
55
|
+
throw new Error("createMemberReader not available in this stub");
|
|
56
|
+
};
|
|
57
|
+
},
|
|
52
58
|
};
|
|
53
59
|
return { ...base, ...overrides };
|
|
54
60
|
}
|
|
@@ -50,6 +50,12 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
|
|
|
50
50
|
async resolveActiveMembership() {
|
|
51
51
|
return { kind: "rejected", reason: "not_a_member" };
|
|
52
52
|
},
|
|
53
|
+
// Not exercised by these routes.
|
|
54
|
+
createMemberReader() {
|
|
55
|
+
return async () => {
|
|
56
|
+
throw new Error("createMemberReader not available in this stub");
|
|
57
|
+
};
|
|
58
|
+
},
|
|
53
59
|
};
|
|
54
60
|
return { ...base, ...overrides };
|
|
55
61
|
}
|
|
@@ -49,6 +49,12 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
|
|
|
49
49
|
async resolveActiveMembership() {
|
|
50
50
|
return { kind: "rejected", reason: "not_a_member" };
|
|
51
51
|
},
|
|
52
|
+
// Not exercised by these routes.
|
|
53
|
+
createMemberReader() {
|
|
54
|
+
return async () => {
|
|
55
|
+
throw new Error("createMemberReader not available in this stub");
|
|
56
|
+
};
|
|
57
|
+
},
|
|
52
58
|
};
|
|
53
59
|
return { ...base, ...overrides };
|
|
54
60
|
}
|
|
@@ -42,6 +42,12 @@ function failingDispatcher(): Dispatcher {
|
|
|
42
42
|
async resolveActiveMembership() {
|
|
43
43
|
return { kind: "rejected", reason: "not_a_member" };
|
|
44
44
|
},
|
|
45
|
+
// Not exercised by these routes.
|
|
46
|
+
createMemberReader() {
|
|
47
|
+
return async () => {
|
|
48
|
+
throw new Error("createMemberReader not available in this stub");
|
|
49
|
+
};
|
|
50
|
+
},
|
|
45
51
|
};
|
|
46
52
|
}
|
|
47
53
|
|
|
@@ -106,6 +106,14 @@ describe("createJwtHelper.verify — payload validation (KF-2)", () => {
|
|
|
106
106
|
const payload = await jwt.verify(await jwt.sign(user));
|
|
107
107
|
expect(payload.locale).toBeUndefined();
|
|
108
108
|
});
|
|
109
|
+
|
|
110
|
+
// A ctx.queryAsMember-resolved SessionUser must never be minted into a
|
|
111
|
+
// session — it carries no `sid` and is read-only by construction.
|
|
112
|
+
it("refuses to sign a SessionUser with origin set", async () => {
|
|
113
|
+
await expect(jwt.sign({ ...user, origin: "member-resolution" })).rejects.toThrow(
|
|
114
|
+
/member-resolution/,
|
|
115
|
+
);
|
|
116
|
+
});
|
|
109
117
|
});
|
|
110
118
|
|
|
111
119
|
describe("createJwtHelper — keyring form", () => {
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// fw#(stream escapeHatch) — mirrors system-identity-switch.integration.test.ts's
|
|
2
|
+
// coverage of ctx.queryAs(SYSTEM, ...) gating, but for stream handlers: a
|
|
3
|
+
// StreamHandlerDef can now declare `escapeHatch: { reason }` to switch
|
|
4
|
+
// identity to SYSTEM. Real HTTP calls (SSE POST /api/stream via Hono's
|
|
5
|
+
// in-memory app.request) + setupTestStack — never createTestDispatcher.
|
|
6
|
+
|
|
7
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
8
|
+
import { createLiveDispatcher } from "@cosmicdrift/kumiko-dispatcher-live";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { generateToken } from "../../api/tokens";
|
|
11
|
+
import { createSystemUser, defineFeature } from "../../engine";
|
|
12
|
+
import type { SessionUser } from "../../engine/types";
|
|
13
|
+
import { createTestUser, setupTestStack, type TestStack, TestUsers } from "../../stack";
|
|
14
|
+
|
|
15
|
+
const user = TestUsers.user;
|
|
16
|
+
const otherUserNoRole: SessionUser = createTestUser({
|
|
17
|
+
id: 60,
|
|
18
|
+
tenantId: user.tenantId,
|
|
19
|
+
roles: ["User"],
|
|
20
|
+
});
|
|
21
|
+
const otherUserWithAdminRole: SessionUser = createTestUser({
|
|
22
|
+
id: 61,
|
|
23
|
+
tenantId: user.tenantId,
|
|
24
|
+
roles: ["Admin"],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const streamProbeFeature = defineFeature("stream-idswitch-probe", (r) => {
|
|
28
|
+
r.queryHandler(
|
|
29
|
+
"whoami",
|
|
30
|
+
z.object({}),
|
|
31
|
+
async (query) => ({ roles: query.user.roles, tenantId: query.user.tenantId }),
|
|
32
|
+
{ access: { roles: ["system", "User"] } },
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
r.queryHandler("admin-only", z.object({}), async () => ({ ok: true }), {
|
|
36
|
+
access: { roles: ["Admin"] },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// --- ctx.queryAs(SYSTEM, ...) from a stream handler: gated by the
|
|
40
|
+
// stream handler's own escapeHatch, same contract as write/query. ---
|
|
41
|
+
r.streamHandler(
|
|
42
|
+
"query-as-system-no-hatch",
|
|
43
|
+
z.object({}),
|
|
44
|
+
async function* (query, ctx) {
|
|
45
|
+
const result = await ctx.queryAs(
|
|
46
|
+
createSystemUser(query.user.tenantId),
|
|
47
|
+
"stream-idswitch-probe:query:whoami",
|
|
48
|
+
{},
|
|
49
|
+
);
|
|
50
|
+
yield result;
|
|
51
|
+
},
|
|
52
|
+
{ access: { roles: ["User"] } },
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
r.streamHandler(
|
|
56
|
+
"query-as-system-with-hatch",
|
|
57
|
+
z.object({}),
|
|
58
|
+
async function* (query, ctx) {
|
|
59
|
+
const result = await ctx.queryAs(
|
|
60
|
+
createSystemUser(query.user.tenantId),
|
|
61
|
+
"stream-idswitch-probe:query:whoami",
|
|
62
|
+
{},
|
|
63
|
+
);
|
|
64
|
+
yield result;
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
access: { roles: ["User"] },
|
|
68
|
+
escapeHatch: { reason: "test: stream needs SYSTEM to look itself up" },
|
|
69
|
+
},
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// --- ctx.queryAs(nonSystemUser, ...) from a stream handler (fw#2876): a foreign
|
|
73
|
+
// user needs the stream handler's escapeHatch; the target's own access rule still applies. ---
|
|
74
|
+
r.streamHandler(
|
|
75
|
+
"query-as-normal-user-no-hatch",
|
|
76
|
+
z.object({}),
|
|
77
|
+
async function* (_query, ctx) {
|
|
78
|
+
yield await ctx.queryAs(otherUserWithAdminRole, "stream-idswitch-probe:query:admin-only", {});
|
|
79
|
+
},
|
|
80
|
+
{ access: { roles: ["User"] } },
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
r.streamHandler(
|
|
84
|
+
"query-as-normal-user",
|
|
85
|
+
z.object({ granted: z.boolean() }),
|
|
86
|
+
async function* (query, ctx) {
|
|
87
|
+
const result = await ctx.queryAs(
|
|
88
|
+
query.payload.granted ? otherUserWithAdminRole : otherUserNoRole,
|
|
89
|
+
"stream-idswitch-probe:query:admin-only",
|
|
90
|
+
{},
|
|
91
|
+
);
|
|
92
|
+
yield result;
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
access: { roles: ["User"] },
|
|
96
|
+
escapeHatch: { reason: "test: stream reads admin-only data as a named colleague" },
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
let stack: TestStack;
|
|
102
|
+
|
|
103
|
+
beforeAll(async () => {
|
|
104
|
+
stack = await setupTestStack({ features: [streamProbeFeature] });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
afterAll(async () => {
|
|
108
|
+
await stack.cleanup();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
async function buildDispatcherFor(asUser: SessionUser) {
|
|
112
|
+
const authJwt = await stack.jwt.sign(asUser);
|
|
113
|
+
const csrfToken = generateToken();
|
|
114
|
+
const cookieHeader = `kumiko_auth=${authJwt}; kumiko_csrf=${csrfToken}`;
|
|
115
|
+
const fetchImpl = (async (url: unknown, init: RequestInit | undefined) => {
|
|
116
|
+
return stack.app.request(String(url), {
|
|
117
|
+
...(init ?? {}),
|
|
118
|
+
headers: { ...(init?.headers ?? {}), Cookie: cookieHeader },
|
|
119
|
+
});
|
|
120
|
+
}) as unknown as typeof fetch;
|
|
121
|
+
return createLiveDispatcher({ fetch: fetchImpl, readCsrf: () => csrfToken });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function collectAll<T>(gen: AsyncGenerator<T>): Promise<T[]> {
|
|
125
|
+
const chunks: T[] = [];
|
|
126
|
+
for await (const chunk of gen) chunks.push(chunk);
|
|
127
|
+
return chunks;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
describe("ctx.queryAs(SYSTEM, ...) from a stream handler — gated by the handler's own escapeHatch", () => {
|
|
131
|
+
test("WITHOUT escapeHatch: fails with access_denied", async () => {
|
|
132
|
+
const dispatcher = await buildDispatcherFor(user);
|
|
133
|
+
let thrown: unknown;
|
|
134
|
+
try {
|
|
135
|
+
for await (const _ of dispatcher.stream(
|
|
136
|
+
"stream-idswitch-probe:stream:query-as-system-no-hatch",
|
|
137
|
+
{},
|
|
138
|
+
)) {
|
|
139
|
+
// should not yield
|
|
140
|
+
}
|
|
141
|
+
} catch (e) {
|
|
142
|
+
thrown = e;
|
|
143
|
+
}
|
|
144
|
+
expect(thrown).toMatchObject({ code: "access_denied" });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("WITH escapeHatch: succeeds", async () => {
|
|
148
|
+
const dispatcher = await buildDispatcherFor(user);
|
|
149
|
+
const chunks = await collectAll(
|
|
150
|
+
dispatcher.stream<{ roles: readonly string[] }>(
|
|
151
|
+
"stream-idswitch-probe:stream:query-as-system-with-hatch",
|
|
152
|
+
{},
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
expect(chunks[0]?.roles).toContain("system");
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("ctx.queryAs(nonSystemUser, ...) from a stream handler — gated like write/query (fw#2876)", () => {
|
|
160
|
+
test("WITHOUT escapeHatch: a foreign user fails with identity_switch_denied", async () => {
|
|
161
|
+
const dispatcher = await buildDispatcherFor(user);
|
|
162
|
+
let thrown: unknown;
|
|
163
|
+
try {
|
|
164
|
+
for await (const _ of dispatcher.stream(
|
|
165
|
+
"stream-idswitch-probe:stream:query-as-normal-user-no-hatch",
|
|
166
|
+
{},
|
|
167
|
+
)) {
|
|
168
|
+
// should not yield
|
|
169
|
+
}
|
|
170
|
+
} catch (e) {
|
|
171
|
+
thrown = e;
|
|
172
|
+
}
|
|
173
|
+
expect(thrown).toMatchObject({
|
|
174
|
+
code: "access_denied",
|
|
175
|
+
details: { reason: "identity_switch_denied" },
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("WITH escapeHatch, delegates to the target's own access check: denied when the target user lacks the role", async () => {
|
|
180
|
+
const dispatcher = await buildDispatcherFor(user);
|
|
181
|
+
let thrown: unknown;
|
|
182
|
+
try {
|
|
183
|
+
for await (const _ of dispatcher.stream("stream-idswitch-probe:stream:query-as-normal-user", {
|
|
184
|
+
granted: false,
|
|
185
|
+
})) {
|
|
186
|
+
// should not yield
|
|
187
|
+
}
|
|
188
|
+
} catch (e) {
|
|
189
|
+
thrown = e;
|
|
190
|
+
}
|
|
191
|
+
expect(thrown).toMatchObject({ code: "access_denied" });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("WITH escapeHatch, delegates to the target's own access check: allowed when the target user has the role", async () => {
|
|
195
|
+
const dispatcher = await buildDispatcherFor(user);
|
|
196
|
+
const chunks = await collectAll(
|
|
197
|
+
dispatcher.stream<{ ok: boolean }>("stream-idswitch-probe:stream:query-as-normal-user", {
|
|
198
|
+
granted: true,
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
expect(chunks[0]?.ok).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
});
|
package/src/api/jwt.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as jose from "jose";
|
|
|
2
2
|
import type { DbRow } from "../db/connection";
|
|
3
3
|
import type { SessionUser, TenantId } from "../engine/types";
|
|
4
4
|
import { parseTenantId } from "../engine/types";
|
|
5
|
+
import { InternalError } from "../errors";
|
|
5
6
|
|
|
6
7
|
export type JwtPayload = {
|
|
7
8
|
// JWT `sub` is a string per RFC 7519. Matches SessionUser.id — a UUID-string
|
|
@@ -108,6 +109,13 @@ export function createJwtHelper(
|
|
|
108
109
|
|
|
109
110
|
return {
|
|
110
111
|
async sign(user) {
|
|
112
|
+
// A resolved member principal (origin set) carries no `sid` and must
|
|
113
|
+
// never be minted into a session — only interactive sign-in may sign.
|
|
114
|
+
if (user.origin !== undefined) {
|
|
115
|
+
throw new InternalError({
|
|
116
|
+
message: `jwt.sign: refusing to sign a SessionUser with origin "${user.origin}" — only interactively-authenticated sessions may be minted.`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
111
119
|
const body: Omit<JwtPayload, "sub" | "jti"> = {
|
|
112
120
|
tenantId: user.tenantId,
|
|
113
121
|
roles: [...user.roles],
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,32 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.269.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "ctx.queryAs/ctx.writeAs to any identity other than the caller itself need r.systemScope() or escapeHatch (fw#2876).",
|
|
6
|
+
"detail": "The identity-switch gate from fw#2859 now applies to every target, not only SYSTEM (`isSystemIdentitySwitchAllowed` is replaced by `isIdentitySwitchAllowed(caller, asUser, hasGrant)` in `pipeline/system-identity-switch.ts`; `createGatedIdentitySwitch` takes the caller as second argument). Without a grant, `HandlerContext.queryAs`/`writeAs` only accept the caller itself: same `id`, same `tenantId`, same `origin`, equal `claims`, and roles that are a subset of the caller's roles (`sid`, `pat`, `timezone`, `locale` may differ). Any other target — a different user id, a different tenant, extra roles, changed claims, a stripped `origin` — throws `AccessDeniedError` (code `access_denied`, `details.reason` `identity_switch_denied`, no target id/tenant in the error); SYSTEM targets keep `system_identity_switch_denied`. The grant is the same as before: the calling handler's `r.systemScope()` feature or `escapeHatch: { reason }` on the write/query/stream handler, or the hook's own `r.hook(..., { escapeHatch })`. It stays non-transitive, hooks never inherit it, extension hooks and `r.authClaims` hooks have no declaration site (their `queryAs` only reaches the caller itself), and a hook's caller is taken from the dispatcher-built gate, never from `ctx.user`. `JobContext.queryAs`/`writeAs`, top-level `dispatcher.write`/`query`, `ctx.queryAsMember` and `ctx.resolveActiveMembership` are unchanged. `r.step.callFeature({ as })` goes through the gated `ctx.writeAs` and needs the handler's `escapeHatch` for a foreign `as`.",
|
|
7
|
+
"migration": "Find every `ctx.queryAs(user, ...)`/`ctx.writeAs(user, ...)` (also via helpers that receive `ctx`, and `r.step.callFeature({ as })`) whose user is not `createSystemUser(...)`. If the user is `event.user`/`query.user` or a copy with fewer roles, nothing to do. If it is another user, another tenant (e.g. `{ ...event.user, tenantId: other }`) or adds roles, declare `escapeHatch: { reason: \"<why this handler acts as that identity>\" }` on the write, query or stream handler (or on the `r.hook(...)` call), move the call into an `r.systemScope()` feature or a job, or use `ctx.queryAsMember(userId, ...)` for reads as a stored tenant member. Code branching on `details.reason` for these denials must also accept `identity_switch_denied`. Audit at release: bundled-features 0 sites, solon 0, offlot-app 0, kumiko-enterprise 0 (ai-agent tool dispatcher passes the caller itself)."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.269.0",
|
|
11
|
+
"type": "improvement",
|
|
12
|
+
"title": "escapeHatch is now also accepted on stream handlers, gating a SYSTEM identity switch via ctx.queryAs the same way it does for write/query handlers.",
|
|
13
|
+
"detail": "StreamHandlerDef/StreamHandlerDefinition and the r.streamHandler options param accept escapeHatch: { reason }, same contract as WriteHandlerDef/QueryHandlerDef: a stream handler that switches identity to SYSTEM via ctx.queryAs now needs its own escapeHatch declaration (or its feature must be r.systemScope()), same as ctx.writeAs/queryAs already require for write and query handlers. Stream handlers still cannot reach db.global() (globalWrites stays write-only) but do get the same SYSTEM-identity-switch and ctx.db.unsafeRaw(reason) grant a query-handler escapeHatch already unlocks. The boot validator rejects an empty reason on a stream handler's escapeHatch the same way it does for write/query handlers."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"version": "0.267.0",
|
|
17
|
+
"type": "breaking",
|
|
18
|
+
"title": "ctx.db.unsafeRaw(reason) gated by escapeHatch, tenancy: \"global\" invariants, bundled features migrated off ctx.db.raw (fw#2858).",
|
|
19
|
+
"detail": "`TenantDb.unsafeRaw(reason)` returns the unfiltered `DbRunner` only when the calling write or query handler declares `escapeHatch: { reason }` (lifecycle hooks: `r.hook(..., { escapeHatch })`; a hook no longer inherits the handler's grant, same as the #2859 SYSTEM identity-switch gate). Without a declaration it throws `AccessDeniedError`; an empty reason throws `Error`. `r.systemScope()` features keep using `ctx.systemDb.unsafeRaw(reason)`. `TenantDb.raw` stays deprecated until fw#2860. `createTenantDb`'s 7th parameter changed from an `EscapeHatchDeclaration` to `TenantDbGrants` `{ globalWrites?, unsafeRaw? }`; `withUnsafeRawGrant(tenantDb, grant)` rebinds the unsafeRaw grant. `bindHookIdentitySwitchGrant`/`withHookIdentitySwitchGrant` are renamed to `bindHookEscapeHatchGrant`/`withHookEscapeHatchGrant`. Global tenancy: a `tenancy: \"global\"` entity must declare `systemStream: true` (boot validator + executor) and its create/update payloads may not carry a non-SYSTEM `tenantId`; tenant-mode `insertOne` into a global table with a `tenant_id` column and `db.global(table)` writes with a non-SYSTEM `tenantId` are rejected; `defineUnmanagedTable({ tenancy: \"global\" })` rejects a `tenant_id` column; new `declareGlobalTenancy(table)` declares a plain `table()` store without `tenant_id` as global. Bundled features: `userEntity` is `tenancy: \"global\"`, `store_global_feature_state` is declared global, and every bundled `ctx.db.raw` call site now uses `ctx.db.global(table)`, a filtered `ctx.db` method, or `ctx.db.unsafeRaw(reason)` with an `escapeHatch` on the handler. Handlers that call the auth-mfa `mfaStatusChecker`/MFA code verifier callbacks, handlers wrapped by cap-counter `withStockCap` (declares it automatically) and callers of the custom-fields quota/field-access query helpers now need that declaration.",
|
|
20
|
+
"migration": "Replace each `ctx.db.raw` use: reads of a `tenancy: \"global\"` table → `ctx.db.global(table).selectMany/fetchOne(...)`; reads/inserts whose where/values only use the caller's own tenantId → `ctx.db.selectMany/fetchOne/insertOne(table, ...)` (method form); everything else → `ctx.db.unsafeRaw(\"<what is read/written and why the tenant filter cannot apply>\")` plus `escapeHatch: { reason }` on the write/query handler definition or in the `r.hook` options. Generic reasons (todo, legacy, migration) fail infra#788. `bun scripts/migrate-db-raw.ts --dry-run <paths>` rewrites the global-table and own-tenant cases and lists the remaining sites; drop `--dry-run` to apply. Custom handlers calling `mfaStatusChecker`/the MFA code verifier or the custom-fields query helpers must declare `escapeHatch`. Direct `createTenantDb(..., escapeHatch)` calls pass `{ globalWrites: escapeHatch }` (and `unsafeRaw` if needed). Rename imports of `bindHookIdentitySwitchGrant`/`withHookIdentitySwitchGrant`. `defineUnmanagedTable({ tenancy: \"global\" })` definitions must drop their `tenant_id` column; `tenancy: \"global\"` entities must add `systemStream: true`.",
|
|
21
|
+
"codemod": "bun scripts/migrate-db-raw.ts --dry-run <paths>"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"version": "0.267.0",
|
|
25
|
+
"type": "improvement",
|
|
26
|
+
"title": "ctx.queryAsMember reads a query handler as a stored tenant member, resolved internally by the framework (fw#2873).",
|
|
27
|
+
"detail": "New `ctx.queryAsMember(userId, qn, payload)` on `HandlerContext`, `JobContext` and (optional) `AppContext` — resolves a background SessionUser for `userId` in the current tenant (active membership, via a new stricter `BACKGROUND_READ_POLICY` — no pending-destruction grace window, no unknown-principal pass → `PrincipalStatusPlugin.resolveProfile` → `buildSessionRoles` → `resolveAuthClaims`) and runs the target query handler as that member. The resolved SessionUser is never exposed: no `sid`, `origin: \"member-resolution\"`. Rejection for any reason (not a member, blocked principal, tenant teardown, or would-be-SYSTEM) surfaces the same generic AccessDeniedError (`member_resolution_denied`, no rejection reason in `details`). Resolution is cached per handler invocation (its lifecycle hooks share that cache) or job run; a nested dispatch gets its own cache. Needs the same grant as a SYSTEM `queryAs` (`r.systemScope()` or `escapeHatch`); a hook's OWN escapeHatch re-gates it independently of the enclosing handler's grant (can grant even when the handler has none). Jobs stay ungated. There is no `writeAsMember` — a resolved member principal is read-only by construction: `write`/`writeAs`/`appendEvent`/`unsafeAppendEvent`/`tryAppendEvent`/`fetchForWriting`/`archiveStream`/`restoreStream`/`snapshotAggregate`/`queryAsMember`/`resolveActiveMembership`/`jobRunner`/`scheduleAfterCommit` all throw `member_resolution_read_only` (also enforced at the shared event-append sink behind `appendEvent`/`fetchForWriting().appendOne`), and `dbOutsideTransaction`/`systemDb`/`runPreSave`/`files`/`derivatives` are unset (raw `ctx.db` writes are NOT blocked — same reachability as that handler running for the member over HTTP). `jwt.sign()` now refuses a SessionUser with `origin` set.",
|
|
28
|
+
"migration": "Custom principalStatus providers must implement resolveProfile for ctx.queryAsMember; login/switch-tenant unaffected."
|
|
29
|
+
},
|
|
2
30
|
{
|
|
3
31
|
"version": "0.266.0",
|
|
4
32
|
"type": "breaking",
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// fw#2858 — a `tenancy: "global"` entity's aggregate is tenant-agnostic: create/update
|
|
2
|
+
// must reject an explicit foreign (non-SYSTEM) payload.tenantId before it ever reaches
|
|
3
|
+
// the projection insert/update. Modelled on event-store-system-stream.integration.test.ts.
|
|
4
|
+
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
6
|
+
import { createEntity, createTextField } from "../../engine";
|
|
7
|
+
import { AccessDeniedError } from "../../errors";
|
|
8
|
+
import {
|
|
9
|
+
createTestDb,
|
|
10
|
+
type TestDb,
|
|
11
|
+
TestUsers,
|
|
12
|
+
testTenantId,
|
|
13
|
+
unsafeCreateEntityTable,
|
|
14
|
+
} from "../../stack";
|
|
15
|
+
import { createEventStoreExecutor } from "../event-store-executor";
|
|
16
|
+
import { buildEntityTable } from "../table-builder";
|
|
17
|
+
import { createTenantDb } from "../tenant-db";
|
|
18
|
+
|
|
19
|
+
const globalEntity = createEntity({
|
|
20
|
+
table: "gtenancy_global",
|
|
21
|
+
tenancy: "global",
|
|
22
|
+
systemStream: true,
|
|
23
|
+
fields: { name: createTextField({ required: true, personal: false, reason: "test_fixture" }) },
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const globalTable = buildEntityTable("gtenancyGlobal", globalEntity);
|
|
27
|
+
const globalExec = createEventStoreExecutor(globalTable, globalEntity, {
|
|
28
|
+
entityName: "gtenancyGlobal",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const user = TestUsers.admin; // tenantId === testTenantId(1)
|
|
32
|
+
const foreignTenantId = testTenantId(2);
|
|
33
|
+
|
|
34
|
+
let testDb: TestDb;
|
|
35
|
+
|
|
36
|
+
beforeAll(async () => {
|
|
37
|
+
testDb = await createTestDb();
|
|
38
|
+
await unsafeCreateEntityTable(testDb.db, globalEntity, "gtenancyGlobal");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterAll(async () => {
|
|
42
|
+
await testDb.cleanup();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("global entity tenantId invariant (fw#2858)", () => {
|
|
46
|
+
test("create with an explicit foreign tenantId throws AccessDeniedError", async () => {
|
|
47
|
+
const db = createTenantDb(testDb.db, user.tenantId);
|
|
48
|
+
await expect(
|
|
49
|
+
globalExec.create({ name: "a", tenantId: foreignTenantId }, user, db),
|
|
50
|
+
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("create without a tenantId (or SYSTEM) succeeds", async () => {
|
|
54
|
+
const db = createTenantDb(testDb.db, user.tenantId);
|
|
55
|
+
const res = await globalExec.create({ name: "a" }, user, db);
|
|
56
|
+
expect(res.isSuccess).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("update with an explicit foreign tenantId in changes throws AccessDeniedError", async () => {
|
|
60
|
+
const db = createTenantDb(testDb.db, user.tenantId);
|
|
61
|
+
const created = await globalExec.create({ name: "a" }, user, db);
|
|
62
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
63
|
+
|
|
64
|
+
await expect(
|
|
65
|
+
globalExec.update(
|
|
66
|
+
{ id: String(created.data.id), changes: { tenantId: foreignTenantId } },
|
|
67
|
+
user,
|
|
68
|
+
db,
|
|
69
|
+
{ skipOptimisticLock: true },
|
|
70
|
+
),
|
|
71
|
+
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
// fw#
|
|
2
|
-
//
|
|
1
|
+
// fw#2858 — db.global(table) against real Postgres: a "global" unmanaged table has
|
|
2
|
+
// no tenant_id column at all (binding decision, issue comment 2026-09-14), so its
|
|
3
|
+
// rows are tenant-agnostic — every caller sees the same rows through db.global().
|
|
4
|
+
// A normal tenancy: "tenant" table is unaffected and still isolates by tenant.
|
|
3
5
|
// Follows tenant-db-where-merge.integration.test.ts's setupTestStack pattern.
|
|
4
6
|
|
|
5
7
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
8
|
+
import { AccessDeniedError } from "../../errors";
|
|
6
9
|
import { setupTestStack, type TestStack, testTenantId, unsafePushTables } from "../../stack";
|
|
7
10
|
import type { TableColumns } from "../dialect";
|
|
8
11
|
import { defineUnmanagedTable } from "../entity-table-meta";
|
|
@@ -10,8 +13,22 @@ import { insertOne } from "../query";
|
|
|
10
13
|
import { createTenantDb } from "../tenant-db";
|
|
11
14
|
|
|
12
15
|
const globalItemsTable = defineUnmanagedTable({
|
|
13
|
-
tableName: "
|
|
16
|
+
tableName: "store_fw2858_global_items_it",
|
|
14
17
|
tenancy: "global",
|
|
18
|
+
columns: [
|
|
19
|
+
{
|
|
20
|
+
name: "id",
|
|
21
|
+
pgType: "uuid",
|
|
22
|
+
notNull: true,
|
|
23
|
+
primaryKey: true,
|
|
24
|
+
defaultSql: "gen_random_uuid()",
|
|
25
|
+
},
|
|
26
|
+
{ name: "some_field", pgType: "text", notNull: true },
|
|
27
|
+
],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const tenantItemsTable = defineUnmanagedTable({
|
|
31
|
+
tableName: "store_fw2858_tenant_items_it",
|
|
15
32
|
columns: [
|
|
16
33
|
{
|
|
17
34
|
name: "id",
|
|
@@ -27,15 +44,20 @@ const globalItemsTable = defineUnmanagedTable({
|
|
|
27
44
|
|
|
28
45
|
let stack: TestStack;
|
|
29
46
|
|
|
30
|
-
const tenantA = testTenantId(
|
|
31
|
-
const tenantB = testTenantId(
|
|
47
|
+
const tenantA = testTenantId(93);
|
|
48
|
+
const tenantB = testTenantId(94);
|
|
32
49
|
|
|
33
50
|
beforeAll(async () => {
|
|
34
51
|
stack = await setupTestStack({ features: [] });
|
|
35
|
-
await unsafePushTables(stack.db, {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
52
|
+
await unsafePushTables(stack.db, {
|
|
53
|
+
globalItems: globalItemsTable,
|
|
54
|
+
tenantItems: tenantItemsTable,
|
|
55
|
+
});
|
|
56
|
+
await insertOne(stack.db, globalItemsTable, { someField: "match" });
|
|
57
|
+
await insertOne(stack.db, globalItemsTable, { someField: "match" });
|
|
58
|
+
await insertOne(stack.db, globalItemsTable, { someField: "no-match" });
|
|
59
|
+
await insertOne(stack.db, tenantItemsTable, { tenantId: tenantA, someField: "match" });
|
|
60
|
+
await insertOne(stack.db, tenantItemsTable, { tenantId: tenantB, someField: "match" });
|
|
39
61
|
});
|
|
40
62
|
|
|
41
63
|
afterAll(async () => {
|
|
@@ -43,25 +65,35 @@ afterAll(async () => {
|
|
|
43
65
|
});
|
|
44
66
|
|
|
45
67
|
describe("TenantDb.global() — reads, real Postgres", () => {
|
|
46
|
-
test("
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
expect(
|
|
55
|
-
expect(
|
|
68
|
+
test("selectMany returns the same rows to callers on different tenants, unfiltered by tenant", async () => {
|
|
69
|
+
const rowsForA = await createTenantDb(stack.db, tenantA)
|
|
70
|
+
.global(globalItemsTable)
|
|
71
|
+
.selectMany<{ id: string; someField: string }>({ someField: "match" });
|
|
72
|
+
const rowsForB = await createTenantDb(stack.db, tenantB)
|
|
73
|
+
.global(globalItemsTable)
|
|
74
|
+
.selectMany<{ id: string; someField: string }>({ someField: "match" });
|
|
75
|
+
|
|
76
|
+
expect(rowsForA).toHaveLength(2);
|
|
77
|
+
expect(rowsForA.map((r) => r.id).sort()).toEqual(rowsForB.map((r) => r.id).sort());
|
|
56
78
|
});
|
|
57
79
|
|
|
58
|
-
test("
|
|
80
|
+
test("fetchOne finds a row for a caller whose own tenant never wrote it", async () => {
|
|
81
|
+
const row = await createTenantDb(stack.db, tenantA)
|
|
82
|
+
.global(globalItemsTable)
|
|
83
|
+
.fetchOne<{ someField: string }>({ someField: "no-match" });
|
|
84
|
+
|
|
85
|
+
expect(row?.someField).toBe("no-match");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("TenantDb — tenant-mode isolation on a normal tenant table", () => {
|
|
90
|
+
test("db.selectMany only returns the caller's own tenant's rows", async () => {
|
|
59
91
|
const tdb = createTenantDb(stack.db, tenantA);
|
|
60
92
|
// selectMany's Table param types the branded-EntityTable shape only;
|
|
61
93
|
// EntityTableMeta reads work identically at runtime (both normalize via
|
|
62
94
|
// asEntityTableMeta) — same unbranded-view cast as tenant-db-where-merge.test.ts.
|
|
63
95
|
const rows = await tdb.selectMany<{ tenantId: string; someField: string }>(
|
|
64
|
-
|
|
96
|
+
tenantItemsTable as unknown as TableColumns,
|
|
65
97
|
{ someField: "match" },
|
|
66
98
|
);
|
|
67
99
|
|
|
@@ -71,18 +103,23 @@ describe("TenantDb.global() — reads, real Postgres", () => {
|
|
|
71
103
|
});
|
|
72
104
|
|
|
73
105
|
describe("TenantDb.global() — writes, real Postgres", () => {
|
|
74
|
-
test("insert/update/delete
|
|
106
|
+
test("insert/update/delete are rejected without a globalWrites grant", async () => {
|
|
107
|
+
const tdb = createTenantDb(stack.db, tenantA);
|
|
108
|
+
await expect(
|
|
109
|
+
tdb.global(globalItemsTable).insertOne({ someField: "ungranted" }),
|
|
110
|
+
).rejects.toThrow(AccessDeniedError);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("insert/update/delete succeed when createTenantDb was given a globalWrites grant", async () => {
|
|
75
114
|
const tdb = createTenantDb(stack.db, tenantA, "tenant", undefined, undefined, undefined, {
|
|
76
|
-
reason: "fw#
|
|
115
|
+
globalWrites: { reason: "fw#2858 integration test — cross-tenant write via db.global()" },
|
|
77
116
|
});
|
|
78
117
|
const globalDb = tdb.global(globalItemsTable);
|
|
79
118
|
|
|
80
|
-
const inserted = await globalDb.insertOne<{ id: string
|
|
81
|
-
tenantId: tenantB,
|
|
119
|
+
const inserted = await globalDb.insertOne<{ id: string }>({
|
|
82
120
|
someField: "escape-hatch-insert",
|
|
83
121
|
});
|
|
84
122
|
if (!inserted) throw new Error("insertOne returned no row");
|
|
85
|
-
expect(inserted.tenantId).toBe(tenantB);
|
|
86
123
|
|
|
87
124
|
const updated = await globalDb.updateMany<{ someField: string }>(
|
|
88
125
|
{ someField: "escape-hatch-update" },
|