@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.268.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.268.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.268.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",
@@ -242,6 +242,7 @@ describe("extendsRegistrar", () => {
242
242
  // systemScope) — the extension hook must not inherit this.
243
243
  const handlerCtx = createGatedIdentitySwitch(
244
244
  'handler "fleet:write:vehicle:create"',
245
+ undefined,
245
246
  true,
246
247
  ungated,
247
248
  );
@@ -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: WriteHandlerDef["escapeHatch"] | QueryHandlerDef["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?: { access: AccessRule; rateLimit?: RateLimitOption },
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
- // Cross-tenant via opts.as (Sysadmin-role-checked at the dispatcher layer).
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";
@@ -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.
@@ -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.
@@ -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 SYSTEM needs r.systemScope() or { escapeHatch }; a
2
- // non-SYSTEM switch is ungated. Real HTTP calls + setupTestStack — never createTestDispatcher.
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: never gated by this feature, only the target's own access rule ---
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(nonSystemUser, ...) — never gated by this feature", () => {
318
- test("delegates to the target's own access check: denied when the target user lacks the role", async () => {
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-normal-user",
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("delegates to the target's own access check: allowed when the target user has the role", async () => {
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
- describe("isSystemIdentitySwitchAllowed", () => {
65
- test("SYSTEM target requires allowSystemIdentity=true", () => {
66
- expect(isSystemIdentitySwitchAllowed(systemUserById, false)).toBe(false);
67
- expect(isSystemIdentitySwitchAllowed(systemUserById, true)).toBe(true);
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("a non-SYSTEM target is always allowed", () => {
71
- expect(isSystemIdentitySwitchAllowed(normalUser, false)).toBe(true);
72
- expect(isSystemIdentitySwitchAllowed(normalUser, true)).toBe(true);
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("a non-system asUser always passes through, regardless of allowSystemIdentity", async () => {
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 = { ...createGatedIdentitySwitch('handler "outer"', true, ungated) };
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 = { ...createGatedIdentitySwitch('handler "outer"', false, ungated) };
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 non-system asUser is always allowed through the hook's own gate", async () => {
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 = { ...createGatedIdentitySwitch('handler "outer"', false, ungated) };
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 = { ...createGatedIdentitySwitch('handler "outer"', true, ungated), extra: 1 };
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('handler "outer"', true, ungated);
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 query escapeHatch.
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 = writeEscapeHatch ?? registry.getQueryHandler(type)?.escapeHatch;
280
- const allowSystemIdentity = isSystem || handlerEscapeHatch !== undefined;
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 switch identity; SYSTEM needs r.systemScope() or { escapeHatch } (system-identity-switch.ts).
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(`handler "${type}"`, allowSystemIdentity, {
384
- queryAs: (asUser: SessionUser, targetType: string, payload: unknown) =>
385
- executeQuery(ctx, targetType, payload, asUser, tx), // @wrapper-known semantic-alias
386
- writeAs: (asUser: SessionUser, targetType: string, payload: unknown) =>
387
- executeWrite(ctx, targetType, payload, asUser, tx, bridgeSink),
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
- allowSystemIdentity,
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 (!allowSystemIdentity) throw systemIdentitySwitchDenied(`handler "${type}"`);
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
- // go through queryAs(otherUser, ...); SYSTEM is always denied (no escapeHatch declaration site here).
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
- export function isSystemIdentitySwitchAllowed(
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
- allowSystemIdentity: boolean,
53
+ hasGrant: boolean,
29
54
  ): boolean {
30
- return !isSystemIdentity(asUser) || allowSystemIdentity;
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
- // Reverse-lookup so withHookEscapeHatchGrant can re-gate the SAME ungated pair under a narrower grant.
43
- const ungatedByGated = new WeakMap<QueryAsFn | WriteAsFn, IdentitySwitch>();
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
- allowSystemIdentity: boolean,
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 (!isSystemIdentitySwitchAllowed(asUser, allowSystemIdentity)) {
54
- throw systemIdentitySwitchDenied(callerLabel);
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 (!isSystemIdentitySwitchAllowed(asUser, allowSystemIdentity)) {
60
- throw systemIdentitySwitchDenied(callerLabel);
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
- ungatedByGated.set(queryAs, ungated);
66
- ungatedByGated.set(writeAs, ungated);
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 ungatedQueryAs = ctxQueryAs && (ungatedByGated.get(ctxQueryAs)?.queryAs ?? ctxQueryAs);
139
- const ungatedWriteAs = ctxWriteAs && (ungatedByGated.get(ctxWriteAs)?.writeAs ?? ctxWriteAs);
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 gated = createGatedIdentitySwitch(callerLabel, escapeHatch !== undefined, ungated);
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 }),