@cosmicdrift/kumiko-framework 0.266.0 → 0.269.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/package.json +3 -3
  2. package/src/api/__tests__/api.test.ts +6 -0
  3. package/src/api/__tests__/auth-routes-cookie.test.ts +6 -0
  4. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +6 -0
  5. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +6 -0
  6. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
  7. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +6 -0
  8. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +6 -0
  9. package/src/api/__tests__/jwt.test.ts +8 -0
  10. package/src/api/__tests__/stream-escape-hatch.integration.test.ts +203 -0
  11. package/src/api/jwt.ts +8 -0
  12. package/src/changes.json +28 -0
  13. package/src/db/__tests__/event-store-executor-global-tenancy.integration.test.ts +73 -0
  14. package/src/db/__tests__/tenant-db-global.integration.test.ts +63 -26
  15. package/src/db/__tests__/tenant-db-global.test.ts +95 -5
  16. package/src/db/entity-table-meta.ts +7 -0
  17. package/src/db/event-store-executor-context.ts +19 -0
  18. package/src/db/event-store-executor-write.ts +16 -0
  19. package/src/db/index.ts +3 -1
  20. package/src/db/table-builder.ts +27 -0
  21. package/src/db/tenant-db.ts +83 -10
  22. package/src/engine/__tests__/extends-registrar.test.ts +1 -0
  23. package/src/engine/__tests__/hook-identity-switch-grant.test.ts +4 -2
  24. package/src/engine/active-membership.ts +15 -1
  25. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +37 -0
  26. package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +58 -0
  27. package/src/engine/boot-validator/access-declarations.ts +6 -1
  28. package/src/engine/boot-validator/global-tenancy.ts +17 -0
  29. package/src/engine/boot-validator/index.ts +2 -0
  30. package/src/engine/feature-entity-handlers.ts +7 -1
  31. package/src/engine/feature-ui-extensions.ts +2 -2
  32. package/src/engine/index.ts +3 -0
  33. package/src/engine/registry-validate.ts +6 -6
  34. package/src/engine/steps/call-feature.ts +1 -1
  35. package/src/engine/types/index.ts +2 -0
  36. package/src/entrypoint/index.ts +2 -13
  37. package/src/errors/i18n/de.yaml +36 -0
  38. package/src/errors/i18n/en.yaml +34 -0
  39. package/src/errors/reasons.ts +13 -0
  40. package/src/jobs/job-runner.ts +14 -0
  41. package/src/pipeline/__tests__/dispatcher-utils.test.ts +17 -0
  42. package/src/pipeline/__tests__/escape-hatch.integration.test.ts +0 -3
  43. package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +201 -8
  44. package/src/pipeline/__tests__/system-identity-switch.test.ts +244 -33
  45. package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +103 -0
  46. package/src/pipeline/active-membership.ts +3 -1
  47. package/src/pipeline/dispatch-shared.ts +107 -20
  48. package/src/pipeline/dispatch-stream.ts +9 -5
  49. package/src/pipeline/dispatch-write.ts +7 -0
  50. package/src/pipeline/dispatcher-utils.ts +1 -0
  51. package/src/pipeline/dispatcher.ts +18 -0
  52. package/src/pipeline/index.ts +1 -1
  53. package/src/pipeline/member-reader.ts +108 -0
  54. package/src/pipeline/system-identity-switch.ts +142 -25
  55. package/src/stack/test-stack.ts +10 -1
  56. package/src/testing/handler-context.ts +3 -0
@@ -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",
@@ -1,4 +1,6 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
+ import type { DbRunner } from "../../db/connection";
3
+ import { createTenantDb, type TenantDb } from "../../db/tenant-db";
2
4
  import { createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID } from "../../engine";
3
5
  import type { SessionUser } from "../../engine/types";
4
6
  import type { TenantId } from "../../engine/types/identifiers";
