@cosmicdrift/kumiko-framework 0.266.0 → 0.269.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/api/__tests__/api.test.ts +6 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +6 -0
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +6 -0
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +6 -0
- package/src/api/__tests__/jwt.test.ts +8 -0
- package/src/api/__tests__/stream-escape-hatch.integration.test.ts +203 -0
- package/src/api/jwt.ts +8 -0
- package/src/changes.json +28 -0
- package/src/db/__tests__/event-store-executor-global-tenancy.integration.test.ts +73 -0
- package/src/db/__tests__/tenant-db-global.integration.test.ts +63 -26
- package/src/db/__tests__/tenant-db-global.test.ts +95 -5
- package/src/db/entity-table-meta.ts +7 -0
- package/src/db/event-store-executor-context.ts +19 -0
- package/src/db/event-store-executor-write.ts +16 -0
- package/src/db/index.ts +3 -1
- package/src/db/table-builder.ts +27 -0
- package/src/db/tenant-db.ts +83 -10
- package/src/engine/__tests__/extends-registrar.test.ts +1 -0
- package/src/engine/__tests__/hook-identity-switch-grant.test.ts +4 -2
- package/src/engine/active-membership.ts +15 -1
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +37 -0
- package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +58 -0
- package/src/engine/boot-validator/access-declarations.ts +6 -1
- package/src/engine/boot-validator/global-tenancy.ts +17 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/feature-entity-handlers.ts +7 -1
- package/src/engine/feature-ui-extensions.ts +2 -2
- package/src/engine/index.ts +3 -0
- package/src/engine/registry-validate.ts +6 -6
- package/src/engine/steps/call-feature.ts +1 -1
- package/src/engine/types/index.ts +2 -0
- package/src/entrypoint/index.ts +2 -13
- package/src/errors/i18n/de.yaml +36 -0
- package/src/errors/i18n/en.yaml +34 -0
- package/src/errors/reasons.ts +13 -0
- package/src/jobs/job-runner.ts +14 -0
- package/src/pipeline/__tests__/dispatcher-utils.test.ts +17 -0
- package/src/pipeline/__tests__/escape-hatch.integration.test.ts +0 -3
- package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +201 -8
- package/src/pipeline/__tests__/system-identity-switch.test.ts +244 -33
- package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +103 -0
- package/src/pipeline/active-membership.ts +3 -1
- package/src/pipeline/dispatch-shared.ts +107 -20
- package/src/pipeline/dispatch-stream.ts +9 -5
- package/src/pipeline/dispatch-write.ts +7 -0
- package/src/pipeline/dispatcher-utils.ts +1 -0
- package/src/pipeline/dispatcher.ts +18 -0
- package/src/pipeline/index.ts +1 -1
- package/src/pipeline/member-reader.ts +108 -0
- package/src/pipeline/system-identity-switch.ts +142 -25
- package/src/stack/test-stack.ts +10 -1
- package/src/testing/handler-context.ts +3 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// fw#2859 — ctx.queryAs/ctx.writeAs to
|
|
2
|
-
//
|
|
1
|
+
// fw#2859/#2876 — ctx.queryAs/ctx.writeAs to anyone but the caller itself (or a role subset) needs
|
|
2
|
+
// r.systemScope() or { escapeHatch }. Real HTTP calls + setupTestStack — never createTestDispatcher.
|
|
3
3
|
|
|
4
4
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
5
5
|
import { z } from "zod";
|
|
@@ -33,6 +33,21 @@ const otherUserWithAdminRole: SessionUser = createTestUser({
|
|
|
33
33
|
roles: ["Admin"],
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
+
const multiRoleUser: SessionUser = createTestUser({
|
|
37
|
+
id: 52,
|
|
38
|
+
tenantId: user.tenantId,
|
|
39
|
+
roles: ["User", "Editor"],
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function foreignTenantTwin(caller: SessionUser): SessionUser {
|
|
43
|
+
return { ...caller, tenantId: TestUsers.otherTenant.tenantId };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function errorReason(details: unknown): string | undefined {
|
|
47
|
+
if (!details || typeof details !== "object" || !("reason" in details)) return undefined;
|
|
48
|
+
return typeof details.reason === "string" ? details.reason : undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
36
51
|
const hookThingEntity = createEntity({
|
|
37
52
|
table: "idswitch_hook_things",
|
|
38
53
|
fields: {
|
|
@@ -46,6 +61,7 @@ const hookThingExecutor = createEventStoreExecutor(hookThingTable, hookThingEnti
|
|
|
46
61
|
|
|
47
62
|
const hookNoEscapeHatchOutcomes: Array<{ readonly threw: boolean }> = [];
|
|
48
63
|
const hookWithEscapeHatchOutcomes: Array<{ readonly ok: boolean }> = [];
|
|
64
|
+
const hookForeignNoEscapeHatchOutcomes: Array<{ readonly threw: boolean }> = [];
|
|
49
65
|
|
|
50
66
|
const probeFeature = defineFeature("idswitch-probe", (r) => {
|
|
51
67
|
r.entity("hookThing", hookThingEntity);
|
|
@@ -132,9 +148,29 @@ const probeFeature = defineFeature("idswitch-probe", (r) => {
|
|
|
132
148
|
},
|
|
133
149
|
);
|
|
134
150
|
|
|
135
|
-
// --- Non-SYSTEM switch:
|
|
151
|
+
// --- Non-SYSTEM switch (fw#2876): only the caller itself (or a role subset) is free ---
|
|
152
|
+
r.queryHandler(
|
|
153
|
+
"query-as-self",
|
|
154
|
+
z.object({ roles: z.array(z.string()).optional() }),
|
|
155
|
+
async (query, ctx) =>
|
|
156
|
+
ctx.queryAs(
|
|
157
|
+
query.payload.roles ? { ...query.user, roles: query.payload.roles } : query.user,
|
|
158
|
+
"idswitch-probe:query:whoami",
|
|
159
|
+
{},
|
|
160
|
+
),
|
|
161
|
+
{ access: { roles: ["User"] } },
|
|
162
|
+
);
|
|
163
|
+
|
|
136
164
|
r.queryHandler(
|
|
137
165
|
"query-as-normal-user",
|
|
166
|
+
z.object({}),
|
|
167
|
+
async (_query, ctx) =>
|
|
168
|
+
ctx.queryAs(otherUserWithAdminRole, "idswitch-probe:query:admin-only", {}),
|
|
169
|
+
{ access: { roles: ["User"] } },
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
r.queryHandler(
|
|
173
|
+
"query-as-normal-user-with-hatch",
|
|
138
174
|
z.object({ granted: z.boolean() }),
|
|
139
175
|
async (query, ctx) =>
|
|
140
176
|
ctx.queryAs(
|
|
@@ -142,9 +178,48 @@ const probeFeature = defineFeature("idswitch-probe", (r) => {
|
|
|
142
178
|
"idswitch-probe:query:admin-only",
|
|
143
179
|
{},
|
|
144
180
|
),
|
|
181
|
+
{
|
|
182
|
+
access: { roles: ["User"] },
|
|
183
|
+
escapeHatch: { reason: "test: reads admin-only data as a named colleague" },
|
|
184
|
+
},
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
r.writeHandler(
|
|
188
|
+
"write-as-foreign-tenant",
|
|
189
|
+
z.object({}),
|
|
190
|
+
async (event, ctx) =>
|
|
191
|
+
ctx.writeAs(foreignTenantTwin(event.user), "idswitch-probe:write:whoami-write", {}),
|
|
145
192
|
{ access: { roles: ["User"] } },
|
|
146
193
|
);
|
|
147
194
|
|
|
195
|
+
r.writeHandler(
|
|
196
|
+
"write-as-foreign-tenant-with-hatch",
|
|
197
|
+
z.object({}),
|
|
198
|
+
async (event, ctx) =>
|
|
199
|
+
ctx.writeAs(foreignTenantTwin(event.user), "idswitch-probe:write:whoami-write", {}),
|
|
200
|
+
{
|
|
201
|
+
access: { roles: ["User"] },
|
|
202
|
+
escapeHatch: { reason: "test: acts for the same user inside a second tenant" },
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
// Reached through outer-with-hatch as a colleague — must not inherit the outer grant.
|
|
207
|
+
r.writeHandler(
|
|
208
|
+
"inner-foreign-no-hatch",
|
|
209
|
+
z.object({}),
|
|
210
|
+
async (event, ctx) =>
|
|
211
|
+
ctx.writeAs(foreignTenantTwin(event.user), "idswitch-probe:write:whoami-write", {}),
|
|
212
|
+
{ access: { roles: ["User"] } },
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
r.writeHandler(
|
|
216
|
+
"outer-as-colleague-with-hatch",
|
|
217
|
+
z.object({}),
|
|
218
|
+
async (_event, ctx) =>
|
|
219
|
+
ctx.writeAs(otherUserNoRole, "idswitch-probe:write:inner-foreign-no-hatch", {}),
|
|
220
|
+
{ access: { roles: ["User"] }, escapeHatch: { reason: "test: outer acts as a colleague" } },
|
|
221
|
+
);
|
|
222
|
+
|
|
148
223
|
// --- Nested: no transitive grant ---
|
|
149
224
|
r.writeHandler(
|
|
150
225
|
"inner-no-hatch",
|
|
@@ -229,6 +304,29 @@ const probeFeature = defineFeature("idswitch-probe", (r) => {
|
|
|
229
304
|
},
|
|
230
305
|
{ phase: HookPhases.inTransaction, escapeHatch: { reason: "test: hook needs SYSTEM" } },
|
|
231
306
|
);
|
|
307
|
+
|
|
308
|
+
r.writeHandler(
|
|
309
|
+
"hook-foreign-target-with-hatch",
|
|
310
|
+
z.object({ label: z.string() }),
|
|
311
|
+
async (event, ctx) => hookThingExecutor.create(event.payload, event.user, ctx.db),
|
|
312
|
+
{ access: { roles: ["User"] }, escapeHatch: { reason: "test: handler acts cross-tenant" } },
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
r.hook(
|
|
316
|
+
"postSave",
|
|
317
|
+
"hook-foreign-target-with-hatch",
|
|
318
|
+
async (_result, ctx) => {
|
|
319
|
+
const handlerCtx = ctx as unknown as HandlerContext;
|
|
320
|
+
try {
|
|
321
|
+
await handlerCtx.writeAs(foreignTenantTwin(user), "idswitch-probe:write:whoami-write", {});
|
|
322
|
+
hookForeignNoEscapeHatchOutcomes.push({ threw: false });
|
|
323
|
+
} catch (err) {
|
|
324
|
+
hookForeignNoEscapeHatchOutcomes.push({ threw: true });
|
|
325
|
+
throw err;
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
{ phase: HookPhases.inTransaction },
|
|
329
|
+
);
|
|
232
330
|
});
|
|
233
331
|
|
|
234
332
|
const systemScopeFeature = defineFeature("idswitch-probe-system", (r) => {
|
|
@@ -241,6 +339,18 @@ const systemScopeFeature = defineFeature("idswitch-probe-system", (r) => {
|
|
|
241
339
|
ctx.writeAs(createSystemUser(event.user.tenantId), "idswitch-probe:write:whoami-write", {}),
|
|
242
340
|
{ access: { roles: ["Admin"] } },
|
|
243
341
|
);
|
|
342
|
+
|
|
343
|
+
r.writeHandler(
|
|
344
|
+
"write-as-foreign-tenant",
|
|
345
|
+
z.object({}),
|
|
346
|
+
async (event, ctx) =>
|
|
347
|
+
ctx.writeAs(
|
|
348
|
+
{ ...foreignTenantTwin(event.user), roles: ["User"] },
|
|
349
|
+
"idswitch-probe:write:whoami-write",
|
|
350
|
+
{},
|
|
351
|
+
),
|
|
352
|
+
{ access: { roles: ["Admin"] } },
|
|
353
|
+
);
|
|
244
354
|
});
|
|
245
355
|
|
|
246
356
|
let stack: TestStack;
|
|
@@ -314,27 +424,110 @@ describe("ctx.resolveActiveMembership — gated like a SYSTEM queryAs/writeAs",
|
|
|
314
424
|
});
|
|
315
425
|
});
|
|
316
426
|
|
|
317
|
-
describe("ctx.queryAs
|
|
318
|
-
test("
|
|
427
|
+
describe("ctx.queryAs/writeAs to a non-SYSTEM identity (fw#2876)", () => {
|
|
428
|
+
test("switching to the caller itself is free", async () => {
|
|
429
|
+
const result = await stack.http.queryOk<{ roles: readonly string[]; tenantId: string }>(
|
|
430
|
+
"idswitch-probe:query:query-as-self",
|
|
431
|
+
{},
|
|
432
|
+
multiRoleUser,
|
|
433
|
+
);
|
|
434
|
+
expect(result.roles).toEqual(["User", "Editor"]);
|
|
435
|
+
expect(result.tenantId).toBe(multiRoleUser.tenantId);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("a subset of the caller's roles is free", async () => {
|
|
439
|
+
const result = await stack.http.queryOk<{ roles: readonly string[] }>(
|
|
440
|
+
"idswitch-probe:query:query-as-self",
|
|
441
|
+
{ roles: ["User"] },
|
|
442
|
+
multiRoleUser,
|
|
443
|
+
);
|
|
444
|
+
expect(result.roles).toEqual(["User"]);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("an additional role without escapeHatch is denied", async () => {
|
|
319
448
|
const err = await stack.http.queryErr(
|
|
320
|
-
"idswitch-probe:query:query-as-
|
|
449
|
+
"idswitch-probe:query:query-as-self",
|
|
450
|
+
{ roles: ["User", "Admin"] },
|
|
451
|
+
multiRoleUser,
|
|
452
|
+
);
|
|
453
|
+
expect(err.code).toBe("access_denied");
|
|
454
|
+
expect(errorReason(err.details)).toBe("identity_switch_denied");
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("a foreign user id without escapeHatch is denied, even when that user could read the target", async () => {
|
|
458
|
+
const err = await stack.http.queryErr("idswitch-probe:query:query-as-normal-user", {}, user);
|
|
459
|
+
expect(err.code).toBe("access_denied");
|
|
460
|
+
expect(errorReason(err.details)).toBe("identity_switch_denied");
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test("writeAs with a foreign tenantId without escapeHatch answers 403 access_denied", async () => {
|
|
464
|
+
const err = await stack.http.writeErr("idswitch-probe:write:write-as-foreign-tenant", {}, user);
|
|
465
|
+
expect(err.httpStatus).toBe(403);
|
|
466
|
+
expect(err.code).toBe("access_denied");
|
|
467
|
+
expect(errorReason(err.details)).toBe("identity_switch_denied");
|
|
468
|
+
expect(JSON.stringify(err)).not.toContain(TestUsers.otherTenant.tenantId);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("writeAs with a foreign tenantId WITH escapeHatch runs in that tenant", async () => {
|
|
472
|
+
const result = await stack.http.writeOk<{ tenantId: string }>(
|
|
473
|
+
"idswitch-probe:write:write-as-foreign-tenant-with-hatch",
|
|
474
|
+
{},
|
|
475
|
+
user,
|
|
476
|
+
);
|
|
477
|
+
expect(result.tenantId).toBe(TestUsers.otherTenant.tenantId);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("WITH escapeHatch the target's own access check still applies: denied when the target user lacks the role", async () => {
|
|
481
|
+
const err = await stack.http.queryErr(
|
|
482
|
+
"idswitch-probe:query:query-as-normal-user-with-hatch",
|
|
321
483
|
{ granted: false },
|
|
322
484
|
user,
|
|
323
485
|
);
|
|
324
486
|
expect(err.code).toBe("access_denied");
|
|
487
|
+
expect(errorReason(err.details)).not.toBe("identity_switch_denied");
|
|
325
488
|
});
|
|
326
489
|
|
|
327
|
-
test("
|
|
490
|
+
test("WITH escapeHatch: allowed when the target user has the role", async () => {
|
|
328
491
|
const result = await stack.http.queryOk<{ ok: boolean }>(
|
|
329
|
-
"idswitch-probe:query:query-as-normal-user",
|
|
492
|
+
"idswitch-probe:query:query-as-normal-user-with-hatch",
|
|
330
493
|
{ granted: true },
|
|
331
494
|
user,
|
|
332
495
|
);
|
|
333
496
|
expect(result.ok).toBe(true);
|
|
334
497
|
});
|
|
498
|
+
|
|
499
|
+
test("no transitive grant: outer-with-hatch -> colleague -> inner-no-hatch cross-tenant writeAs is denied", async () => {
|
|
500
|
+
const err = await stack.http.writeErr(
|
|
501
|
+
"idswitch-probe:write:outer-as-colleague-with-hatch",
|
|
502
|
+
{},
|
|
503
|
+
user,
|
|
504
|
+
);
|
|
505
|
+
expect(err.code).toBe("access_denied");
|
|
506
|
+
expect(errorReason(err.details)).toBe("identity_switch_denied");
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("hook without escapeHatch does not inherit the handler's grant for a cross-tenant writeAs", async () => {
|
|
510
|
+
hookForeignNoEscapeHatchOutcomes.length = 0;
|
|
511
|
+
const err = await stack.http.writeErr(
|
|
512
|
+
"idswitch-probe:write:hook-foreign-target-with-hatch",
|
|
513
|
+
{ label: "should-roll-back" },
|
|
514
|
+
user,
|
|
515
|
+
);
|
|
516
|
+
expect(err.code).toBe("access_denied");
|
|
517
|
+
expect(hookForeignNoEscapeHatchOutcomes).toEqual([{ threw: true }]);
|
|
518
|
+
});
|
|
335
519
|
});
|
|
336
520
|
|
|
337
521
|
describe("r.systemScope() feature — no escapeHatch needed", () => {
|
|
522
|
+
test("writeAs with a foreign tenantId succeeds from a systemScope handler", async () => {
|
|
523
|
+
const result = await stack.http.writeOk<{ tenantId: string }>(
|
|
524
|
+
"idswitch-probe-system:write:write-as-foreign-tenant",
|
|
525
|
+
{},
|
|
526
|
+
TestUsers.admin,
|
|
527
|
+
);
|
|
528
|
+
expect(result.tenantId).toBe(TestUsers.otherTenant.tenantId);
|
|
529
|
+
});
|
|
530
|
+
|
|
338
531
|
test("writeAs(SYSTEM) succeeds from a systemScope handler", async () => {
|
|
339
532
|
const result = await stack.http.writeOk<{ roles: readonly string[] }>(
|
|
340
533
|
"idswitch-probe-system:write:write-as-system",
|
|
@@ -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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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("
|
|
68
|
-
|
|
69
|
-
|
|
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("
|
|
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("
|
|
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 = {
|
|
232
|
+
const handlerCtx = {
|
|
233
|
+
...createGatedIdentitySwitch('handler "outer"', normalUser, true, ungated),
|
|
234
|
+
};
|
|
129
235
|
|
|
130
|
-
const hookCtx =
|
|
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 = {
|
|
246
|
+
const handlerCtx = {
|
|
247
|
+
...createGatedIdentitySwitch('handler "outer"', normalUser, false, ungated),
|
|
248
|
+
};
|
|
145
249
|
|
|
146
|
-
const hookCtx =
|
|
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
|
|
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 = {
|
|
158
|
-
|
|
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 = {
|
|
314
|
+
const handlerCtx = {
|
|
315
|
+
...createGatedIdentitySwitch('handler "outer"', normalUser, true, ungated),
|
|
316
|
+
extra: 1,
|
|
317
|
+
};
|
|
167
318
|
const spread = { ...handlerCtx };
|
|
168
319
|
|
|
169
|
-
const hookCtx =
|
|
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 = {
|
|
177
|
-
const result =
|
|
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 =
|
|
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 =
|
|
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
|
});
|