Skip to content

Commit 4377d6f

Browse files
bettercleverclaude
andcommitted
fix(studio-mcp): enforce API key permissions and bind session identity
Address Codex P1 review findings: 1. Permission gating: All workflow/run MCP tools now check auth.apiKeyPermissions before executing. Keys with restricted permissions (e.g. workflows.run=false) are properly denied. Component tools remain ungated (static metadata). 2. Session identity binding: MCP sessions store the creator's userId + organizationId. Subsequent requests verify the caller matches the session creator, returning 403 on mismatch to prevent cross-tenant session hijacking. 3. Extended AuthContext with optional apiKeyPermissions field, populated during API key authentication in AuthGuard. Tests: 26 pass (19 service + 7 controller), 61 assertions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: betterclever <paliwal.pranjal83@gmail.com>
1 parent 659fb0e commit 4377d6f

6 files changed

Lines changed: 433 additions & 8 deletions

File tree

backend/src/auth/auth.guard.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ export class AuthGuard implements CanActivate {
124124
roles: ['MEMBER'], // API keys have MEMBER role by default
125125
isAuthenticated: true,
126126
provider: 'api-key',
127+
apiKeyPermissions: apiKey.permissions,
127128
};
128129
}
129130
}

backend/src/auth/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
export type AuthRole = 'ADMIN' | 'MEMBER';
22

3+
export interface ApiKeyPermissions {
4+
workflows: { run: boolean; list: boolean; read: boolean };
5+
runs: { read: boolean; cancel: boolean };
6+
}
7+
38
export interface AuthContext {
49
userId: string | null;
510
organizationId: string | null;
611
roles: AuthRole[];
712
isAuthenticated: boolean;
813
provider: string;
14+
/** Present only when authenticated via API key. */
15+
apiKeyPermissions?: ApiKeyPermissions;
916
}
1017