@@ -6,9 +8,10 @@ import { AccessDeniedError, FrameworkReasons } from "../../errors";
6
8
  import {
7
9
  createGatedIdentitySwitch,
8
10
  type IdentitySwitch,
11
+ isIdentitySwitchAllowed,
9
12
  isSystemIdentity,
10
- isSystemIdentitySwitchAllowed,
11
- withHookIdentitySwitchGrant,
13
+ type WriteAsFn,
14
+ withHookEscapeHatchGrant,
12
15
  } from "../system-identity-switch";
13
16
 
14
17
  const TENANT = "00000000-0000-4000-8000-00000000ab01" as TenantId;
@@ -58,22 +61,89 @@ describe("isSystemIdentity", () => {
58
61
  });
59
62
  });
60
63
 
61
- describe("isSystemIdentitySwitchAllowed", () => {
62
- test("SYSTEM target requires allowSystemIdentity=true", () => {
63
- expect(isSystemIdentitySwitchAllowed(systemUserById, false)).toBe(false);
64
- 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);
78
+ });
79
+
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);
65
119
  });
66
120
 
67
- test("a non-SYSTEM target is always allowed", () => {
68
- expect(isSystemIdentitySwitchAllowed(normalUser, false)).toBe(true);
69
- expect(isSystemIdentitySwitchAllowed(normalUser, true)).toBe(true);
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);
70
140
  });
71
141
  });
72
142
 
