diff --git a/apps/sim/app/api/credentials/[id]/members/route.ts b/apps/sim/app/api/credentials/[id]/members/route.ts index 2c9daab48d..8b1768ac2b 100644 --- a/apps/sim/app/api/credentials/[id]/members/route.ts +++ b/apps/sim/app/api/credentials/[id]/members/route.ts @@ -58,7 +58,7 @@ export const GET = withRouteHandler(async (_request: NextRequest, context: Route .limit(1) if (!cred) { - return NextResponse.json({ members: [] }, { status: 200 }) + return NextResponse.json({ error: 'Not found' }, { status: 404 }) } const callerPerm = await getUserEntityPermissions( @@ -67,7 +67,7 @@ export const GET = withRouteHandler(async (_request: NextRequest, context: Route cred.workspaceId ) if (callerPerm === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + return NextResponse.json({ error: 'Not found' }, { status: 404 }) } const members = await db @@ -120,10 +120,36 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route .limit(1) if (existing) { - await db - .update(credentialMember) - .set({ role, status: 'active', updatedAt: now }) - .where(eq(credentialMember.id, existing.id)) + const ok = await db.transaction(async (tx) => { + const [current] = await tx + .select({ role: credentialMember.role, status: credentialMember.status }) + .from(credentialMember) + .where(eq(credentialMember.id, existing.id)) + .limit(1) + .for('update') + if (current?.role === 'admin' && current?.status === 'active' && role !== 'admin') { + const activeAdmins = await tx + .select({ id: credentialMember.id }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, credentialId), + eq(credentialMember.role, 'admin'), + eq(credentialMember.status, 'active') + ) + ) + .for('update') + if (activeAdmins.length <= 1) return false + } + await tx + .update(credentialMember) + .set({ role, status: 'active', updatedAt: now }) + .where(eq(credentialMember.id, existing.id)) + return true + }) + if (!ok) { + return NextResponse.json({ error: 'Cannot demote the last admin' }, { status: 400 }) + } return NextResponse.json({ success: true }) } @@ -195,6 +221,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou eq(credentialMember.status, 'active') ) ) + .for('update') if (activeAdmins.length <= 1) { return false diff --git a/apps/sim/app/api/mcp/copilot/route.ts b/apps/sim/app/api/mcp/copilot/route.ts index 021b04aac0..694009c94c 100644 --- a/apps/sim/app/api/mcp/copilot/route.ts +++ b/apps/sim/app/api/mcp/copilot/route.ts @@ -27,6 +27,7 @@ import { createRequestId } from '@/lib/copilot/request/http' import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { orchestrateSubagentStream } from '@/lib/copilot/request/subagent' import { ensureHandlersRegistered, executeTool } from '@/lib/copilot/tool-executor' +import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' import { DIRECT_TOOL_DEFS, SUBAGENT_TOOL_DEFS } from '@/lib/copilot/tools/mcp/definitions' import { env } from '@/lib/core/config/env' @@ -445,10 +446,36 @@ async function handleDirectToolCall( userId: string ): Promise { try { + const rawWorkflowId = (args.workflowId as string) || '' + let resolvedWorkspaceId: string | undefined + if (rawWorkflowId) { + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId: rawWorkflowId, + userId, + action: 'read', + }) + if (!authorization.allowed) { + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { success: false, error: 'Workflow not found or access denied' }, + null, + 2 + ), + }, + ], + isError: true, + } + } + resolvedWorkspaceId = authorization.workflow?.workspaceId || undefined + } const execContext = await prepareExecutionContext( userId, - (args.workflowId as string) || '', - (args.chatId as string) || undefined + rawWorkflowId, + (args.chatId as string) || undefined, + { workspaceId: resolvedWorkspaceId } ) const toolCall = { @@ -642,12 +669,46 @@ async function handleSubagentToolCall( context.plan = args.plan } + // Authorize user-supplied workflowId / workspaceId before forwarding downstream + const rawWorkflowId = args.workflowId as string | undefined + const rawWorkspaceId = args.workspaceId as string | undefined + let resolvedWorkflowId: string | undefined + let resolvedWorkspaceId: string | undefined + + if (rawWorkflowId) { + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId: rawWorkflowId, + userId, + action: 'read', + }) + if (!authorization.allowed) { + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { success: false, error: 'Workflow not found or access denied' }, + null, + 2 + ), + }, + ], + isError: true, + } + } + resolvedWorkflowId = rawWorkflowId + resolvedWorkspaceId = authorization.workflow?.workspaceId || undefined + } else if (rawWorkspaceId) { + await ensureWorkspaceAccess(rawWorkspaceId, userId, 'read') + resolvedWorkspaceId = rawWorkspaceId + } + const result = await orchestrateSubagentStream( toolDef.agentId, { message: requestText, - workflowId: args.workflowId, - workspaceId: args.workspaceId, + workflowId: resolvedWorkflowId, + workspaceId: resolvedWorkspaceId, context, model: DEFAULT_COPILOT_MODEL, headless: true, @@ -655,8 +716,8 @@ async function handleSubagentToolCall( }, { userId, - workflowId: args.workflowId as string | undefined, - workspaceId: args.workspaceId as string | undefined, + workflowId: resolvedWorkflowId, + workspaceId: resolvedWorkspaceId, simRequestId, abortSignal, } diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.ts b/apps/sim/lib/copilot/tools/handlers/oauth.ts index 7d89617783..1179f47c43 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.ts @@ -4,6 +4,7 @@ import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, lt } from 'drizzle-orm' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { getBaseUrl } from '@/lib/core/utils/urls' import { getAllOAuthServices } from '@/lib/oauth/utils' @@ -14,6 +15,10 @@ export async function executeOAuthGetAuthLink( const providerName = String(rawParams.providerName || rawParams.provider_name || '') const baseUrl = getBaseUrl() try { + if (!context.workspaceId || !context.userId) { + throw new Error('workspaceId and userId are required to generate an OAuth link') + } + await ensureWorkspaceAccess(context.workspaceId, context.userId, 'write') const result = await generateOAuthLink( context.userId, context.workspaceId,