1118
export const DEFAULT_ROLES: AuthRole[] = ['ADMIN', 'MEMBER'];
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import { describe, it, expect, beforeEach, jest } from 'bun:test';
2+
import { StudioMcpController } from '../studio-mcp.controller';
3+
import type { StudioMcpService } from '../studio-mcp.service';
4+
import type { AuthContext } from '../../auth/types';
5+
import type { Request, Response } from 'express';
6+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7+
8+
// Access private sessions map for assertions
9+
type SessionsMap = Map<
10+
string,
11+
{ transport: unknown; userId: string | null; organizationId: string | null }
12+
>;
13+
function getSessions(controller: StudioMcpController): SessionsMap {
14+
return (controller as unknown as { sessions: SessionsMap }).sessions;
15+
}
16+
17+
function createMockRes(): Response & { _status: number; _json: unknown } {
18+
const res = {
19+
_status: 200,
20+
_json: null,
21+
status(code: number) {
22+
res._status = code;
23+
return res;
24+
},
25+
json(body: unknown) {
26+
res._json = body;
27+
return res;
28+
},
29+
on: jest.fn(),
30+
} as unknown as Response & { _status: number; _json: unknown };
31+
return res;
32+
}
33+
34+
function createMockReq(
35+
overrides: Partial<Request> & { auth?: AuthContext } = {},
36+
): Request & { auth?: AuthContext } {
37+
return {
38+
method: 'POST',
39+
headers: {},
40+
header: jest.fn().mockReturnValue(undefined),
41+
body: {},
42+
...overrides,
43+
} as unknown as Request & { auth?: AuthContext };
44+
}
45+
46+
describe('StudioMcpController', () => {
47+
let controller: StudioMcpController;
48+
let mcpService: StudioMcpService;
49+
50+
const authUser1: AuthContext = {
51+
userId: 'user-1',
52+
organizationId: 'org-1',
53+
roles: ['MEMBER'],
54+
isAuthenticated: true,
55+
provider: 'api-key',
56+
apiKeyPermissions: {
57+
workflows: { run: true, list: true, read: true },
58+
runs: { read: true, cancel: true },
59+
},
60+
};
61+
62+
const authUser2: AuthContext = {
63+
userId: 'user-2',
64+
organizationId: 'org-2',
65+
roles: ['MEMBER'],
66+
isAuthenticated: true,
67+
provider: 'api-key',
68+
apiKeyPermissions: {
69+
workflows: { run: true, list: true, read: true },
70+
runs: { read: true, cancel: true },
71+
},
72+
};
73+
74+
beforeEach(() => {
75+
mcpService = {
76+
createServer: jest.fn().mockReturnValue(new McpServer({ name: 'test', version: '1.0.0' })),
77+
} as unknown as StudioMcpService;
78+
79+
controller = new StudioMcpController(mcpService);
80+
});
81+
82+
it('rejects unauthenticated requests with 401', async () => {
83+
const req = createMockReq({ auth: undefined });
84+
const res = createMockRes();
85+
86+
await controller.handleMcp(req, res);
87+
88+
expect(res._status).toBe(401);
89+
expect(res._json).toEqual({
90+
error: 'Authentication required. Use Bearer sk_live_* API key.',
91+
});
92+
});
93+
94+
it('rejects requests without session ID and without initialize body with 400', async () => {
95+
const req = createMockReq({
96+
auth: authUser1,
97+
method: 'POST',
98+
headers: {},
99+
body: { jsonrpc: '2.0', method: 'tools/list', id: 1 },
100+
});
101+
const res = createMockRes();
102+
103+
await controller.handleMcp(req, res);
104+
105+
expect(res._status).toBe(400);
106+
});
107+
108+
it('returns 404 for unknown session ID', async () => {
109+
const req = createMockReq({
110+
auth: authUser1,
111+
headers: { 'mcp-session-id': 'nonexistent-session' },
112+
});
113+
const res = createMockRes();
114+
115+
await controller.handleMcp(req, res);
116+
117+
expect(res._status).toBe(404);
118+
expect(res._json).toEqual({ error: 'Session not found or expired' });
119+
});
120+
121+
describe('session identity binding', () => {
122+
it('rejects session reuse from different user with 403', async () => {
123+
// Manually insert a session owned by user-1
124+
const sessions = getSessions(controller);
125+
const mockTransport = { handleRequest: jest.fn() };
126+
sessions.set('test-session-id', {
127+
transport: mockTransport,
128+
userId: authUser1.userId,
129+
organizationId: authUser1.organizationId,
130+
});
131+
132+
// User-2 tries to use user-1's session
133+
const req = createMockReq({
134+
auth: authUser2,
135+
method: 'POST',
136+
headers: { 'mcp-session-id': 'test-session-id' },
137+
body: { jsonrpc: '2.0', method: 'tools/list', id: 1 },
138+
});
139+
const res = createMockRes();
140+
141+
await controller.handleMcp(req, res);
142+
143+
expect(res._status).toBe(403);
144+
expect(res._json).toEqual({ error: 'Session belongs to a different principal' });
145+
expect(mockTransport.handleRequest).not.toHaveBeenCalled();
146+
});
147+
148+
it('rejects session reuse from different org with 403', async () => {
149+
const sessions = getSessions(controller);
150+
const mockTransport = { handleRequest: jest.fn() };
151+
sessions.set('test-session-id', {
152+
transport: mockTransport,
153+
userId: authUser1.userId,
154+
organizationId: authUser1.organizationId,
155+
});
156+
157+
// Same user ID but different org
158+
const crossOrgAuth: AuthContext = {
159+
...authUser1,
160+
organizationId: 'different-org',
161+
};
162+
const req = createMockReq({
163+
auth: crossOrgAuth,
164+
method: 'POST',
165+
headers: { 'mcp-session-id': 'test-session-id' },
166+
body: { jsonrpc: '2.0', method: 'tools/list', id: 1 },
167+
});
168+
const res = createMockRes();
169+
170+
await controller.handleMcp(req, res);
171+
172+
expect(res._status).toBe(403);
173+
expect(mockTransport.handleRequest).not.toHaveBeenCalled();
174+
});
175+
176+
it('allows session reuse from same principal', async () => {
177+
const sessions = getSessions(controller);
178+
const mockTransport = { handleRequest: jest.fn() };
179+
sessions.set('test-session-id', {
180+
transport: mockTransport,
181+
userId: authUser1.userId,
182+
organizationId: authUser1.organizationId,
183+
});
184+
185+
const req = createMockReq({
186+
auth: authUser1,
187+
method: 'POST',
188+
headers: { 'mcp-session-id': 'test-session-id' },
189+
body: { jsonrpc: '2.0', method: 'tools/list', id: 1 },
190+
});
191+
const res = createMockRes();
192+
193+
await controller.handleMcp(req, res);
194+
195+
// Should have forwarded to the transport, not returned an error
196+
expect(res._status).toBe(200); // not changed to 403 or 404
197+
expect(mockTransport.handleRequest).toHaveBeenCalled();
198+
});
199+
200+
it('cleans up session on DELETE from same principal', async () => {
201+
const sessions = getSessions(controller);
202+
const mockTransport = { handleRequest: jest.fn() };
203+
sessions.set('test-session-id', {
204+
transport: mockTransport,
205+
userId: authUser1.userId,
206+
organizationId: authUser1.organizationId,
207+
});
208+
209+
const req = createMockReq({
210+
auth: authUser1,
211+
method: 'DELETE',
212+
headers: { 'mcp-session-id': 'test-session-id' },
213+
});
214+
const res = createMockRes();
215+
216+
await controller.handleMcp(req, res);
217+
218+
expect(sessions.has('test-session-id')).toBe(false);
219+
expect(mockTransport.handleRequest).toHaveBeenCalled();
220+
});
221+
});
222+
});

backend/src/studio-mcp/__tests__/studio-mcp.service.spec.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,125 @@ describe('StudioMcpService Unit Tests', () => {
207207
expect(getResult).toBeDefined();
208208
});
209209