73
143
  describe("createGatedIdentitySwitch", () => {
74
144
  test("queryAs to SYSTEM throws AccessDeniedError with the reason code, no asUser/tenantId/payload leak", async () => {
75
145
  const { ungated } = makeUngated();
76
- const gated = createGatedIdentitySwitch('handler "x"', false, ungated);
146
+ const gated = createGatedIdentitySwitch('handler "x"', normalUser, false, ungated);
77
147
 
78
148
  let caught: unknown;
79
149
  try {
@@ -92,7 +162,7 @@ describe("createGatedIdentitySwitch", () => {
92
162
 
93
163
  test("writeAs to SYSTEM throws the same way (never falls back to a WriteResult failure)", async () => {
94
164
  const { ungated } = makeUngated();
95
- const gated = createGatedIdentitySwitch('handler "x"', false, ungated);
165
+ const gated = createGatedIdentitySwitch('handler "x"', normalUser, false, ungated);
96
166
 
97
167
  await expect(gated.writeAs(systemUserById, "some:target", {})).rejects.toBeInstanceOf(
98
168
  AccessDeniedError,
@@ -101,7 +171,7 @@ describe("createGatedIdentitySwitch", () => {
101
171
 
102
172
  test("allowSystemIdentity=true lets a SYSTEM switch pass through to the ungated pair", async () => {
103
173
  const { ungated, queryAsMock, writeAsMock } = makeUngated();
104
- const gated = createGatedIdentitySwitch('handler "x"', true, ungated);
174
+ const gated = createGatedIdentitySwitch('handler "x"', normalUser, true, ungated);
105
175
 
106
176
  await gated.queryAs(systemUserById, "q", { a: 1 });
107
177
  await gated.writeAs(systemUserById, "w", { b: 2 });
@@ -110,28 +180,60 @@ describe("createGatedIdentitySwitch", () => {
110
180
  expect(writeAsMock).toHaveBeenCalledWith(systemUserById, "w", { b: 2 });
111
181
  });
112
182
 
113
- 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 () => {
114
184
  const { ungated, queryAsMock } = makeUngated();
115
- const deniedByDefault = createGatedIdentitySwitch('handler "x"', false, ungated);
185
+ const deniedByDefault = createGatedIdentitySwitch('handler "x"', normalUser, false, ungated);
116
186
 
117
187
  await deniedByDefault.queryAs(normalUser, "q", {});
118
188
 
119
189
  expect(queryAsMock).toHaveBeenCalledWith(normalUser, "q", {});
120
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
+ });
121
225
  });
122
226
 
123
- describe("withHookIdentitySwitchGrant", () => {
227
+ describe("withHookEscapeHatchGrant", () => {
124
228
  test("hook without escapeHatch does NOT inherit the handler's SYSTEM grant", async () => {
125
229
  const { ungated } = makeUngated();
126
230
  // Simulates dispatch-shared.ts's buildHandlerContext for a handler that
127
231
  // itself declared escapeHatch / is systemScope (allowSystemIdentity=true).
128
- const handlerCtx = { ...createGatedIdentitySwitch('handler "outer"', true, ungated) };
232
+ const handlerCtx = {
233
+ ...createGatedIdentitySwitch('handler "outer"', normalUser, true, ungated),
234
+ };
129
235
 
130
- const hookCtx = withHookIdentitySwitchGrant(
131
- handlerCtx,
132
- 'postSave hook of feature "f"',
133
- undefined,
134
- );
236
+ const hookCtx = withHookEscapeHatchGrant(handlerCtx, 'postSave hook of feature "f"', undefined);
135
237
 
136
238
  await expect(hookCtx.queryAs(systemUserById, "q", {})).rejects.toBeInstanceOf(
137
239
  AccessDeniedError,
@@ -141,9 +243,11 @@ describe("withHookIdentitySwitchGrant", () => {
141
243
  test("hook WITH escapeHatch is allowed even when the handler's own grant was false", async () => {
142
244
  const { ungated, queryAsMock } = makeUngated();
143
245
  // Handler itself has NO escapeHatch (allowSystemIdentity=false).
144
- const handlerCtx = { ...createGatedIdentitySwitch('handler "outer"', false, ungated) };
246
+ const handlerCtx = {
247
+ ...createGatedIdentitySwitch('handler "outer"', normalUser, false, ungated),
248
+ };
145
249
 
146
- const hookCtx = withHookIdentitySwitchGrant(handlerCtx, 'postSave hook of feature "f"', {
250
+ const hookCtx = withHookEscapeHatchGrant(handlerCtx, 'postSave hook of feature "f"', {
147
251
  reason: "test hook needs SYSTEM",
148
252
  });
149
253
 
@@ -152,44 +256,151 @@ describe("withHookIdentitySwitchGrant", () => {
152
256
  expect(queryAsMock).toHaveBeenCalledWith(systemUserById, "q", { a: 1 });
153
257
  });
154
258
 
155
- 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 () => {
156
260
  const { ungated, queryAsMock } = makeUngated();
157
- const handlerCtx = { ...createGatedIdentitySwitch('handler "outer"', false, ungated) };
158
- const hookCtx = withHookIdentitySwitchGrant(handlerCtx, "hook", undefined);
261
+ const handlerCtx = {
262
+ ...createGatedIdentitySwitch('handler "outer"', normalUser, false, ungated),
263
+ };
264
+ const hookCtx = withHookEscapeHatchGrant(handlerCtx, "hook", undefined);
159
265
 
160
266
  await hookCtx.queryAs(normalUser, "q", {});
161
267
  expect(queryAsMock).toHaveBeenCalledWith(normalUser, "q", {});
162
268
  });
163
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
+
164
312
  test("spreading the context ({...ctx}) still resolves back to the ungated pair", async () => {
165
313
  const { ungated, queryAsMock } = makeUngated();
166
- const handlerCtx = { ...createGatedIdentitySwitch('handler "outer"', true, ungated), extra: 1 };
314
+ const handlerCtx = {
315
+ ...createGatedIdentitySwitch('handler "outer"', normalUser, true, ungated),
316
+ extra: 1,
317
+ };
167
318
  const spread = { ...handlerCtx };
168
319
 
169
- const hookCtx = withHookIdentitySwitchGrant(spread, "hook", { reason: "needs system" });
320
+ const hookCtx = withHookEscapeHatchGrant(spread, "hook", { reason: "needs system" });
170
321
  await hookCtx.queryAs(systemUserById, "q", {});
171
322
 
172
323
  expect(queryAsMock).toHaveBeenCalledWith(systemUserById, "q", {});
173
324
  });
174
325
 
175
- test("a context without queryAs/writeAs is returned unchanged", () => {
176
- const plainContext = { db: {} };
177
- const result = withHookIdentitySwitchGrant(plainContext, "hook", { reason: "x" });
326
+ test("a context without queryAs/writeAs/db/dbOutsideTransaction is returned unchanged", () => {
327
+ const plainContext = { unrelated: 1 };
328
+ const result = withHookEscapeHatchGrant(plainContext, "hook", { reason: "x" });
178
329
  expect(result).toBe(plainContext);
179
330
  });
180
331
 
332
+ test("a deny-stubbed writeAs is not revived through a sibling queryAs's registered ungated pair", async () => {
333
+ // Like a member-resolution ctx: registered gated queryAs next to an unregistered deny-stub writeAs.
334
+ const { ungated, writeAsMock } = makeUngated();
335
+ const gatedIdentitySwitch = createGatedIdentitySwitch(
336
+ 'handler "outer"',
337
+ normalUser,
338
+ true,
339
+ ungated,
340
+ );
341
+ const denyStubWriteAs: WriteAsFn = async () => {
342
+ throw new AccessDeniedError({
343
+ message: "read-only",
344
+ details: { reason: "member_resolution_read_only" },
345
+ });
346
+ };
347
+ const handlerCtx = { queryAs: gatedIdentitySwitch.queryAs, writeAs: denyStubWriteAs };
348
+
349
+ const hookCtx = withHookEscapeHatchGrant(handlerCtx, "hook", {
350
+ reason: "test hook needs SYSTEM",
351
+ });
352
+
353
+ await expect(hookCtx.writeAs(systemUserById, "w", {})).rejects.toBeInstanceOf(
354
+ AccessDeniedError,
355
+ );
356
+ expect(writeAsMock).not.toHaveBeenCalled();
357
+ });
358
+
181
359
  test("an ungated context not built by the dispatcher bridge fails closed (unit-test stub)", async () => {
182
360
  const stubQueryAs = mock(async (_user: SessionUser, _qn: string, _payload: unknown) => "ok");
183
361
  const stubContext = { queryAs: stubQueryAs };
184
362
 
185
- const hookCtx = withHookIdentitySwitchGrant(stubContext, "hook", undefined);
363
+ const hookCtx = withHookEscapeHatchGrant(stubContext, "hook", undefined);
186
364
  await expect(hookCtx.queryAs(systemUserById, "q", {})).rejects.toBeInstanceOf(
187
365
  AccessDeniedError,
188
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
+ );
189
376
  expect(stubQueryAs).not.toHaveBeenCalled();
190
377
 
191
- const allowedHookCtx = withHookIdentitySwitchGrant(stubContext, "hook", { reason: "x" });
378
+ const allowedHookCtx = withHookEscapeHatchGrant(stubContext, "hook", { reason: "x" });
192
379
  await allowedHookCtx.queryAs(systemUserById, "q", {});
193
380
  expect(stubQueryAs).toHaveBeenCalledWith(systemUserById, "q", {});
194
381
  });
382
+
383
+ test("hook re-gates ctx.db.unsafeRaw() independently of the handler's own grant", () => {
384
+ const rawDb: DbRunner = {
385
+ unsafe: async () => [],
386
+ begin: async () => {
387
+ throw new Error("begin not used in this test");
388
+ },
389
+ } as DbRunner;
390
+ // Simulates buildHandlerContext granting the HANDLER its own unsafeRaw escapeHatch.
391
+ const handlerDb = createTenantDb(rawDb, TENANT, "tenant", undefined, undefined, undefined, {
392
+ unsafeRaw: { reason: "handler's own grant" },
393
+ });
394
+ const handlerCtx = { db: handlerDb };
395
+
396
+ const hookCtxWithoutEscapeHatch = withHookEscapeHatchGrant(handlerCtx, "hook", undefined) as {
397
+ db: TenantDb;
398
+ };
399
+ expect(() => hookCtxWithoutEscapeHatch.db.unsafeRaw("test reason")).toThrow(AccessDeniedError);
400
+
401
+ const hookCtxWithEscapeHatch = withHookEscapeHatchGrant(handlerCtx, "hook", {
402
+ reason: "hook's own grant",
403
+ }) as { db: TenantDb };
404
+ expect(hookCtxWithEscapeHatch.db.unsafeRaw("test reason")).toBe(rawDb);
405
+ });
195
406
  });