@cosmicdrift/kumiko-bundled-features 0.201.0 → 0.202.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 +7 -7
- package/src/auth-mfa/__tests__/session-auto-revoke.integration.test.ts +18 -6
- package/src/jobs/web/__tests__/job-runs-screen.test.tsx +5 -3
- package/src/sessions/__tests__/role-rederivation.integration.test.ts +137 -0
- package/src/sessions/__tests__/sessions.integration.test.ts +3 -1
- package/src/sessions/session-callbacks.ts +44 -13
- package/src/sessions/session-checker-fail-open.test.ts +74 -0
- package/src/step-dispatcher/webhook-runner.ts +15 -0
- package/src/tenant/handlers/remove-member.write.ts +13 -12
- package/src/tenant/handlers/update-member-roles.write.ts +13 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.202.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -126,12 +126,12 @@
|
|
|
126
126
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
127
127
|
},
|
|
128
128
|
"dependencies": {
|
|
129
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
130
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
131
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
132
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
129
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.202.0",
|
|
130
|
+
"@cosmicdrift/kumiko-framework": "0.202.0",
|
|
131
|
+
"@cosmicdrift/kumiko-headless": "0.202.0",
|
|
132
|
+
"@cosmicdrift/kumiko-renderer": "0.202.0",
|
|
133
|
+
"@cosmicdrift/kumiko-renderer-web": "0.202.0",
|
|
134
|
+
"@cosmicdrift/kumiko-types": "0.202.0",
|
|
135
135
|
"@mollie/api-client": "^4.5.0",
|
|
136
136
|
"@node-rs/argon2": "^2.0.2",
|
|
137
137
|
"@types/mailparser": "^3.4.6",
|
|
@@ -30,6 +30,18 @@ import { currentTotpCode } from "../totp";
|
|
|
30
30
|
// account — every OTHER live session must be signed out (stolen-session
|
|
31
31
|
// defense), but the session that performed the change must survive.
|
|
32
32
|
|
|
33
|
+
// sessionChecker's "live" outcome can now carry re-derived roles as
|
|
34
|
+
// `{status: "live", roles: [...]}` instead of the bare string — these tests
|
|
35
|
+
// only care about liveness, not the exact role set, so unwrap the status.
|
|
36
|
+
async function checkerStatus(
|
|
37
|
+
callbacks: ReturnType<typeof createSessionCallbacks>,
|
|
38
|
+
sid: string,
|
|
39
|
+
userId: string,
|
|
40
|
+
): Promise<string> {
|
|
41
|
+
const result = await callbacks.sessionChecker(sid, userId);
|
|
42
|
+
return typeof result === "string" ? result : result.status;
|
|
43
|
+
}
|
|
44
|
+
|
|
33
45
|
let stack: TestStack;
|
|
34
46
|
let sessionCallbacks: ReturnType<typeof createSessionCallbacks>;
|
|
35
47
|
|
|
@@ -94,8 +106,8 @@ describe("session auto-revoke on MFA state changes", () => {
|
|
|
94
106
|
user,
|
|
95
107
|
);
|
|
96
108
|
|
|
97
|
-
expect(await sessionCallbacks
|
|
98
|
-
expect(await sessionCallbacks
|
|
109
|
+
expect(await checkerStatus(sessionCallbacks, currentSid, userId)).toBe("live");
|
|
110
|
+
expect(await checkerStatus(sessionCallbacks, otherSid, userId)).toBe("revoked");
|
|
99
111
|
});
|
|
100
112
|
|
|
101
113
|
test("disable revokes every OTHER session but keeps the caller's", async () => {
|
|
@@ -130,8 +142,8 @@ describe("session auto-revoke on MFA state changes", () => {
|
|
|
130
142
|
caller,
|
|
131
143
|
);
|
|
132
144
|
|
|
133
|
-
expect(await sessionCallbacks
|
|
134
|
-
expect(await sessionCallbacks
|
|
145
|
+
expect(await checkerStatus(sessionCallbacks, currentSid, userId)).toBe("live");
|
|
146
|
+
expect(await checkerStatus(sessionCallbacks, otherSid, userId)).toBe("revoked");
|
|
135
147
|
});
|
|
136
148
|
|
|
137
149
|
test("a failed disable attempt does NOT revoke any session", async () => {
|
|
@@ -163,7 +175,7 @@ describe("session auto-revoke on MFA state changes", () => {
|
|
|
163
175
|
const err = await stack.http.writeErr(AuthMfaHandlers.disable, { code: "000000" }, caller);
|
|
164
176
|
expectErrorIncludes(err, "invalid_totp_code");
|
|
165
177
|
|
|
166
|
-
expect(await sessionCallbacks
|
|
167
|
-
expect(await sessionCallbacks
|
|
178
|
+
expect(await checkerStatus(sessionCallbacks, currentSid, userId)).toBe("live");
|
|
179
|
+
expect(await checkerStatus(sessionCallbacks, otherSid, userId)).toBe("live");
|
|
168
180
|
});
|
|
169
181
|
});
|
|
@@ -49,10 +49,12 @@ const stubNav: NavApi = {
|
|
|
49
49
|
route: undefined,
|
|
50
50
|
navigate: () => {},
|
|
51
51
|
replace: () => {},
|
|
52
|
-
hrefFor: (target) =>
|
|
53
|
-
target
|
|
52
|
+
hrefFor: (target) => {
|
|
53
|
+
if (!("screenId" in target)) return "";
|
|
54
|
+
return target.entityId !== undefined
|
|
54
55
|
? `/${target.screenId}/${target.entityId}`
|
|
55
|
-
: `/${target.screenId}
|
|
56
|
+
: `/${target.screenId}`;
|
|
57
|
+
},
|
|
56
58
|
searchParams: {},
|
|
57
59
|
setSearchParams: () => {},
|
|
58
60
|
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
4
|
+
import type { SessionCreator } from "@cosmicdrift/kumiko-framework/api";
|
|
5
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
6
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import {
|
|
8
|
+
setupTestStack,
|
|
9
|
+
type TestStack,
|
|
10
|
+
testTenantId,
|
|
11
|
+
unsafeCreateEntityTable,
|
|
12
|
+
unsafePushTables,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
14
|
+
import {
|
|
15
|
+
createLateBoundHolder,
|
|
16
|
+
createTestEnvelopeCipher,
|
|
17
|
+
updateRows,
|
|
18
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
19
|
+
import { AuthHandlers } from "../../auth-email-password/constants";
|
|
20
|
+
import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
|
|
21
|
+
import { createConfigFeature } from "../../config";
|
|
22
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
23
|
+
import { configValuesTable } from "../../config/table";
|
|
24
|
+
import { TenantQueries } from "../../tenant/constants";
|
|
25
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
26
|
+
import { tenantInvitationEntity } from "../../tenant/invitation-table";
|
|
27
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
28
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
29
|
+
import { createUserFeature } from "../../user/feature";
|
|
30
|
+
import { userEntity, userTable } from "../../user/schema/user";
|
|
31
|
+
import { createSessionsFeature } from "../feature";
|
|
32
|
+
import { userSessionEntity, userSessionTable } from "../schema/user-session";
|
|
33
|
+
import { createSessionCallbacks, type SessionCallbacks } from "../session-callbacks";
|
|
34
|
+
import { sessionCallbacksFromLateBound } from "../testing";
|
|
35
|
+
import { makeSessionHelpers } from "./test-helpers";
|
|
36
|
+
|
|
37
|
+
// Proves the core DoD of #2148: a role change written directly to
|
|
38
|
+
// tenantMembershipsTable takes effect on the very next request made with an
|
|
39
|
+
// ALREADY-ISSUED, still-live token — no re-login, no JWT expiry needed.
|
|
40
|
+
// Deliberately bypasses update-member-roles.write.ts (which force-revokes
|
|
41
|
+
// the session on a role change) so the effect under test isn't masked by
|
|
42
|
+
// that unrelated, already-correct revoke-on-change behavior.
|
|
43
|
+
|
|
44
|
+
let stack: TestStack;
|
|
45
|
+
let h: ReturnType<typeof makeSessionHelpers>;
|
|
46
|
+
let sessionCreator: SessionCreator;
|
|
47
|
+
const callbacks = createLateBoundHolder<SessionCallbacks>("session-callbacks");
|
|
48
|
+
|
|
49
|
+
const encryptionKey = randomBytes(32).toString("base64");
|
|
50
|
+
|
|
51
|
+
// Matches TestUsers.systemAdmin.tenantId — same rationale as
|
|
52
|
+
// membership-revoke.integration.test.ts.
|
|
53
|
+
const TENANT_A: TenantId = testTenantId(1);
|
|
54
|
+
|
|
55
|
+
beforeAll(async () => {
|
|
56
|
+
const encryption = createTestEnvelopeCipher(encryptionKey);
|
|
57
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
58
|
+
const bound = sessionCallbacksFromLateBound(callbacks);
|
|
59
|
+
|
|
60
|
+
stack = await setupTestStack({
|
|
61
|
+
features: [
|
|
62
|
+
createConfigFeature(),
|
|
63
|
+
createUserFeature(),
|
|
64
|
+
createTenantFeature(),
|
|
65
|
+
createAuthEmailPasswordFeature(),
|
|
66
|
+
authFoundationFeature,
|
|
67
|
+
createSessionsFeature(),
|
|
68
|
+
],
|
|
69
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
70
|
+
authConfig: {
|
|
71
|
+
...bound.asAuthConfig(),
|
|
72
|
+
membershipQuery: "tenant:query:memberships",
|
|
73
|
+
loginHandler: AuthHandlers.login,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
callbacks.set(createSessionCallbacks({ db: stack.db }));
|
|
77
|
+
const creator = bound.asAuthConfig().sessionCreator;
|
|
78
|
+
if (!creator) throw new Error("sessionCreator missing from bound auth config");
|
|
79
|
+
sessionCreator = creator;
|
|
80
|
+
h = makeSessionHelpers(stack, TENANT_A, sessionCreator);
|
|
81
|
+
|
|
82
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
83
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
84
|
+
await unsafeCreateEntityTable(stack.db, tenantInvitationEntity);
|
|
85
|
+
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
86
|
+
await unsafeCreateEntityTable(stack.db, userSessionEntity);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterAll(async () => {
|
|
90
|
+
await stack.cleanup();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
beforeEach(async () => {
|
|
94
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
|
|
95
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
96
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${userSessionTable.tableName}"`);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("sessionChecker re-derives roles from the DB on every request", () => {
|
|
100
|
+
test("a DB-side membership role change unlocks a role-gated action for the SAME live token", async () => {
|
|
101
|
+
// 1. Log in with an initial tenant-membership role set that does NOT
|
|
102
|
+
// satisfy the admin gate below.
|
|
103
|
+
const { userId } = await h.seedUser("role-upgrade@example.com", "first-password", {
|
|
104
|
+
roles: ["User"],
|
|
105
|
+
});
|
|
106
|
+
const { token } = await h.login("role-upgrade@example.com", "first-password");
|
|
107
|
+
|
|
108
|
+
// 2. tenant:query:invitations is gated on access.admin
|
|
109
|
+
// (["TenantAdmin", "Admin", "SystemAdmin"]) — the freshly-logged-in
|
|
110
|
+
// "User" role does not satisfy it.
|
|
111
|
+
const before = await h.authedPost("/api/query", token, {
|
|
112
|
+
type: TenantQueries.invitations,
|
|
113
|
+
payload: {},
|
|
114
|
+
});
|
|
115
|
+
expect(before.status).toBe(403);
|
|
116
|
+
|
|
117
|
+
// 3. Elevate the role by writing DIRECTLY to tenantMembershipsTable —
|
|
118
|
+
// explicitly NOT via update-member-roles.write.ts, whose
|
|
119
|
+
// force-revoke-on-change would kill the very token this test needs
|
|
120
|
+
// to keep using.
|
|
121
|
+
await updateRows(
|
|
122
|
+
stack.db,
|
|
123
|
+
tenantMembershipsTable,
|
|
124
|
+
{ roles: JSON.stringify(["Admin"]) },
|
|
125
|
+
{ userId, tenantId: TENANT_A },
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// 4. Same, still-unrevoked, still-unexpired token — the next request
|
|
129
|
+
// re-derives roles from the DB and now passes the gate.
|
|
130
|
+
const after = await h.authedPost("/api/query", token, {
|
|
131
|
+
type: TenantQueries.invitations,
|
|
132
|
+
payload: {},
|
|
133
|
+
});
|
|
134
|
+
expect(after.status).toBe(200);
|
|
135
|
+
expect(await after.json()).toEqual({ data: [] });
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -494,7 +494,9 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
494
494
|
|
|
495
495
|
// Direct invariant: Bob's sid + Alice's userId → missing (not live/revoked).
|
|
496
496
|
expect(await callbacks.get().sessionChecker(bobLogin.sid, alice.userId)).toBe("missing");
|
|
497
|
-
|
|
497
|
+
const bobResult = await callbacks.get().sessionChecker(bobLogin.sid, bob.userId);
|
|
498
|
+
const bobStatus = typeof bobResult === "string" ? bobResult : bobResult.status;
|
|
499
|
+
expect(bobStatus).toBe("live");
|
|
498
500
|
|
|
499
501
|
// HTTP path: mint Alice's identity onto Bob's sid (stolen-sid scenario).
|
|
500
502
|
const forged = await stack.jwt.sign({
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
|
|
2
|
+
AuthSessionCheckResult,
|
|
3
3
|
SessionChecker,
|
|
4
4
|
SessionCreator,
|
|
5
5
|
SessionMassRevoker,
|
|
@@ -11,12 +11,13 @@ export type { SessionMassRevoker } from "@cosmicdrift/kumiko-framework/api";
|
|
|
11
11
|
|
|
12
12
|
import { fetchOne, insertOne, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
13
13
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
14
|
-
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
15
|
-
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
15
|
+
import { buildSessionRoles, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
16
|
import { append } from "@cosmicdrift/kumiko-framework/event-store";
|
|
17
|
-
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
17
|
+
import { generateId, parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
18
18
|
import { Temporal } from "temporal-polyfill";
|
|
19
19
|
import { encryptForDirectWrite } from "../shared";
|
|
20
|
+
import { tenantMembershipsTable } from "../tenant";
|
|
20
21
|
import { USER_STATUS, type UserStatus, userTable } from "../user";
|
|
21
22
|
import { DEFAULT_SESSION_EXPIRY_MS } from "./constants";
|
|
22
23
|
import { userSessionEntity, userSessionTable } from "./schema/user-session";
|
|
@@ -113,9 +114,10 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
|
|
|
113
114
|
);
|
|
114
115
|
},
|
|
115
116
|
|
|
116
|
-
async sessionChecker(sid: string, expectedUserId: string): Promise<
|
|
117
|
+
async sessionChecker(sid: string, expectedUserId: string): Promise<AuthSessionCheckResult> {
|
|
117
118
|
const row = await fetchOne<{
|
|
118
119
|
userId: string;
|
|
120
|
+
tenantId: TenantId;
|
|
119
121
|
revokedAt: unknown;
|
|
120
122
|
expiresAt: { epochMilliseconds: number };
|
|
121
123
|
}>(db, userSessionTable, { id: sid });
|
|
@@ -137,14 +139,43 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
|
|
|
137
139
|
// revocation is primary; never turn a user-row miss into a global
|
|
138
140
|
// lockout. (+1 PK read on read_users per authenticated request.)
|
|
139
141
|
//
|
|
140
|
-
// Fail-open covers a THROW
|
|
141
|
-
// the hot path of every authenticated request,
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
142
|
+
// Fail-open covers a THROW *and* a null-miss, not just a throw: this
|
|
143
|
+
// read sits on the hot path of every authenticated request, and a
|
|
144
|
+
// missing row here means we have no DB-confirmed roles to derive from
|
|
145
|
+
// (e.g. a bootstrap/system actor with no persisted user row) — that is
|
|
146
|
+
// a different situation from tenantMembershipsTable below, where a
|
|
147
|
+
// missing row is a legitimate "no tenant roles" outcome. Both branches
|
|
148
|
+
// return the bare "live" string (no re-derived roles) — the middleware
|
|
149
|
+
// falls back to the JWT's frozen roles claim.
|
|
150
|
+
let user: { status: UserStatus; roles: string | null } | undefined;
|
|
151
|
+
try {
|
|
152
|
+
user = await fetchOne<{ status: UserStatus; roles: string | null }>(db, userTable, {
|
|
153
|
+
id: expectedUserId,
|
|
154
|
+
});
|
|
155
|
+
} catch {
|
|
156
|
+
return "live";
|
|
157
|
+
}
|
|
158
|
+
if (!user) return "live";
|
|
159
|
+
if (isPrincipalBlocked(user.status)) return "blocked";
|
|
160
|
+
|
|
161
|
+
// Same fail-open reasoning as the userTable lookup above — a transient
|
|
162
|
+
// DB error on the membership read must not lock the user out. A
|
|
163
|
+
// missing row (not a throw) is a valid outcome: the user genuinely has
|
|
164
|
+
// no tenant-scoped roles for row.tenantId, so membershipRoles is [].
|
|
165
|
+
let membershipRoles: readonly string[];
|
|
166
|
+
try {
|
|
167
|
+
const membership = await fetchOne<{ roles: string | null }>(db, tenantMembershipsTable, {
|
|
168
|
+
userId: expectedUserId,
|
|
169
|
+
tenantId: row.tenantId,
|
|
170
|
+
});
|
|
171
|
+
membershipRoles = membership ? parseRoles(membership.roles) : [];
|
|
172
|
+
} catch {
|
|
173
|
+
return "live";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const globalRoles = parseRoles(user.roles);
|
|
177
|
+
const roles = buildSessionRoles(globalRoles, membershipRoles);
|
|
178
|
+
return { status: "live", roles } as const;
|
|
148
179
|
},
|
|
149
180
|
|
|
150
181
|
async sessionMassRevoker(userId: string): Promise<number> {
|
|
@@ -2,6 +2,7 @@ import { describe, expect, spyOn, test } from "bun:test";
|
|
|
2
2
|
import * as bunDb from "@cosmicdrift/kumiko-framework/bun-db";
|
|
3
3
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
4
4
|
import { Temporal } from "temporal-polyfill";
|
|
5
|
+
import { tenantMembershipsTable } from "../tenant/membership-table";
|
|
5
6
|
import { USER_STATUS, userTable } from "../user/schema/user";
|
|
6
7
|
import { userSessionTable } from "./schema/user-session";
|
|
7
8
|
import { createSessionCallbacks } from "./session-callbacks";
|
|
@@ -74,4 +75,77 @@ describe("sessionChecker fail-open on user-lookup throw", () => {
|
|
|
74
75
|
spy.mockRestore();
|
|
75
76
|
}
|
|
76
77
|
});
|
|
78
|
+
|
|
79
|
+
test("membership lookup THROW → live (not 500)", async () => {
|
|
80
|
+
const db = {} as DbConnection;
|
|
81
|
+
const cbs = createSessionCallbacks({ db });
|
|
82
|
+
const sid = "00000000-0000-4000-8000-00000000sid3";
|
|
83
|
+
const userId = "00000000-0000-4000-8000-00000000usr3";
|
|
84
|
+
const tenantId = "00000000-0000-4000-8000-000000tenant";
|
|
85
|
+
const farFutureMs = Temporal.Now.instant().add({ hours: 1 }).epochMilliseconds;
|
|
86
|
+
|
|
87
|
+
const spy = spyOn(bunDb, "fetchOne").mockImplementation((async (_db, table) => {
|
|
88
|
+
if (table === userSessionTable) {
|
|
89
|
+
return {
|
|
90
|
+
userId,
|
|
91
|
+
tenantId,
|
|
92
|
+
revokedAt: null,
|
|
93
|
+
expiresAt: { epochMilliseconds: farFutureMs },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (table === userTable) {
|
|
97
|
+
return { status: USER_STATUS.Active, roles: null };
|
|
98
|
+
}
|
|
99
|
+
if (table === tenantMembershipsTable) {
|
|
100
|
+
throw new Error("simulated pool exhaustion");
|
|
101
|
+
}
|
|
102
|
+
throw new Error("unexpected table in sessionChecker spy");
|
|
103
|
+
}) as FetchOne);
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
expect(await cbs.sessionChecker(sid, userId)).toBe("live");
|
|
107
|
+
} finally {
|
|
108
|
+
spy.mockRestore();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("sessionChecker role re-derivation", () => {
|
|
114
|
+
test("live session composes global + tenant-membership roles fresh from the DB", async () => {
|
|
115
|
+
const db = {} as DbConnection;
|
|
116
|
+
const cbs = createSessionCallbacks({ db });
|
|
117
|
+
const sid = "00000000-0000-4000-8000-00000000sid4";
|
|
118
|
+
const userId = "00000000-0000-4000-8000-00000000usr4";
|
|
119
|
+
const tenantId = "00000000-0000-4000-8000-000000tenan2";
|
|
120
|
+
const farFutureMs = Temporal.Now.instant().add({ hours: 1 }).epochMilliseconds;
|
|
121
|
+
|
|
122
|
+
const spy = spyOn(bunDb, "fetchOne").mockImplementation((async (_db, table) => {
|
|
123
|
+
if (table === userSessionTable) {
|
|
124
|
+
return {
|
|
125
|
+
userId,
|
|
126
|
+
tenantId,
|
|
127
|
+
revokedAt: null,
|
|
128
|
+
expiresAt: { epochMilliseconds: farFutureMs },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (table === userTable) {
|
|
132
|
+
return { status: USER_STATUS.Active, roles: JSON.stringify(["Support"]) };
|
|
133
|
+
}
|
|
134
|
+
if (table === tenantMembershipsTable) {
|
|
135
|
+
return { roles: JSON.stringify(["User"]) };
|
|
136
|
+
}
|
|
137
|
+
throw new Error("unexpected table in sessionChecker spy");
|
|
138
|
+
}) as FetchOne);
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const result = await cbs.sessionChecker(sid, userId);
|
|
142
|
+
if (typeof result === "string") {
|
|
143
|
+
throw new Error(`expected object result, got bare string "${result}"`);
|
|
144
|
+
}
|
|
145
|
+
expect(result.status).toBe("live");
|
|
146
|
+
expect([...result.roles].sort()).toEqual(["Support", "User"]);
|
|
147
|
+
} finally {
|
|
148
|
+
spy.mockRestore();
|
|
149
|
+
}
|
|
150
|
+
});
|
|
77
151
|
});
|
|
@@ -39,6 +39,20 @@ export function setWebhookFetch(fn: typeof fetch): void {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export async function performWebhookDispatch(spec: WebhookSpec): Promise<WebhookDispatchResult> {
|
|
42
|
+
// SSRF guard at the primitive boundary: only http(s), and never follow
|
|
43
|
+
// redirects — a 3xx could point at an internal/metadata target and the
|
|
44
|
+
// spec carries secrets (auth) that would be forwarded there. A webhook
|
|
45
|
+
// destination that redirects now surfaces as a delivery error instead.
|
|
46
|
+
let url: URL;
|
|
47
|
+
try {
|
|
48
|
+
url = new URL(spec.url);
|
|
49
|
+
} catch {
|
|
50
|
+
return { ok: false, error: `invalid url "${spec.url}"` };
|
|
51
|
+
}
|
|
52
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
53
|
+
return { ok: false, error: `unsupported url scheme "${url.protocol}"` };
|
|
54
|
+
}
|
|
55
|
+
|
|
42
56
|
const headers: Record<string, string> = { "content-type": "application/json", ...spec.headers };
|
|
43
57
|
if (spec.auth) {
|
|
44
58
|
const secret = secretResolver(spec.auth.secretRef);
|
|
@@ -55,6 +69,7 @@ export async function performWebhookDispatch(spec: WebhookSpec): Promise<Webhook
|
|
|
55
69
|
const res = await fetchImpl(spec.url, {
|
|
56
70
|
method: spec.method,
|
|
57
71
|
headers,
|
|
72
|
+
redirect: "manual",
|
|
58
73
|
body: spec.body !== undefined ? JSON.stringify(spec.body) : undefined,
|
|
59
74
|
});
|
|
60
75
|
if (!res.ok) {
|
|
@@ -45,24 +45,25 @@ export const removeMemberWrite = defineWriteHandler({
|
|
|
45
45
|
);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// Revoke THIS tenant's sessions BEFORE the delete — a removed member must
|
|
49
|
+
// not keep a valid session for the window between the delete and a
|
|
50
|
+
// post-delete revoke. Best-effort cross-feature call: sessions may not be
|
|
51
|
+
// mounted (registry lookup, see above). If the delete then fails, the
|
|
52
|
+
// member is logged out but the membership is intact — safe direction.
|
|
53
|
+
const revoker = ctx.registry.getWriteHandler(REVOKE_ALL_SESSIONS_QN);
|
|
54
|
+
if (revoker) {
|
|
55
|
+
await ctx.writeAs(createSystemUser(event.payload.tenantId), REVOKE_ALL_SESSIONS_QN, {
|
|
56
|
+
userId: event.payload.userId,
|
|
57
|
+
tenantId: event.payload.tenantId,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
48
61
|
const result = await executor.delete(
|
|
49
62
|
{ id: (existing as DbRow)["id"] as string }, // @cast-boundary db-row
|
|
50
63
|
event.user,
|
|
51
64
|
db,
|
|
52
65
|
);
|
|
53
66
|
|
|
54
|
-
// Revoke only this tenant's sessions — a multi-tenant user stays logged
|
|
55
|
-
// in to tenants they're still a member of. Best-effort cross-feature
|
|
56
|
-
// call: sessions may not be mounted (registry lookup, see above).
|
|
57
|
-
if (result.isSuccess) {
|
|
58
|
-
const revoker = ctx.registry.getWriteHandler(REVOKE_ALL_SESSIONS_QN);
|
|
59
|
-
if (revoker) {
|
|
60
|
-
await ctx.writeAs(createSystemUser(event.payload.tenantId), REVOKE_ALL_SESSIONS_QN, {
|
|
61
|
-
userId: event.payload.userId,
|
|
62
|
-
tenantId: event.payload.tenantId,
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
67
|
return withResponseData(result, event.payload);
|
|
67
68
|
},
|
|
68
69
|
});
|
|
@@ -62,6 +62,19 @@ export const updateMemberRolesWrite = defineWriteHandler({
|
|
|
62
62
|
// silent overwrite. Per-membership parallelism is rare; if it happens,
|
|
63
63
|
// the client retries on the error.
|
|
64
64
|
const row = existing as DbRow; // @cast-boundary generic-record
|
|
65
|
+
// A role change can be security-relevant (e.g. demoting an Admin) — the
|
|
66
|
+
// user must re-authenticate with the new roles, everywhere. Revoke BEFORE
|
|
67
|
+
// the update closes the window where the demoted user keeps a valid
|
|
68
|
+
// session until the revoke write lands. Best-effort cross-feature call:
|
|
69
|
+
// sessions may not be mounted (registry lookup instead of a hard
|
|
70
|
+
// requires, see above).
|
|
71
|
+
const revoker = ctx.registry.getWriteHandler(REVOKE_ALL_SESSIONS_QN);
|
|
72
|
+
if (revoker) {
|
|
73
|
+
await ctx.writeAs(createSystemUser(event.user.tenantId), REVOKE_ALL_SESSIONS_QN, {
|
|
74
|
+
userId: event.payload.userId,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
65
78
|
const result = await executor.update(
|
|
66
79
|
{
|
|
67
80
|
id: row["id"] as string, // @cast-boundary db-row
|
|
@@ -72,18 +85,6 @@ export const updateMemberRolesWrite = defineWriteHandler({
|
|
|
72
85
|
db,
|
|
73
86
|
);
|
|
74
87
|
|
|
75
|
-
// A role change can be security-relevant (e.g. demoting an Admin) — the
|
|
76
|
-
// user must re-authenticate with the new roles, everywhere, not just in
|
|
77
|
-
// this tenant. Best-effort cross-feature call: sessions may not be
|
|
78
|
-
// mounted (registry lookup instead of a hard requires, see above).
|
|
79
|
-
if (result.isSuccess) {
|
|
80
|
-
const revoker = ctx.registry.getWriteHandler(REVOKE_ALL_SESSIONS_QN);
|
|
81
|
-
if (revoker) {
|
|
82
|
-
await ctx.writeAs(createSystemUser(event.user.tenantId), REVOKE_ALL_SESSIONS_QN, {
|
|
83
|
-
userId: event.payload.userId,
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
88
|
return withResponseData(result, event.payload);
|
|
88
89
|
},
|
|
89
90
|
});
|