210+
describe('API key permission gating', () => {
211+
const restrictedAuth: AuthContext = {
212+
userId: 'api-key-id',
213+
organizationId: 'test-org-id',
214+
roles: ['MEMBER'],
215+
isAuthenticated: true,
216+
provider: 'api-key',
217+
apiKeyPermissions: {
218+
workflows: { run: false, list: true, read: true },
219+
runs: { read: true, cancel: false },
220+
},
221+
};
222+
223+
it('allows list_workflows when workflows.list is true', async () => {
224+
const server = service.createServer(restrictedAuth);
225+
const tools = getRegisteredTools(server);
226+
const result = (await tools['list_workflows'].handler({})) as { isError?: boolean };
227+
expect(result.isError).toBeUndefined();
228+
});
229+
230+
it('denies run_workflow when workflows.run is false', async () => {
231+
const server = service.createServer(restrictedAuth);
232+
const tools = getRegisteredTools(server);
233+
const result = (await tools['run_workflow'].handler({
234+
workflowId: '11111111-1111-4111-8111-111111111111',
235+
})) as { isError?: boolean; content: { text: string }[] };
236+
expect(result.isError).toBe(true);
237+
expect(result.content[0].text).toContain('workflows.run');
238+
});
239+
240+
it('denies cancel_run when runs.cancel is false', async () => {
241+
const server = service.createServer(restrictedAuth);
242+
const tools = getRegisteredTools(server);
243+
const result = (await tools['cancel_run'].handler({
244+
runId: 'test-run-id',
245+
})) as { isError?: boolean; content: { text: string }[] };
246+
expect(result.isError).toBe(true);
247+
expect(result.content[0].text).toContain('runs.cancel');
248+
});
249+
250+
it('allows get_run_status when runs.read is true', async () => {
251+
const server = service.createServer(restrictedAuth);
252+
const tools = getRegisteredTools(server);
253+
const result = (await tools['get_run_status'].handler({
254+
runId: 'test-run-id',
255+
})) as { isError?: boolean };
256+
expect(result.isError).toBeUndefined();
257+
});
258+
259+
it('allows all tools when no apiKeyPermissions (non-API-key auth)', async () => {
260+
const server = service.createServer(mockAuthContext); // no apiKeyPermissions
261+
const tools = getRegisteredTools(server);
262+
263+
// All workflow/run tools should work without permission errors
264+
const listResult = (await tools['list_workflows'].handler({})) as { isError?: boolean };
265+
expect(listResult.isError).toBeUndefined();
266+
267+
const runResult = (await tools['run_workflow'].handler({
268+
workflowId: '11111111-1111-4111-8111-111111111111',
269+
})) as { isError?: boolean };
270+
expect(runResult.isError).toBeUndefined();
271+
272+
const cancelResult = (await tools['cancel_run'].handler({
273+
runId: 'test-run-id',
274+
})) as { isError?: boolean };
275+
expect(cancelResult.isError).toBeUndefined();
276+
});
277+
278+
it('component tools are always allowed regardless of permissions', async () => {
279+
const noPermsAuth: AuthContext = {
280+
...restrictedAuth,
281+
apiKeyPermissions: {
282+
workflows: { run: false, list: false, read: false },
283+
runs: { read: false, cancel: false },
284+
},
285+
};
286+
const server = service.createServer(noPermsAuth);
287+
const tools = getRegisteredTools(server);
288+
289+
const listResult = (await tools['list_components'].handler({})) as { isError?: boolean };
290+
expect(listResult.isError).toBeUndefined();
291+
292+
const getResult = (await tools['get_component'].handler({
293+
componentId: 'core.workflow.entrypoint',
294+
})) as { isError?: boolean };
295+
expect(getResult.isError).toBeUndefined();
296+
});
297+
298+
it('denies all 7 gated tools when all permissions are false', async () => {
299+
const noPermsAuth: AuthContext = {
300+
...restrictedAuth,
301+
apiKeyPermissions: {
302+
workflows: { run: false, list: false, read: false },
303+
runs: { read: false, cancel: false },
304+
},
305+
};
306+
const server = service.createServer(noPermsAuth);
307+
const tools = getRegisteredTools(server);
308+
309+
const gatedTools = [
310+
'list_workflows',
311+
'get_workflow',
312+
'run_workflow',
313+
'list_runs',
314+
'get_run_status',
315+
'get_run_result',
316+
'cancel_run',
317+
];
318+
319+
for (const toolName of gatedTools) {
320+
const result = (await tools[toolName].handler({
321+
workflowId: '11111111-1111-4111-8111-111111111111',
322+
runId: 'test-run-id',
323+
})) as { isError?: boolean };
324+
expect(result.isError).toBe(true);
325+
}
326+
});
327+
});
328+
210329
it('each server instance has isolated auth context', async () => {
211330
const authContext1: AuthContext = {
212331
userId: 'user-1',

0 commit comments

Comments
 (0)