@cosmicdrift/kumiko-framework 0.268.0 → 0.269.1
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__/stream-escape-hatch.integration.test.ts +203 -0
- package/src/changes.json +13 -0
- 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/boot-validator/__tests__/access-declarations.test.ts +37 -0
- package/src/engine/boot-validator/access-declarations.ts +6 -1
- package/src/engine/feature-entity-handlers.ts +7 -1
- package/src/engine/steps/call-feature.ts +1 -1
- package/src/errors/i18n/de.yaml +16 -0
- package/src/errors/i18n/en.yaml +15 -0
- package/src/errors/reasons.ts +5 -0
- package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +201 -8
- package/src/pipeline/__tests__/system-identity-switch.test.ts +185 -19
- package/src/pipeline/dispatch-shared.ts +22 -14
- package/src/pipeline/system-identity-switch.ts +72 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.269.1",
|
|
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.1",
|
|
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.1",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
|
@@ -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/changes.json
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
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
|
+
},
|
|
2
15
|
{
|
|
3
16
|
"version": "0.267.0",
|
|
4
17
|
"type": "breaking",
|
|
@@ -34,6 +34,7 @@ describe("r.hook({ allOf }, ...) identity-switch gate", () => {
|
|
|
34
34
|
// escapeHatch or is systemScope) — the hook must not inherit this.
|
|
35
35
|
const handlerCtx = createGatedIdentitySwitch(
|
|
36
36
|
'handler "test:write:thing:create"',
|
|
37
|
+
undefined,
|
|
37
38
|
true,
|
|
38
39
|
ungated,
|
|
39
40
|
);
|
|
@@ -70,6 +71,7 @@ describe("r.hook({ allOf }, ...) identity-switch gate", () => {
|
|
|
70
71
|
};
|
|
71
72
|
const handlerCtx = createGatedIdentitySwitch(
|
|
72
73
|
'handler "test:write:thing:create"',
|
|
74
|
+
undefined,
|
|
73
75
|
false,
|
|
74
76
|
ungated,
|
|
75
77
|
);
|
|
@@ -114,7 +116,7 @@ describe("r.hook({ allOf }, ...) identity-switch gate", () => {
|
|
|
114
116
|
};
|
|
115
117
|
const ungatedResolveActiveMembership = mock(async () => activeResult);
|
|
116
118
|
const handlerCtx = {
|
|
117
|
-
...createGatedIdentitySwitch('handler "test:write:thing:create"', true, {
|
|
119
|
+
...createGatedIdentitySwitch('handler "test:write:thing:create"', undefined, true, {
|
|
118
120
|
queryAs: mock(async () => "ok") as QueryAsFn,
|
|
119
121
|
writeAs: async () => ({ isSuccess: true as const, data: null }),
|
|
120
122
|
}),
|
|
@@ -155,7 +157,7 @@ describe("r.hook({ allOf }, ...) identity-switch gate", () => {
|
|
|
155
157
|
};
|
|
156
158
|
const ungatedResolveActiveMembership = mock(async () => activeResult);
|
|
157
159
|
const handlerCtx = {
|
|
158
|
-
...createGatedIdentitySwitch('handler "test:write:thing:create"', false, {
|
|
160
|
+
...createGatedIdentitySwitch('handler "test:write:thing:create"', undefined, false, {
|
|
159
161
|
queryAs: mock(async () => "ok") as QueryAsFn,
|
|
160
162
|
writeAs: async () => ({ isSuccess: true as const, data: null }),
|
|
161
163
|
}),
|
|
@@ -144,6 +144,43 @@ describe("validateAccessDeclarations", () => {
|
|
|
144
144
|
expect(() => validateAccessDeclarations(feature)).not.toThrow();
|
|
145
145
|
});
|
|
146
146
|
|
|
147
|
+
// 2c. escapeHatch reason must be non-empty on a stream handler too — same
|
|
148
|
+
// contract as write/query: streams can't reach db.global(), but they can
|
|
149
|
+
// still declare escapeHatch to switch identity to SYSTEM via ctx.queryAs.
|
|
150
|
+
test("stream handler escapeHatch with an empty reason throws, naming the handler", () => {
|
|
151
|
+
const feature = defineFeature("notes", (r) => {
|
|
152
|
+
r.entity("note", noteEntity);
|
|
153
|
+
r.streamHandler(
|
|
154
|
+
"note:tail",
|
|
155
|
+
z.object({}),
|
|
156
|
+
async function* () {
|
|
157
|
+
yield {};
|
|
158
|
+
},
|
|
159
|
+
{ access: { roles: ["Admin"] }, escapeHatch: { reason: "" } },
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/Feature notes/);
|
|
163
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/"note:tail"/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("stream handler escapeHatch with a non-empty reason boots fine", () => {
|
|
167
|
+
const feature = defineFeature("notes", (r) => {
|
|
168
|
+
r.entity("note", noteEntity);
|
|
169
|
+
r.streamHandler(
|
|
170
|
+
"note:tail",
|
|
171
|
+
z.object({}),
|
|
172
|
+
async function* () {
|
|
173
|
+
yield {};
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
access: { roles: ["Admin"] },
|
|
177
|
+
escapeHatch: { reason: "cross-tenant note tail for auth" },
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
expect(() => validateAccessDeclarations(feature)).not.toThrow();
|
|
182
|
+
});
|
|
183
|
+
|
|
147
184
|
// 3. openToAll write handler accepting an unbound personal-data field needs personalData.
|
|
148
185
|
test("openToAll write handler accepting a personal-data field without personalData throws", () => {
|
|
149
186
|
const feature = defineFeature("notes", (r) => {
|
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
OwnershipMap,
|
|
5
5
|
OwnershipRule,
|
|
6
6
|
QueryHandlerDef,
|
|
7
|
+
StreamHandlerDef,
|
|
7
8
|
WriteHandlerDef,
|
|
8
9
|
} from "../types";
|
|
9
10
|
import type { EntityDefinition, ResolvedPiiFlags } from "../types/fields";
|
|
@@ -133,7 +134,10 @@ function validateEscapeHatchReason(
|
|
|
133
134
|
feature: FeatureDefinition,
|
|
134
135
|
kind: HandlerKind,
|
|
135
136
|
handlerName: string,
|
|
136
|
-
escapeHatch:
|
|
137
|
+
escapeHatch:
|
|
138
|
+
| WriteHandlerDef["escapeHatch"]
|
|
139
|
+
| QueryHandlerDef["escapeHatch"]
|
|
140
|
+
| StreamHandlerDef["escapeHatch"],
|
|
137
141
|
): void {
|
|
138
142
|
// skip: no escapeHatch declared, or its reason is already non-empty
|
|
139
143
|
if (!escapeHatch || escapeHatch.reason.trim().length > 0) return;
|
|
@@ -212,6 +216,7 @@ export function validateAccessDeclarations(feature: FeatureDefinition): void {
|
|
|
212
216
|
}
|
|
213
217
|
for (const [handlerName, handler] of Object.entries(feature.streamHandlers)) {
|
|
214
218
|
validateOpenToAllReason(feature, "stream", handlerName, handler.access);
|
|
219
|
+
validateEscapeHatchReason(feature, "stream", handlerName, handler.escapeHatch);
|
|
215
220
|
validatePersonalDataOnlyOnWrite(feature, "stream", handlerName, handler.access);
|
|
216
221
|
}
|
|
217
222
|
}
|
|
@@ -228,7 +228,11 @@ export function buildEntityHandlerMethods<TName extends string>(
|
|
|
228
228
|
nameOrDef: string | StreamHandlerDefinition<TName, TSchema>,
|
|
229
229
|
schema?: TSchema,
|
|
230
230
|
handler?: StreamHandlerFn<z.infer<TSchema>>,
|
|
231
|
-
options?: {
|
|
231
|
+
options?: {
|
|
232
|
+
access: AccessRule;
|
|
233
|
+
rateLimit?: RateLimitOption;
|
|
234
|
+
escapeHatch?: EscapeHatchDeclaration;
|
|
235
|
+
},
|
|
232
236
|
): HandlerRef {
|
|
233
237
|
if (typeof nameOrDef === "object") {
|
|
234
238
|
const def = nameOrDef;
|
|
@@ -239,6 +243,7 @@ export function buildEntityHandlerMethods<TName extends string>(
|
|
|
239
243
|
handler: def.handler as StreamHandlerFn, // @cast-boundary engine-bridge
|
|
240
244
|
access: def.access,
|
|
241
245
|
...(def.rateLimit && { rateLimit: def.rateLimit }),
|
|
246
|
+
...(def.escapeHatch && { escapeHatch: def.escapeHatch }),
|
|
242
247
|
};
|
|
243
248
|
return { name: def.name };
|
|
244
249
|
}
|
|
@@ -249,6 +254,7 @@ export function buildEntityHandlerMethods<TName extends string>(
|
|
|
249
254
|
handler: inline.handler as StreamHandlerFn, // @cast-boundary engine-bridge
|
|
250
255
|
access: inline.options.access,
|
|
251
256
|
...(inline.options.rateLimit && { rateLimit: inline.options.rateLimit }),
|
|
257
|
+
...(inline.options.escapeHatch && { escapeHatch: inline.options.escapeHatch }),
|
|
252
258
|
};
|
|
253
259
|
return { name: nameOrDef };
|
|
254
260
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// r.step.callFeature — typed sub-command on another Kumiko feature.
|
|
2
2
|
// Tier-2: requires r.requires.step("callFeature"). Sync (no dispatcher).
|
|
3
|
-
//
|
|
3
|
+
// opts.as other than the caller itself needs escapeHatch on the handler (system-identity-switch.ts).
|
|
4
4
|
|
|
5
5
|
import { defineStep } from "../define-step";
|
|
6
6
|
import type { SessionUser } from "../types";
|
package/src/errors/i18n/de.yaml
CHANGED
|
@@ -97,6 +97,22 @@ system_identity_switch_denied:
|
|
|
97
97
|
(oder am `r.hook(...)`-Aufruf, wenn der Wechsel in einem Hook passiert),
|
|
98
98
|
oder verlagere die Logik in ein `r.systemScope()`-Feature oder einen Job.
|
|
99
99
|
|
|
100
|
+
identity_switch_denied:
|
|
101
|
+
endUser: |
|
|
102
|
+
Diese Aktion ist nicht erlaubt.
|
|
103
|
+
Wende dich an einen Administrator wenn du glaubst, dass das ein Fehler ist.
|
|
104
|
+
developer: |
|
|
105
|
+
`AccessDeniedError`: ein Handler oder Hook hat `ctx.queryAs`/`ctx.writeAs`
|
|
106
|
+
mit einer anderen Identität als dem eigenen Caller aufgerufen — andere
|
|
107
|
+
User-ID, anderer Tenant, andere Claims oder Origin, oder Rollen, die der
|
|
108
|
+
Caller nicht hat — ohne `r.systemScope()`-Handler, Job zu sein oder
|
|
109
|
+
`escapeHatch: { reason }` zu deklarieren. Der Wechsel auf den Caller selbst
|
|
110
|
+
oder eine Teilmenge seiner Rollen bleibt frei.
|
|
111
|
+
|
|
112
|
+
Deklariere `escapeHatch: { reason: "..." }` am Write- oder Query-Handler
|
|
113
|
+
(oder am `r.hook(...)`-Aufruf, wenn der Wechsel in einem Hook passiert),
|
|
114
|
+
oder verlagere die Logik in ein `r.systemScope()`-Feature oder einen Job.
|
|
115
|
+
|
|
100
116
|
member_resolution_denied:
|
|
101
117
|
endUser: |
|
|
102
118
|
Diese Aktion ist nicht erlaubt.
|
package/src/errors/i18n/en.yaml
CHANGED
|
@@ -94,6 +94,21 @@ system_identity_switch_denied:
|
|
|
94
94
|
(or on the `r.hook(...)` call, if the switch happens inside a hook), or
|
|
95
95
|
move the logic into an `r.systemScope()` feature or a job.
|
|
96
96
|
|
|
97
|
+
identity_switch_denied:
|
|
98
|
+
endUser: |
|
|
99
|
+
This action isn't allowed.
|
|
100
|
+
Please contact an administrator if you believe this is a mistake.
|
|
101
|
+
developer: |
|
|
102
|
+
`AccessDeniedError`: a handler or hook called `ctx.queryAs`/`ctx.writeAs`
|
|
103
|
+
with an identity other than its own caller — a different user id, tenant,
|
|
104
|
+
claims or origin, or roles the caller does not have — without being an
|
|
105
|
+
`r.systemScope()` handler, a job, or declaring `escapeHatch: { reason }`.
|
|
106
|
+
Switching to the caller itself or to a subset of its roles stays free.
|
|
107
|
+
|
|
108
|
+
Declare `escapeHatch: { reason: "..." }` on the write or query handler
|
|
109
|
+
(or on the `r.hook(...)` call, if the switch happens inside a hook), or
|
|
110
|
+
move the logic into an `r.systemScope()` feature or a job.
|
|
111
|
+
|
|
97
112
|
member_resolution_denied:
|
|
98
113
|
endUser: |
|
|
99
114
|
This action isn't allowed.
|
package/src/errors/reasons.ts
CHANGED
|
@@ -43,6 +43,11 @@ export const FrameworkReasons = {
|
|
|
43
43
|
// SYSTEM identity without an r.systemScope() feature or a declared escapeHatch.
|
|
44
44
|
systemIdentitySwitchDenied: "system_identity_switch_denied",
|
|
45
45
|
|
|
46
|
+
// AccessDeniedError: a handler/hook called ctx.queryAs/ctx.writeAs with a
|
|
47
|
+
// non-SYSTEM identity other than its own caller (different user, tenant,
|
|
48
|
+
// claims, origin or extra roles) without r.systemScope() or escapeHatch.
|
|
49
|
+
identitySwitchDenied: "identity_switch_denied",
|
|
50
|
+
|
|
46
51
|
// AccessDeniedError: ctx.queryAsMember's userId is not an active member of
|
|
47
52
|
// ctx's tenant. Deliberately generic — a caller can't probe which check failed.
|
|
48
53
|
memberResolutionDenied: "member_resolution_denied",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// fw#2859 — ctx.queryAs/ctx.writeAs to
|
|
2
|
-
//
|
|
1
|
+
// fw#2859/#2876 — ctx.queryAs/ctx.writeAs to anyone but the caller itself (or a role subset) needs
|
|
2
|
+
// r.systemScope() or { escapeHatch }. Real HTTP calls + setupTestStack — never createTestDispatcher.
|
|
3
3
|
|
|
4
4
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
5
5
|
import { z } from "zod";
|
|
@@ -33,6 +33,21 @@ const otherUserWithAdminRole: SessionUser = createTestUser({
|
|
|
33
33
|
roles: ["Admin"],
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
+
const multiRoleUser: SessionUser = createTestUser({
|
|
37
|
+
id: 52,
|
|
38
|
+
tenantId: user.tenantId,
|
|
39
|
+
roles: ["User", "Editor"],
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function foreignTenantTwin(caller: SessionUser): SessionUser {
|
|
43
|
+
return { ...caller, tenantId: TestUsers.otherTenant.tenantId };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function errorReason(details: unknown): string | undefined {
|
|
47
|
+
if (!details || typeof details !== "object" || !("reason" in details)) return undefined;
|
|
48
|
+
return typeof details.reason === "string" ? details.reason : undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
36
51
|
const hookThingEntity = createEntity({
|
|
37
52
|
table: "idswitch_hook_things",
|
|
38
53
|
fields: {
|
|
@@ -46,6 +61,7 @@ const hookThingExecutor = createEventStoreExecutor(hookThingTable, hookThingEnti
|
|
|
46
61
|
|
|
47
62
|
const hookNoEscapeHatchOutcomes: Array<{ readonly threw: boolean }> = [];
|
|
48
63
|
const hookWithEscapeHatchOutcomes: Array<{ readonly ok: boolean }> = [];
|
|
64
|
+
const hookForeignNoEscapeHatchOutcomes: Array<{ readonly threw: boolean }> = [];
|
|
49
65
|
|
|
50
66
|
const probeFeature = defineFeature("idswitch-probe", (r) => {
|
|
51
67
|
r.entity("hookThing", hookThingEntity);
|
|
@@ -132,9 +148,29 @@ const probeFeature = defineFeature("idswitch-probe", (r) => {
|
|
|
132
148
|
},
|
|
133
149
|
);
|
|
134
150
|
|
|
135
|
-
// --- Non-SYSTEM switch:
|
|
151
|
+
// --- Non-SYSTEM switch (fw#2876): only the caller itself (or a role subset) is free ---
|
|
152
|
+
r.queryHandler(
|
|
153
|
+
"query-as-self",
|
|
154
|
+
z.object({ roles: z.array(z.string()).optional() }),
|
|
155
|
+
async (query, ctx) =>
|
|
156
|
+
ctx.queryAs(
|
|
157
|
+
query.payload.roles ? { ...query.user, roles: query.payload.roles } : query.user,
|
|
158
|
+
"idswitch-probe:query:whoami",
|
|
159
|
+
{},
|
|
160
|
+
),
|
|
161
|
+
{ access: { roles: ["User"] } },
|
|
162
|
+
);
|
|
163
|
+
|
|
136
164
|
r.queryHandler(
|
|
137
165
|
"query-as-normal-user",
|
|
166
|
+
z.object({}),
|
|
167
|
+
async (_query, ctx) =>
|
|
168
|
+
ctx.queryAs(otherUserWithAdminRole, "idswitch-probe:query:admin-only", {}),
|
|
169
|
+
{ access: { roles: ["User"] } },
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
r.queryHandler(
|
|
173
|
+
"query-as-normal-user-with-hatch",
|
|
138
174
|
z.object({ granted: z.boolean() }),
|
|
139
175
|
async (query, ctx) =>
|
|
140
176
|
ctx.queryAs(
|
|
@@ -142,9 +178,48 @@ const probeFeature = defineFeature("idswitch-probe", (r) => {
|
|
|
142
178
|
"idswitch-probe:query:admin-only",
|
|
143
179
|
{},
|
|
144
180
|
),
|
|
181
|
+
{
|
|
182
|
+
access: { roles: ["User"] },
|
|
183
|
+
escapeHatch: { reason: "test: reads admin-only data as a named colleague" },
|
|
184
|
+
},
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
r.writeHandler(
|
|
188
|
+
"write-as-foreign-tenant",
|
|
189
|
+
z.object({}),
|
|
190
|
+
async (event, ctx) =>
|
|
191
|
+
ctx.writeAs(foreignTenantTwin(event.user), "idswitch-probe:write:whoami-write", {}),
|
|
145
192
|
{ access: { roles: ["User"] } },
|
|
146
193
|
);
|
|
147
194
|
|
|
195
|
+
r.writeHandler(
|
|
196
|
+
"write-as-foreign-tenant-with-hatch",
|
|
197
|
+
z.object({}),
|
|
198
|
+
async (event, ctx) =>
|
|
199
|
+
ctx.writeAs(foreignTenantTwin(event.user), "idswitch-probe:write:whoami-write", {}),
|
|
200
|
+
{
|
|
201
|
+
access: { roles: ["User"] },
|
|
202
|
+
escapeHatch: { reason: "test: acts for the same user inside a second tenant" },
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
// Reached through outer-with-hatch as a colleague — must not inherit the outer grant.
|
|
207
|
+
r.writeHandler(
|
|
208
|
+
"inner-foreign-no-hatch",
|
|
209
|
+
z.object({}),
|
|
210
|
+
async (event, ctx) =>
|
|
211
|
+
ctx.writeAs(foreignTenantTwin(event.user), "idswitch-probe:write:whoami-write", {}),
|
|
212
|
+
{ access: { roles: ["User"] } },
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
r.writeHandler(
|
|
216
|
+
"outer-as-colleague-with-hatch",
|
|
217
|
+
z.object({}),
|
|
218
|
+
async (_event, ctx) =>
|
|
219
|
+
ctx.writeAs(otherUserNoRole, "idswitch-probe:write:inner-foreign-no-hatch", {}),
|
|
220
|
+
{ access: { roles: ["User"] }, escapeHatch: { reason: "test: outer acts as a colleague" } },
|
|
221
|
+
);
|
|
222
|
+
|
|
148
223
|
// --- Nested: no transitive grant ---
|
|
149
224
|
r.writeHandler(
|
|
150
225
|
"inner-no-hatch",
|
|
@@ -229,6 +304,29 @@ const probeFeature = defineFeature("idswitch-probe", (r) => {
|
|
|
229
304
|
},
|
|
230
305
|
{ phase: HookPhases.inTransaction, escapeHatch: { reason: "test: hook needs SYSTEM" } },
|
|
231
306
|
);
|
|
307
|
+
|
|
308
|
+
r.writeHandler(
|
|
309
|
+
"hook-foreign-target-with-hatch",
|
|
310
|
+
z.object({ label: z.string() }),
|
|
311
|
+
async (event, ctx) => hookThingExecutor.create(event.payload, event.user, ctx.db),
|
|
312
|
+
{ access: { roles: ["User"] }, escapeHatch: { reason: "test: handler acts cross-tenant" } },
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
r.hook(
|
|
316
|
+
"postSave",
|
|
317
|
+
"hook-foreign-target-with-hatch",
|
|
318
|
+
async (_result, ctx) => {
|
|
319
|
+
const handlerCtx = ctx as unknown as HandlerContext;
|
|
320
|
+
try {
|
|
321
|
+
await handlerCtx.writeAs(foreignTenantTwin(user), "idswitch-probe:write:whoami-write", {});
|
|
322
|
+
hookForeignNoEscapeHatchOutcomes.push({ threw: false });
|
|
323
|
+
} catch (err) {
|
|
324
|
+
hookForeignNoEscapeHatchOutcomes.push({ threw: true });
|
|
325
|
+
throw err;
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
{ phase: HookPhases.inTransaction },
|
|
329
|
+
);
|
|
232
330
|
});
|
|
233
331
|
|
|
234
332
|
const systemScopeFeature = defineFeature("idswitch-probe-system", (r) => {
|
|
@@ -241,6 +339,18 @@ const systemScopeFeature = defineFeature("idswitch-probe-system", (r) => {
|
|
|
241
339
|
ctx.writeAs(createSystemUser(event.user.tenantId), "idswitch-probe:write:whoami-write", {}),
|
|
242
340
|
{ access: { roles: ["Admin"] } },
|
|
243
341
|
);
|
|
342
|
+
|
|
343
|
+
r.writeHandler(
|
|
344
|
+
"write-as-foreign-tenant",
|
|
345
|
+
z.object({}),
|
|
346
|
+
async (event, ctx) =>
|
|
347
|
+
ctx.writeAs(
|
|
348
|
+
{ ...foreignTenantTwin(event.user), roles: ["User"] },
|
|
349
|
+
"idswitch-probe:write:whoami-write",
|
|
350
|
+
{},
|
|
351
|
+
),
|
|
352
|
+
{ access: { roles: ["Admin"] } },
|
|
353
|
+
);
|
|
244
354
|
});
|
|
245
355
|
|
|
246
356
|
let stack: TestStack;
|
|
@@ -314,27 +424,110 @@ describe("ctx.resolveActiveMembership — gated like a SYSTEM queryAs/writeAs",
|
|
|
314
424
|
});
|
|
315
425
|
});
|
|
316
426
|
|
|
317
|
-
describe("ctx.queryAs
|
|
318
|
-
test("
|
|
427
|
+
describe("ctx.queryAs/writeAs to a non-SYSTEM identity (fw#2876)", () => {
|
|
428
|
+
test("switching to the caller itself is free", async () => {
|
|
429
|
+
const result = await stack.http.queryOk<{ roles: readonly string[]; tenantId: string }>(
|
|
430
|
+
"idswitch-probe:query:query-as-self",
|
|
431
|
+
{},
|
|
432
|
+
multiRoleUser,
|
|
433
|
+
);
|
|
434
|
+
expect(result.roles).toEqual(["User", "Editor"]);
|
|
435
|
+
expect(result.tenantId).toBe(multiRoleUser.tenantId);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("a subset of the caller's roles is free", async () => {
|
|
439
|
+
const result = await stack.http.queryOk<{ roles: readonly string[] }>(
|
|
440
|
+
"idswitch-probe:query:query-as-self",
|
|
441
|
+
{ roles: ["User"] },
|
|
442
|
+
multiRoleUser,
|
|
443
|
+
);
|
|
444
|
+
expect(result.roles).toEqual(["User"]);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("an additional role without escapeHatch is denied", async () => {
|
|
319
448
|
const err = await stack.http.queryErr(
|
|
320
|
-
"idswitch-probe:query:query-as-
|
|
449
|
+
"idswitch-probe:query:query-as-self",
|
|
450
|
+
{ roles: ["User", "Admin"] },
|
|
451
|
+
multiRoleUser,
|
|
452
|
+
);
|
|
453
|
+
expect(err.code).toBe("access_denied");
|
|
454
|
+
expect(errorReason(err.details)).toBe("identity_switch_denied");
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("a foreign user id without escapeHatch is denied, even when that user could read the target", async () => {
|
|
458
|
+
const err = await stack.http.queryErr("idswitch-probe:query:query-as-normal-user", {}, user);
|
|
459
|
+
expect(err.code).toBe("access_denied");
|
|
460
|
+
expect(errorReason(err.details)).toBe("identity_switch_denied");
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test("writeAs with a foreign tenantId without escapeHatch answers 403 access_denied", async () => {
|
|
464
|
+
const err = await stack.http.writeErr("idswitch-probe:write:write-as-foreign-tenant", {}, user);
|
|
465
|
+
expect(err.httpStatus).toBe(403);
|
|
466
|
+
expect(err.code).toBe("access_denied");
|
|
467
|
+
expect(errorReason(err.details)).toBe("identity_switch_denied");
|
|
468
|
+
expect(JSON.stringify(err)).not.toContain(TestUsers.otherTenant.tenantId);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("writeAs with a foreign tenantId WITH escapeHatch runs in that tenant", async () => {
|
|
472
|
+
const result = await stack.http.writeOk<{ tenantId: string }>(
|
|
473
|
+
"idswitch-probe:write:write-as-foreign-tenant-with-hatch",
|
|
474
|
+
{},
|
|
475
|
+
user,
|
|
476
|
+
);
|
|
477
|
+
expect(result.tenantId).toBe(TestUsers.otherTenant.tenantId);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("WITH escapeHatch the target's own access check still applies: denied when the target user lacks the role", async () => {
|
|
481
|
+
const err = await stack.http.queryErr(
|
|
482
|
+
"idswitch-probe:query:query-as-normal-user-with-hatch",
|
|
321
483
|
{ granted: false },
|
|
322
484
|
user,
|
|
323
485
|
);
|
|
324
486
|
expect(err.code).toBe("access_denied");
|
|
487
|
+
expect(errorReason(err.details)).not.toBe("identity_switch_denied");
|
|
325
488
|
});
|
|
326
489
|
|
|
327
|
-
test("
|
|
490
|
+
test("WITH escapeHatch: allowed when the target user has the role", async () => {
|
|
328
491
|
const result = await stack.http.queryOk<{ ok: boolean }>(
|
|
329
|
-
"idswitch-probe:query:query-as-normal-user",
|
|
492
|
+
"idswitch-probe:query:query-as-normal-user-with-hatch",
|
|
330
493
|
{ granted: true },
|
|
331
494
|
user,
|
|
332
495
|
);
|
|
333
496
|
expect(result.ok).toBe(true);
|
|
334
497
|
});
|
|
498
|
+
|
|
499
|
+
test("no transitive grant: outer-with-hatch -> colleague -> inner-no-hatch cross-tenant writeAs is denied", async () => {
|
|
500
|
+
const err = await stack.http.writeErr(
|
|
501
|
+
"idswitch-probe:write:outer-as-colleague-with-hatch",
|
|
502
|
+
{},
|
|
503
|
+
user,
|
|
504
|
+
);
|
|
505
|
+
expect(err.code).toBe("access_denied");
|
|
506
|
+
expect(errorReason(err.details)).toBe("identity_switch_denied");
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("hook without escapeHatch does not inherit the handler's grant for a cross-tenant writeAs", async () => {
|
|
510
|
+
hookForeignNoEscapeHatchOutcomes.length = 0;
|
|
511
|
+
const err = await stack.http.writeErr(
|
|
512
|
+
"idswitch-probe:write:hook-foreign-target-with-hatch",
|
|
513
|
+
{ label: "should-roll-back" },
|
|
514
|
+
user,
|
|
515
|
+
);
|
|
516
|
+
expect(err.code).toBe("access_denied");
|
|
517
|
+
expect(hookForeignNoEscapeHatchOutcomes).toEqual([{ threw: true }]);
|
|
518
|
+
});
|
|
335
519
|
});
|
|
336
520
|
|
|
337
521
|
describe("r.systemScope() feature — no escapeHatch needed", () => {
|
|
522
|
+
test("writeAs with a foreign tenantId succeeds from a systemScope handler", async () => {
|
|
523
|
+
const result = await stack.http.writeOk<{ tenantId: string }>(
|
|
524
|
+
"idswitch-probe-system:write:write-as-foreign-tenant",
|
|
525
|
+
{},
|
|
526
|
+
TestUsers.admin,
|
|
527
|
+
);
|
|
528
|
+
expect(result.tenantId).toBe(TestUsers.otherTenant.tenantId);
|
|
529
|
+
});
|
|
530
|
+
|
|
338
531
|
test("writeAs(SYSTEM) succeeds from a systemScope handler", async () => {
|
|
339
532
|
const result = await stack.http.writeOk<{ roles: readonly string[] }>(
|
|
340
533
|
"idswitch-probe-system:write:write-as-system",
|
|
@@ -8,8 +8,8 @@ import { AccessDeniedError, FrameworkReasons } from "../../errors";
|
|
|
8
8
|
import {
|
|
9
9
|
createGatedIdentitySwitch,
|
|
10
10
|
type IdentitySwitch,
|
|
11
|
+
isIdentitySwitchAllowed,
|
|
11
12
|
isSystemIdentity,
|
|
12
|
-
isSystemIdentitySwitchAllowed,
|
|
13
13
|
type WriteAsFn,
|
|
14
14
|
withHookEscapeHatchGrant,
|
|
15
15
|
} from "../system-identity-switch";
|
|
@@ -61,22 +61,89 @@ describe("isSystemIdentity", () => {
|
|
|
61
61
|
});
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
const OTHER_TENANT = "00000000-0000-4000-8000-00000000ab02" as TenantId;
|
|
65
|
+
const multiRoleCaller: SessionUser = {
|
|
66
|
+
id: crypto.randomUUID(),
|
|
67
|
+
tenantId: TENANT,
|
|
68
|
+
roles: ["User", "Editor"],
|
|
69
|
+
claims: { "teams:teamId": "team-1" },
|
|
70
|
+
sid: "session-1",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
describe("isIdentitySwitchAllowed", () => {
|
|
74
|
+
test("SYSTEM target requires a grant, even when the caller is SYSTEM itself", () => {
|
|
75
|
+
expect(isIdentitySwitchAllowed(normalUser, systemUserById, false)).toBe(false);
|
|
76
|
+
expect(isIdentitySwitchAllowed(systemUserById, systemUserById, false)).toBe(false);
|
|
77
|
+
expect(isIdentitySwitchAllowed(normalUser, systemUserByRole, true)).toBe(true);
|
|
68
78
|
});
|
|
69
79
|
|
|
70
|
-
test("
|
|
71
|
-
expect(
|
|
72
|
-
expect(
|
|
80
|
+
test("switching to the caller itself is free", () => {
|
|
81
|
+
expect(isIdentitySwitchAllowed(multiRoleCaller, multiRoleCaller, false)).toBe(true);
|
|
82
|
+
expect(isIdentitySwitchAllowed(multiRoleCaller, { ...multiRoleCaller }, false)).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("a subset of the caller's roles is free; sid/locale may differ", () => {
|
|
86
|
+
const narrowed: SessionUser = {
|
|
87
|
+
...multiRoleCaller,
|
|
88
|
+
roles: ["User"],
|
|
89
|
+
claims: { "teams:teamId": "team-1" },
|
|
90
|
+
sid: undefined,
|
|
91
|
+
locale: "de",
|
|
92
|
+
};
|
|
93
|
+
expect(isIdentitySwitchAllowed(multiRoleCaller, narrowed, false)).toBe(true);
|
|
94
|
+
expect(isIdentitySwitchAllowed(multiRoleCaller, { ...multiRoleCaller, roles: [] }, false)).toBe(
|
|
95
|
+
true,
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("an additional role without a grant is denied", () => {
|
|
100
|
+
const escalated: SessionUser = { ...multiRoleCaller, roles: ["User", "TenantAdmin"] };
|
|
101
|
+
expect(isIdentitySwitchAllowed(multiRoleCaller, escalated, false)).toBe(false);
|
|
102
|
+
expect(isIdentitySwitchAllowed(multiRoleCaller, escalated, true)).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("the same id in a foreign tenant without a grant is denied, even with a role subset", () => {
|
|
106
|
+
const foreignTenant: SessionUser = { ...multiRoleCaller, tenantId: OTHER_TENANT, roles: [] };
|
|
107
|
+
expect(isIdentitySwitchAllowed(multiRoleCaller, foreignTenant, false)).toBe(false);
|
|
108
|
+
expect(isIdentitySwitchAllowed(multiRoleCaller, foreignTenant, true)).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("a foreign id in the same tenant without a grant is denied, even with a role subset", () => {
|
|
112
|
+
const foreignUser: SessionUser = {
|
|
113
|
+
...multiRoleCaller,
|
|
114
|
+
id: crypto.randomUUID(),
|
|
115
|
+
roles: ["User"],
|
|
116
|
+
};
|
|
117
|
+
expect(isIdentitySwitchAllowed(multiRoleCaller, foreignUser, false)).toBe(false);
|
|
118
|
+
expect(isIdentitySwitchAllowed(multiRoleCaller, foreignUser, true)).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("changed claims or origin without a grant are denied", () => {
|
|
122
|
+
const forgedClaim: SessionUser = { ...multiRoleCaller, claims: { "teams:teamId": "team-2" } };
|
|
123
|
+
const droppedClaims: SessionUser = { ...multiRoleCaller, claims: undefined };
|
|
124
|
+
const extraClaim: SessionUser = {
|
|
125
|
+
...multiRoleCaller,
|
|
126
|
+
claims: { "teams:teamId": "team-1", "billing:plan": "enterprise" },
|
|
127
|
+
};
|
|
128
|
+
const memberCaller: SessionUser = { ...multiRoleCaller, origin: "member-resolution" };
|
|
129
|
+
const { origin: _origin, ...originStripped } = memberCaller;
|
|
130
|
+
expect(isIdentitySwitchAllowed(multiRoleCaller, forgedClaim, false)).toBe(false);
|
|
131
|
+
expect(isIdentitySwitchAllowed(multiRoleCaller, droppedClaims, false)).toBe(false);
|
|
132
|
+
expect(isIdentitySwitchAllowed(multiRoleCaller, extraClaim, false)).toBe(false);
|
|
133
|
+
expect(isIdentitySwitchAllowed(memberCaller, originStripped, false)).toBe(false);
|
|
134
|
+
expect(isIdentitySwitchAllowed(multiRoleCaller, memberCaller, false)).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("without a known caller only a grant lets a switch through", () => {
|
|
138
|
+
expect(isIdentitySwitchAllowed(undefined, normalUser, false)).toBe(false);
|
|
139
|
+
expect(isIdentitySwitchAllowed(undefined, normalUser, true)).toBe(true);
|
|
73
140
|
});
|
|
74
141
|
});
|
|
75
142
|
|
|
76
143
|
describe("createGatedIdentitySwitch", () => {
|
|
77
144
|
test("queryAs to SYSTEM throws AccessDeniedError with the reason code, no asUser/tenantId/payload leak", async () => {
|
|
78
145
|
const { ungated } = makeUngated();
|
|
79
|
-
const gated = createGatedIdentitySwitch('handler "x"', false, ungated);
|
|
146
|
+
const gated = createGatedIdentitySwitch('handler "x"', normalUser, false, ungated);
|
|
80
147
|
|
|
81
148
|
let caught: unknown;
|
|
82
149
|
try {
|
|
@@ -95,7 +162,7 @@ describe("createGatedIdentitySwitch", () => {
|
|
|
95
162
|
|
|
96
163
|
test("writeAs to SYSTEM throws the same way (never falls back to a WriteResult failure)", async () => {
|
|
97
164
|
const { ungated } = makeUngated();
|
|
98
|
-
const gated = createGatedIdentitySwitch('handler "x"', false, ungated);
|
|
165
|
+
const gated = createGatedIdentitySwitch('handler "x"', normalUser, false, ungated);
|
|
99
166
|
|
|
100
167
|
await expect(gated.writeAs(systemUserById, "some:target", {})).rejects.toBeInstanceOf(
|
|
101
168
|
AccessDeniedError,
|
|
@@ -104,7 +171,7 @@ describe("createGatedIdentitySwitch", () => {
|
|
|
104
171
|
|
|
105
172
|
test("allowSystemIdentity=true lets a SYSTEM switch pass through to the ungated pair", async () => {
|
|
106
173
|
const { ungated, queryAsMock, writeAsMock } = makeUngated();
|
|
107
|
-
const gated = createGatedIdentitySwitch('handler "x"', true, ungated);
|
|
174
|
+
const gated = createGatedIdentitySwitch('handler "x"', normalUser, true, ungated);
|
|
108
175
|
|
|
109
176
|
await gated.queryAs(systemUserById, "q", { a: 1 });
|
|
110
177
|
await gated.writeAs(systemUserById, "w", { b: 2 });
|
|
@@ -113,14 +180,48 @@ describe("createGatedIdentitySwitch", () => {
|
|
|
113
180
|
expect(writeAsMock).toHaveBeenCalledWith(systemUserById, "w", { b: 2 });
|
|
114
181
|
});
|
|
115
182
|
|
|
116
|
-
test("
|
|
183
|
+
test("switching to the caller itself passes through without a grant", async () => {
|
|
117
184
|
const { ungated, queryAsMock } = makeUngated();
|
|
118
|
-
const deniedByDefault = createGatedIdentitySwitch('handler "x"', false, ungated);
|
|
185
|
+
const deniedByDefault = createGatedIdentitySwitch('handler "x"', normalUser, false, ungated);
|
|
119
186
|
|
|
120
187
|
await deniedByDefault.queryAs(normalUser, "q", {});
|
|
121
188
|
|
|
122
189
|
expect(queryAsMock).toHaveBeenCalledWith(normalUser, "q", {});
|
|
123
190
|
});
|
|
191
|
+
|
|
192
|
+
test("writeAs to a foreign tenant without a grant throws identity_switch_denied, no id/tenant leak", async () => {
|
|
193
|
+
const { ungated, writeAsMock } = makeUngated();
|
|
194
|
+
const gated = createGatedIdentitySwitch('handler "x"', normalUser, false, ungated);
|
|
195
|
+
const foreignTenantAdmin: SessionUser = {
|
|
196
|
+
id: crypto.randomUUID(),
|
|
197
|
+
tenantId: OTHER_TENANT,
|
|
198
|
+
roles: ["TenantAdmin"],
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
let caught: unknown;
|
|
202
|
+
try {
|
|
203
|
+
await gated.writeAs(foreignTenantAdmin, "billing:write:cancel", {});
|
|
204
|
+
} catch (err) {
|
|
205
|
+
caught = err;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
expect(caught).toBeInstanceOf(AccessDeniedError);
|
|
209
|
+
const error = caught as AccessDeniedError;
|
|
210
|
+
expect(error.details).toEqual({ reason: FrameworkReasons.identitySwitchDenied });
|
|
211
|
+
expect(error.message).not.toContain(foreignTenantAdmin.id);
|
|
212
|
+
expect(error.message).not.toContain(OTHER_TENANT);
|
|
213
|
+
expect(writeAsMock).not.toHaveBeenCalled();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("a grant lets a foreign identity through", async () => {
|
|
217
|
+
const { ungated, writeAsMock } = makeUngated();
|
|
218
|
+
const gated = createGatedIdentitySwitch('handler "x"', normalUser, true, ungated);
|
|
219
|
+
const foreignUser: SessionUser = { id: crypto.randomUUID(), tenantId: OTHER_TENANT, roles: [] };
|
|
220
|
+
|
|
221
|
+
await gated.writeAs(foreignUser, "w", {});
|
|
222
|
+
|
|
223
|
+
expect(writeAsMock).toHaveBeenCalledWith(foreignUser, "w", {});
|
|
224
|
+
});
|
|
124
225
|
});
|
|
125
226
|
|
|
126
227
|
describe("withHookEscapeHatchGrant", () => {
|
|
@@ -128,7 +229,9 @@ describe("withHookEscapeHatchGrant", () => {
|
|
|
128
229
|
const { ungated } = makeUngated();
|
|
129
230
|
// Simulates dispatch-shared.ts's buildHandlerContext for a handler that
|
|
130
231
|
// itself declared escapeHatch / is systemScope (allowSystemIdentity=true).
|
|
131
|
-
const handlerCtx = {
|
|
232
|
+
const handlerCtx = {
|
|
233
|
+
...createGatedIdentitySwitch('handler "outer"', normalUser, true, ungated),
|
|
234
|
+
};
|
|
132
235
|
|
|
133
236
|
const hookCtx = withHookEscapeHatchGrant(handlerCtx, 'postSave hook of feature "f"', undefined);
|
|
134
237
|
|
|
@@ -140,7 +243,9 @@ describe("withHookEscapeHatchGrant", () => {
|
|
|
140
243
|
test("hook WITH escapeHatch is allowed even when the handler's own grant was false", async () => {
|
|
141
244
|
const { ungated, queryAsMock } = makeUngated();
|
|
142
245
|
// Handler itself has NO escapeHatch (allowSystemIdentity=false).
|
|
143
|
-
const handlerCtx = {
|
|
246
|
+
const handlerCtx = {
|
|
247
|
+
...createGatedIdentitySwitch('handler "outer"', normalUser, false, ungated),
|
|
248
|
+
};
|
|
144
249
|
|
|
145
250
|
const hookCtx = withHookEscapeHatchGrant(handlerCtx, 'postSave hook of feature "f"', {
|
|
146
251
|
reason: "test hook needs SYSTEM",
|
|
@@ -151,18 +256,65 @@ describe("withHookEscapeHatchGrant", () => {
|
|
|
151
256
|
expect(queryAsMock).toHaveBeenCalledWith(systemUserById, "q", { a: 1 });
|
|
152
257
|
});
|
|
153
258
|
|
|
154
|
-
test("a
|
|
259
|
+
test("a hook without escapeHatch may still switch to the handler's own caller", async () => {
|
|
155
260
|
const { ungated, queryAsMock } = makeUngated();
|
|
156
|
-
const handlerCtx = {
|
|
261
|
+
const handlerCtx = {
|
|
262
|
+
...createGatedIdentitySwitch('handler "outer"', normalUser, false, ungated),
|
|
263
|
+
};
|
|
157
264
|
const hookCtx = withHookEscapeHatchGrant(handlerCtx, "hook", undefined);
|
|
158
265
|
|
|
159
266
|
await hookCtx.queryAs(normalUser, "q", {});
|
|
160
267
|
expect(queryAsMock).toHaveBeenCalledWith(normalUser, "q", {});
|
|
161
268
|
});
|
|
162
269
|
|
|
270
|
+
test("a hook without escapeHatch does NOT inherit the handler's grant for a foreign identity", async () => {
|
|
271
|
+
const { ungated, writeAsMock } = makeUngated();
|
|
272
|
+
const handlerCtx = {
|
|
273
|
+
...createGatedIdentitySwitch('handler "outer"', normalUser, true, ungated),
|
|
274
|
+
};
|
|
275
|
+
const hookCtx = withHookEscapeHatchGrant(handlerCtx, "hook", undefined);
|
|
276
|
+
const foreignUser: SessionUser = { ...normalUser, tenantId: OTHER_TENANT };
|
|
277
|
+
|
|
278
|
+
await expect(hookCtx.writeAs(foreignUser, "w", {})).rejects.toBeInstanceOf(AccessDeniedError);
|
|
279
|
+
expect(writeAsMock).not.toHaveBeenCalled();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("the hook gate takes the caller from the dispatcher, never from a spoofed ctx.user", async () => {
|
|
283
|
+
const { ungated, writeAsMock } = makeUngated();
|
|
284
|
+
const foreignUser: SessionUser = {
|
|
285
|
+
id: crypto.randomUUID(),
|
|
286
|
+
tenantId: OTHER_TENANT,
|
|
287
|
+
roles: ["Admin"],
|
|
288
|
+
};
|
|
289
|
+
const spoofedCtx = {
|
|
290
|
+
...createGatedIdentitySwitch('handler "outer"', normalUser, false, ungated),
|
|
291
|
+
user: foreignUser,
|
|
292
|
+
};
|
|
293
|
+
const hookCtx = withHookEscapeHatchGrant(spoofedCtx, "hook", undefined);
|
|
294
|
+
|
|
295
|
+
await expect(hookCtx.writeAs(foreignUser, "w", {})).rejects.toBeInstanceOf(AccessDeniedError);
|
|
296
|
+
expect(writeAsMock).not.toHaveBeenCalled();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("queryAs and writeAs gated for different callers yield no caller — only a grant passes", async () => {
|
|
300
|
+
const { ungated, queryAsMock } = makeUngated();
|
|
301
|
+
const otherCaller: SessionUser = { ...normalUser, id: crypto.randomUUID() };
|
|
302
|
+
const mixedCtx = {
|
|
303
|
+
queryAs: createGatedIdentitySwitch('handler "a"', normalUser, false, ungated).queryAs,
|
|
304
|
+
writeAs: createGatedIdentitySwitch('handler "b"', otherCaller, false, ungated).writeAs,
|
|
305
|
+
};
|
|
306
|
+
const hookCtx = withHookEscapeHatchGrant(mixedCtx, "hook", undefined);
|
|
307
|
+
|
|
308
|
+
await expect(hookCtx.queryAs(normalUser, "q", {})).rejects.toBeInstanceOf(AccessDeniedError);
|
|
309
|
+
expect(queryAsMock).not.toHaveBeenCalled();
|
|
310
|
+
});
|
|
311
|
+
|
|
163
312
|
test("spreading the context ({...ctx}) still resolves back to the ungated pair", async () => {
|
|
164
313
|
const { ungated, queryAsMock } = makeUngated();
|
|
165
|
-
const handlerCtx = {
|
|
314
|
+
const handlerCtx = {
|
|
315
|
+
...createGatedIdentitySwitch('handler "outer"', normalUser, true, ungated),
|
|
316
|
+
extra: 1,
|
|
317
|
+
};
|
|
166
318
|
const spread = { ...handlerCtx };
|
|
167
319
|
|
|
168
320
|
const hookCtx = withHookEscapeHatchGrant(spread, "hook", { reason: "needs system" });
|
|
@@ -180,7 +332,12 @@ describe("withHookEscapeHatchGrant", () => {
|
|
|
180
332
|
test("a deny-stubbed writeAs is not revived through a sibling queryAs's registered ungated pair", async () => {
|
|
181
333
|
// Like a member-resolution ctx: registered gated queryAs next to an unregistered deny-stub writeAs.
|
|
182
334
|
const { ungated, writeAsMock } = makeUngated();
|
|
183
|
-
const gatedIdentitySwitch = createGatedIdentitySwitch(
|
|
335
|
+
const gatedIdentitySwitch = createGatedIdentitySwitch(
|
|
336
|
+
'handler "outer"',
|
|
337
|
+
normalUser,
|
|
338
|
+
true,
|
|
339
|
+
ungated,
|
|
340
|
+
);
|
|
184
341
|
const denyStubWriteAs: WriteAsFn = async () => {
|
|
185
342
|
throw new AccessDeniedError({
|
|
186
343
|
message: "read-only",
|
|
@@ -207,6 +364,15 @@ describe("withHookEscapeHatchGrant", () => {
|
|
|
207
364
|
await expect(hookCtx.queryAs(systemUserById, "q", {})).rejects.toBeInstanceOf(
|
|
208
365
|
AccessDeniedError,
|
|
209
366
|
);
|
|
367
|
+
await expect(hookCtx.queryAs(normalUser, "q", {})).rejects.toBeInstanceOf(AccessDeniedError);
|
|
368
|
+
const stubWithUser = withHookEscapeHatchGrant(
|
|
369
|
+
{ ...stubContext, user: normalUser },
|
|
370
|
+
"hook",
|
|
371
|
+
undefined,
|
|
372
|
+
);
|
|
373
|
+
await expect(stubWithUser.queryAs(normalUser, "q", {})).rejects.toBeInstanceOf(
|
|
374
|
+
AccessDeniedError,
|
|
375
|
+
);
|
|
210
376
|
expect(stubQueryAs).not.toHaveBeenCalled();
|
|
211
377
|
|
|
212
378
|
const allowedHookCtx = withHookEscapeHatchGrant(stubContext, "hook", { reason: "x" });
|
|
@@ -274,10 +274,13 @@ export async function buildHandlerContext(
|
|
|
274
274
|
// but at this point we're the root of the pipeline — cast is safe.
|
|
275
275
|
const dbSource = resolveDbSource(ctx, tx);
|
|
276
276
|
const reqCtx = requestContext.get();
|
|
277
|
-
// global() writes are write-handler-only; SYSTEM identity switch and unsafeRaw accept write or
|
|
277
|
+
// global() writes are write-handler-only; SYSTEM identity switch and unsafeRaw accept write, query or stream escapeHatch.
|
|
278
278
|
const writeEscapeHatch = registry.getWriteHandler(type)?.escapeHatch;
|
|
279
|
-
const handlerEscapeHatch =
|
|
280
|
-
|
|
279
|
+
const handlerEscapeHatch =
|
|
280
|
+
writeEscapeHatch ??
|
|
281
|
+
registry.getQueryHandler(type)?.escapeHatch ??
|
|
282
|
+
registry.getStreamHandler(type)?.escapeHatch;
|
|
283
|
+
const hasIdentitySwitchGrant = isSystem || handlerEscapeHatch !== undefined;
|
|
281
284
|
const buildTenantScopedDb = (source: DbConnection | DbTx, signal: AbortSignal | undefined) =>
|
|
282
285
|
createTenantDb(
|
|
283
286
|
source,
|
|
@@ -375,23 +378,28 @@ export async function buildHandlerContext(
|
|
|
375
378
|
: () => createNoopMetricsHandle();
|
|
376
379
|
|
|
377
380
|
// Cross-feature bridge: ctx.query/write share the current tx + afterCommitHooks sink.
|
|
378
|
-
// queryAs/writeAs
|
|
381
|
+
// queryAs/writeAs to anyone but the caller itself needs r.systemScope() or { escapeHatch } (system-identity-switch.ts).
|
|
379
382
|
const bridgeSink = afterCommitHooks ?? [];
|
|
380
383
|
const scheduleAfterCommit = (hook: AfterCommitHook): void => {
|
|
381
384
|
bridgeSink.push(hook);
|
|
382
385
|
};
|
|
383
|
-
const identitySwitch = createGatedIdentitySwitch(
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
386
|
+
const identitySwitch = createGatedIdentitySwitch(
|
|
387
|
+
`handler "${type}"`,
|
|
388
|
+
user,
|
|
389
|
+
hasIdentitySwitchGrant,
|
|
390
|
+
{
|
|
391
|
+
queryAs: (asUser: SessionUser, targetType: string, payload: unknown) =>
|
|
392
|
+
executeQuery(ctx, targetType, payload, asUser, tx), // @wrapper-known semantic-alias
|
|
393
|
+
writeAs: (asUser: SessionUser, targetType: string, payload: unknown) =>
|
|
394
|
+
executeWrite(ctx, targetType, payload, asUser, tx, bridgeSink),
|
|
395
|
+
},
|
|
396
|
+
);
|
|
389
397
|
// Lazy — creates the reader (which fails closed on SYSTEM_TENANT_ID) only
|
|
390
398
|
// on first actual use, not on every HandlerContext build.
|
|
391
399
|
let ungatedMemberReader: MemberReader | undefined;
|
|
392
400
|
const queryAsMember = createGatedMemberReader(
|
|
393
401
|
`handler "${type}"`,
|
|
394
|
-
|
|
402
|
+
hasIdentitySwitchGrant,
|
|
395
403
|
(userId, qn, payload) => {
|
|
396
404
|
ungatedMemberReader ??= createMemberReaderFn(ctx, user.tenantId, tx);
|
|
397
405
|
return ungatedMemberReader(userId, qn, payload);
|
|
@@ -671,7 +679,7 @@ export async function buildHandlerContext(
|
|
|
671
679
|
// Internally queries memberships as SYSTEM, so it needs the same grant
|
|
672
680
|
// as a SYSTEM queryAs/writeAs (r.systemScope() or escapeHatch).
|
|
673
681
|
resolveActiveMembership: (userId: string, tenantId: TenantId) => {
|
|
674
|
-
if (!
|
|
682
|
+
if (!hasIdentitySwitchGrant) throw systemIdentitySwitchDenied(`handler "${type}"`);
|
|
675
683
|
return resolveActiveMembershipFn(ctx, userId, tenantId, INTERACTIVE_SIGN_IN_POLICY); // @wrapper-known semantic-alias
|
|
676
684
|
},
|
|
677
685
|
|
|
@@ -1046,7 +1054,7 @@ export async function enforceRateLimit(
|
|
|
1046
1054
|
// hooks run OUTSIDE any request transaction (login is itself the root
|
|
1047
1055
|
// operation, not a nested call) and read-only — so the TenantDb is
|
|
1048
1056
|
// scoped as "tenant" and no tx is threaded through. Cross-tenant lookups
|
|
1049
|
-
//
|
|
1057
|
+
// are denied: queryAs only reaches the claims user itself (no escapeHatch declaration site here).
|
|
1050
1058
|
function buildAuthClaimsContext(ctx: DispatchContext, user: SessionUser): AuthClaimsContext {
|
|
1051
1059
|
const { appContext: context } = ctx;
|
|
1052
1060
|
const dbSource = resolveDbSource(ctx, undefined);
|
|
@@ -1063,7 +1071,7 @@ function buildAuthClaimsContext(ctx: DispatchContext, user: SessionUser): AuthCl
|
|
|
1063
1071
|
secrets: context.secrets,
|
|
1064
1072
|
})
|
|
1065
1073
|
: undefined;
|
|
1066
|
-
const identitySwitch = createGatedIdentitySwitch("r.authClaims hook", false, {
|
|
1074
|
+
const identitySwitch = createGatedIdentitySwitch("r.authClaims hook", user, false, {
|
|
1067
1075
|
queryAs: (asUser: SessionUser, qn: string, payload: unknown) =>
|
|
1068
1076
|
executeQuery(ctx, qn, payload, asUser), // @wrapper-known semantic-alias
|
|
1069
1077
|
writeAs: async () => {
|
|
@@ -23,11 +23,38 @@ export function isSystemIdentity(user: SessionUser): boolean {
|
|
|
23
23
|
return user.id === SYSTEM_USER_ID || user.roles.includes(SYSTEM_ROLE);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
function hasSameClaims(caller: SessionUser, asUser: SessionUser): boolean {
|
|
27
|
+
if (caller.claims === asUser.claims) return true;
|
|
28
|
+
const callerClaims = caller.claims ?? {};
|
|
29
|
+
const asUserClaims = asUser.claims ?? {};
|
|
30
|
+
const keys = Object.keys(callerClaims);
|
|
31
|
+
if (keys.length !== Object.keys(asUserClaims).length) return false;
|
|
32
|
+
return keys.every(
|
|
33
|
+
(key) =>
|
|
34
|
+
Object.hasOwn(asUserClaims, key) &&
|
|
35
|
+
JSON.stringify(callerClaims[key]) === JSON.stringify(asUserClaims[key]),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// claims drive ownership row filters and origin makes a ctx read-only, so both must match exactly.
|
|
40
|
+
export function isSelfDelegation(caller: SessionUser, asUser: SessionUser): boolean {
|
|
41
|
+
return (
|
|
42
|
+
asUser.id === caller.id &&
|
|
43
|
+
asUser.tenantId === caller.tenantId &&
|
|
44
|
+
asUser.origin === caller.origin &&
|
|
45
|
+
asUser.roles.every((role) => caller.roles.includes(role)) &&
|
|
46
|
+
hasSameClaims(caller, asUser)
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isIdentitySwitchAllowed(
|
|
51
|
+
caller: SessionUser | undefined,
|
|
27
52
|
asUser: SessionUser,
|
|
28
|
-
|
|
53
|
+
hasGrant: boolean,
|
|
29
54
|
): boolean {
|
|
30
|
-
|
|
55
|
+
if (hasGrant) return true;
|
|
56
|
+
if (isSystemIdentity(asUser) || caller === undefined) return false;
|
|
57
|
+
return isSelfDelegation(caller, asUser);
|
|
31
58
|
}
|
|
32
59
|
|
|
33
60
|
export function systemIdentitySwitchDenied(callerLabel: string): AccessDeniedError {
|
|
@@ -39,31 +66,48 @@ export function systemIdentitySwitchDenied(callerLabel: string): AccessDeniedErr
|
|
|
39
66
|
});
|
|
40
67
|
}
|
|
41
68
|
|
|
42
|
-
|
|
43
|
-
|
|
69
|
+
export function identitySwitchDenied(callerLabel: string, asUser: SessionUser): AccessDeniedError {
|
|
70
|
+
if (isSystemIdentity(asUser)) return systemIdentitySwitchDenied(callerLabel);
|
|
71
|
+
return new AccessDeniedError({
|
|
72
|
+
message:
|
|
73
|
+
`${callerLabel} may only switch identity to its own caller (same user, tenant, claims and ` +
|
|
74
|
+
"a subset of its roles) — declare r.systemScope() or escapeHatch: { reason } on it",
|
|
75
|
+
details: { reason: FrameworkReasons.identitySwitchDenied },
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type GatedIdentitySwitchSource = {
|
|
80
|
+
readonly ungated: IdentitySwitch;
|
|
81
|
+
readonly caller: SessionUser | undefined;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Reverse-lookup so withHookEscapeHatchGrant can re-gate the SAME ungated pair (and caller) under a narrower grant.
|
|
85
|
+
const sourceByGated = new WeakMap<QueryAsFn | WriteAsFn, GatedIdentitySwitchSource>();
|
|
44
86
|
// Same idea, for ctx.queryAsMember — a single function rather than a pair.
|
|
45
87
|
const ungatedMemberReaderByGated = new WeakMap<MemberReader, MemberReader>();
|
|
46
88
|
|
|
47
89
|
export function createGatedIdentitySwitch(
|
|
48
90
|
callerLabel: string,
|
|
49
|
-
|
|
91
|
+
caller: SessionUser | undefined,
|
|
92
|
+
hasGrant: boolean,
|
|
50
93
|
ungated: IdentitySwitch,
|
|
51
94
|
): IdentitySwitch {
|
|
52
95
|
const queryAs: QueryAsFn = async (asUser, qn, payload) => {
|
|
53
|
-
if (!
|
|
54
|
-
throw
|
|
96
|
+
if (!isIdentitySwitchAllowed(caller, asUser, hasGrant)) {
|
|
97
|
+
throw identitySwitchDenied(callerLabel, asUser);
|
|
55
98
|
}
|
|
56
99
|
return ungated.queryAs(asUser, qn, payload);
|
|
57
100
|
};
|
|
58
101
|
const writeAs: WriteAsFn = async (asUser, qn, payload) => {
|
|
59
|
-
if (!
|
|
60
|
-
throw
|
|
102
|
+
if (!isIdentitySwitchAllowed(caller, asUser, hasGrant)) {
|
|
103
|
+
throw identitySwitchDenied(callerLabel, asUser);
|
|
61
104
|
}
|
|
62
105
|
return ungated.writeAs(asUser, qn, payload);
|
|
63
106
|
};
|
|
64
107
|
const gated: IdentitySwitch = { queryAs, writeAs };
|
|
65
|
-
|
|
66
|
-
|
|
108
|
+
const source: GatedIdentitySwitchSource = { ungated, caller };
|
|
109
|
+
sourceByGated.set(queryAs, source);
|
|
110
|
+
sourceByGated.set(writeAs, source);
|
|
67
111
|
return gated;
|
|
68
112
|
}
|
|
69
113
|
|
|
@@ -126,6 +170,16 @@ function fallbackUngatedIdentitySwitch(
|
|
|
126
170
|
};
|
|
127
171
|
}
|
|
128
172
|
|
|
173
|
+
// Only a dispatcher-registered gate names the caller; ctx.user is never trusted, and two
|
|
174
|
+
// gates for different callers (a hand-mixed ctx) yield none, so only a grant passes.
|
|
175
|
+
function resolveDispatcherCaller(
|
|
176
|
+
querySource: GatedIdentitySwitchSource | undefined,
|
|
177
|
+
writeSource: GatedIdentitySwitchSource | undefined,
|
|
178
|
+
): SessionUser | undefined {
|
|
179
|
+
if (querySource && writeSource && querySource.caller !== writeSource.caller) return undefined;
|
|
180
|
+
return (querySource ?? writeSource)?.caller;
|
|
181
|
+
}
|
|
182
|
+
|
|
129
183
|
// Resolved per function: a shared ungated pair would revive a member-resolution
|
|
130
184
|
// ctx's deny-stubbed writeAs through its still-registered queryAs.
|
|
131
185
|
function gatedIdentitySwitchFields(
|
|
@@ -135,10 +189,13 @@ function gatedIdentitySwitchFields(
|
|
|
135
189
|
ctxWriteAs: WriteAsFn | undefined,
|
|
136
190
|
): Partial<IdentitySwitch> {
|
|
137
191
|
if (!ctxQueryAs && !ctxWriteAs) return {};
|
|
138
|
-
const
|
|
139
|
-
const
|
|
192
|
+
const querySource = ctxQueryAs && sourceByGated.get(ctxQueryAs);
|
|
193
|
+
const writeSource = ctxWriteAs && sourceByGated.get(ctxWriteAs);
|
|
194
|
+
const ungatedQueryAs = ctxQueryAs && (querySource?.ungated.queryAs ?? ctxQueryAs);
|
|
195
|
+
const ungatedWriteAs = ctxWriteAs && (writeSource?.ungated.writeAs ?? ctxWriteAs);
|
|
140
196
|
const ungated = fallbackUngatedIdentitySwitch(callerLabel, ungatedQueryAs, ungatedWriteAs);
|
|
141
|
-
const
|
|
197
|
+
const caller = resolveDispatcherCaller(querySource, writeSource);
|
|
198
|
+
const gated = createGatedIdentitySwitch(callerLabel, caller, escapeHatch !== undefined, ungated);
|
|
142
199
|
return {
|
|
143
200
|
...(ctxQueryAs && { queryAs: gated.queryAs }),
|
|
144
201
|
...(ctxWriteAs && { writeAs: gated.writeAs }),
|