Skip to content

Commit d895e0e

Browse files
authored
fix(security): authorize MCP subagent IDs, oauth workspace, credential admin demotion (#4551)
* fix(security): authorize MCP subagent IDs, oauth workspace, credential admin demotion - handleSubagentToolCall and handleDirectToolCall now authorize user-supplied workflowId/workspaceId via authorizeWorkflowByWorkspacePermission / ensureWorkspaceAccess before forwarding downstream; resolvedWorkspaceId is derived from the authorized workflow record instead of trusted from the body - executeOAuthGetAuthLink verifies caller membership (write level) on the target workspaceId before generating the OAuth link or writing pendingCredentialDraft, closing the cross-workspace credential injection path - POST /api/credentials/[id]/members wraps role updates in a transaction that counts active admins and rejects demotion of the last admin (mirrors the existing DELETE guard in the same file) - GET /api/credentials/[id]/members returns uniform 404 for both missing and inaccessible credentials to remove the existence oracle * fix(security): address PR review — active-status guard, FOR UPDATE locks, workspaceId propagation - credentials/members POST: add `current.status === 'active'` check to the last-admin demotion guard so re-inviting a revoked admin as a non-admin role no longer incorrectly hits the "Cannot demote the last admin" path - credentials/members POST+DELETE: add `.for('update')` to the active-admin count SELECT inside both transactions to serialize concurrent demotions and eliminate the admin-count TOCTOU race under Postgres READ COMMITTED - credentials/members POST: also lock the member row itself with `.for('update')` so the role+status read and the subsequent UPDATE are atomic - mcp/copilot handleDirectToolCall: thread the DB-verified workspaceId from the authorization result into prepareExecutionContext instead of relying on user-supplied args - oauth handler: fix error message to mention both workspaceId and userId when either is missing from the execution context
1 parent 3adbde4 commit d895e0e

3 files changed

Lines changed: 105 additions & 12 deletions

File tree

apps/sim/app/api/credentials/[id]/members/route.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export const GET = withRouteHandler(async (_request: NextRequest, context: Route
5858
.limit(1)
5959

6060
if (!cred) {
61-
return NextResponse.json({ members: [] }, { status: 200 })
61+
return NextResponse.json({ error: 'Not found' }, { status: 404 })
6262
}
6363

6464
const callerPerm = await getUserEntityPermissions(
@@ -67,7 +67,7 @@ export const GET = withRouteHandler(async (_request: NextRequest, context: Route
6767
cred.workspaceId
6868
)
6969
if (callerPerm === null) {
70-
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
70+
return NextResponse.json({ error: 'Not found' }, { status: 404 })
7171
}
7272

7373
const members = await db
@@ -120,10 +120,36 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route
120120
.limit(1)
121121

122122
if (existing) {
123-
await db
124-
.update(credentialMember)
125-
.set({ role, status: 'active', updatedAt: now })
126-
.where(eq(credentialMember.id, existing.id))
123+
const ok = await db.transaction(async (tx) => {
124+
const [current] = await tx
125+
.select({ role: credentialMember.role, status: credentialMember.status })
126+
.from(credentialMember)
127+
.where(eq(credentialMember.id, existing.id))
128+
.limit(1)
129+
.for('update')
130+
if (current?.role === 'admin' && current?.status === 'active' && role !== 'admin') {
131+
const activeAdmins = await tx
132+
.select({ id: credentialMember.id })
133+
.from(credentialMember)
134+
.where(
135+
and(
136+
eq(credentialMember.credentialId, credentialId),
137+
eq(credentialMember.role, 'admin'),
138+
eq(credentialMember.status, 'active')
139+
)
140+
)
141+
.for('update')
142+
if (activeAdmins.length <= 1) return false
143+
}
144+
await tx
145+
.update(credentialMember)
146+
.set({ role, status: 'active', updatedAt: now })
147+
.where(eq(credentialMember.id, existing.id))
148+
return true
149+
})
150+
if (!ok) {
151+
return NextResponse.json({ error: 'Cannot demote the last admin' }, { status: 400 })
152+
}
127153
return NextResponse.json({ success: true })
128154
}
129155

@@ -195,6 +221,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou
195221
eq(credentialMember.status, 'active')
196222
)
197223
)
224+
.for('update')
198225

199226
if (activeAdmins.length <= 1) {
200227
return false

apps/sim/app/api/mcp/copilot/route.ts

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { createRequestId } from '@/lib/copilot/request/http'
2727
import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless'
2828
import { orchestrateSubagentStream } from '@/lib/copilot/request/subagent'
2929
import { ensureHandlersRegistered, executeTool } from '@/lib/copilot/tool-executor'
30+
import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access'
3031
import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context'
3132
import { DIRECT_TOOL_DEFS, SUBAGENT_TOOL_DEFS } from '@/lib/copilot/tools/mcp/definitions'
3233
import { env } from '@/lib/core/config/env'
@@ -445,10 +446,36 @@ async function handleDirectToolCall(
445446
userId: string
446447
): Promise<CallToolResult> {
447448
try {
449+
const rawWorkflowId = (args.workflowId as string) || ''
450+
let resolvedWorkspaceId: string | undefined
451+
if (rawWorkflowId) {
452+
const authorization = await authorizeWorkflowByWorkspacePermission({
453+
workflowId: rawWorkflowId,
454+
userId,
455+
action: 'read',
456+
})
457+
if (!authorization.allowed) {
458+
return {
459+
content: [
460+
{
461+
type: 'text',
462+
text: JSON.stringify(
463+
{ success: false, error: 'Workflow not found or access denied' },
464+
null,
465+
2
466+
),
467+
},
468+
],
469+
isError: true,
470+
}
471+
}
472+
resolvedWorkspaceId = authorization.workflow?.workspaceId || undefined
473+
}
448474
const execContext = await prepareExecutionContext(
449475
userId,
450-
(args.workflowId as string) || '',
451-
(args.chatId as string) || undefined
476+
rawWorkflowId,
477+
(args.chatId as string) || undefined,
478+
{ workspaceId: resolvedWorkspaceId }
452479
)
453480

454481
const toolCall = {
@@ -642,21 +669,55 @@ async function handleSubagentToolCall(
642669
context.plan = args.plan
643670
}
644671

672+
// Authorize user-supplied workflowId / workspaceId before forwarding downstream
673+
const rawWorkflowId = args.workflowId as string | undefined
674+
const rawWorkspaceId = args.workspaceId as string | undefined
675+
let resolvedWorkflowId: string | undefined
676+
let resolvedWorkspaceId: string | undefined
677+
678+
if (rawWorkflowId) {
679+
const authorization = await authorizeWorkflowByWorkspacePermission({
680+
workflowId: rawWorkflowId,
681+
userId,
682+
action: 'read',
683+
})
684+
if (!authorization.allowed) {
685+
return {
686+
content: [
687+
{
688+
type: 'text',
689+
text: JSON.stringify(
690+
{ success: false, error: 'Workflow not found or access denied' },
691+
null,
692+
2
693+
),
694+
},
695+
],
696+
isError: true,
697+
}
698+
}
699+
resolvedWorkflowId = rawWorkflowId
700+
resolvedWorkspaceId = authorization.workflow?.workspaceId || undefined
701+
} else if (rawWorkspaceId) {
702+
await ensureWorkspaceAccess(rawWorkspaceId, userId, 'read')
703+
resolvedWorkspaceId = rawWorkspaceId
704+
}
705+
645706
const result = await orchestrateSubagentStream(
646707
toolDef.agentId,
647708
{
648709
message: requestText,
649-
workflowId: args.workflowId,
650-
workspaceId: args.workspaceId,
710+
workflowId: resolvedWorkflowId,
711+
workspaceId: resolvedWorkspaceId,
651712
context,
652713
model: DEFAULT_COPILOT_MODEL,
653714
headless: true,
654715
source: 'mcp',
655716
},
656717
{
657718
userId,
658-
workflowId: args.workflowId as string | undefined,
659-
workspaceId: args.workspaceId as string | undefined,
719+
workflowId: resolvedWorkflowId,
720+
workspaceId: resolvedWorkspaceId,
660721
simRequestId,
661722
abortSignal,
662723
}

apps/sim/lib/copilot/tools/handlers/oauth.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { toError } from '@sim/utils/errors'
44
import { generateId } from '@sim/utils/id'
55
import { and, eq, lt } from 'drizzle-orm'
66
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
7+
import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access'
78
import { getBaseUrl } from '@/lib/core/utils/urls'
89
import { getAllOAuthServices } from '@/lib/oauth/utils'
910

@@ -14,6 +15,10 @@ export async function executeOAuthGetAuthLink(
1415
const providerName = String(rawParams.providerName || rawParams.provider_name || '')
1516
const baseUrl = getBaseUrl()
1617
try {
18+
if (!context.workspaceId || !context.userId) {
19+
throw new Error('workspaceId and userId are required to generate an OAuth link')
20+
}
21+
await ensureWorkspaceAccess(context.workspaceId, context.userId, 'write')
1722
const result = await generateOAuthLink(
1823
context.userId,
1924
context.workspaceId,

0 commit comments

Comments
 (0)