diff --git a/apps/sim/app/api/auth/oauth/wealthbox/item/route.ts b/apps/sim/app/api/auth/oauth/wealthbox/item/route.ts index 924e5c68fcf..9e43c3bc8a0 100644 --- a/apps/sim/app/api/auth/oauth/wealthbox/item/route.ts +++ b/apps/sim/app/api/auth/oauth/wealthbox/item/route.ts @@ -1,15 +1,12 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { wealthboxOAuthItemContract } from '@/lib/api/contracts/selectors/wealthbox' import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -31,13 +28,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const session = await getSession() - - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthenticated request rejected`) - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) - } - const parsed = await parseRequest(wealthboxOAuthItemContract, request, {}) if (!parsed.success) return parsed.response const { credentialId, itemId, type } = parsed.data.query @@ -60,39 +50,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: itemIdValidation.error }, { status: 400 }) } - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - if (resolved.workspaceId) { - const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils') - const perm = await getUserEntityPermissions( - session.user.id, - 'workspace', - resolved.workspaceId - ) - if (perm === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - } - - const credentials = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - - if (!credentials.length) { - logger.warn(`[${requestId}] Credential not found`, { credentialId }) - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + const credAccess = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!credAccess.ok || !credAccess.credentialOwnerUserId) { + logger.warn(`[${requestId}] Credential access denied`, { error: credAccess.error }) + return NextResponse.json({ error: credAccess.error || 'Unauthorized' }, { status: 401 }) } - const accountRow = credentials[0] - const accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - accountRow.userId, + credentialId, + credAccess.credentialOwnerUserId, requestId ) diff --git a/apps/sim/app/api/providers/route.ts b/apps/sim/app/api/providers/route.ts index a399a8399cb..ec5cfcba94e 100644 --- a/apps/sim/app/api/providers/route.ts +++ b/apps/sim/app/api/providers/route.ts @@ -6,6 +6,7 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { executeProviderContract } from '@/lib/api/contracts/providers' import { parseRequest } from '@/lib/api/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -141,6 +142,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let finalApiKey: string | undefined = apiKey try { if (provider === 'vertex' && vertexCredential) { + const vertexCredAccess = await authorizeCredentialUse(request, { + credentialId: vertexCredential, + workflowId: workflowId || undefined, + requireWorkflowIdForInternal: false, + }) + if (!vertexCredAccess.ok) { + logger.warn(`[${requestId}] Vertex credential access denied`, { + error: vertexCredAccess.error, + credentialId: vertexCredential, + }) + return NextResponse.json( + { error: vertexCredAccess.error || 'Unauthorized' }, + { status: 401 } + ) + } finalApiKey = await resolveVertexCredential(requestId, vertexCredential) } } catch (error) { diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts index a9bab734a0f..ff70c6f1898 100644 --- a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts @@ -141,6 +141,7 @@ export const POST = withRouteHandler( try { const enqueueResult = await PauseResumeManager.enqueueOrStartResume({ executionId, + workflowId, contextId, resumeInput, userId, @@ -249,7 +250,7 @@ export const POST = withRouteHandler( contextId: enqueueResult.contextId, failureReason: 'Failed to queue async resume execution', }) - await PauseResumeManager.processQueuedResumes(executionId) + await PauseResumeManager.processQueuedResumes(executionId, workflowId) return NextResponse.json( { error: 'Failed to queue resume execution. Please try again.' }, { status: 503 } @@ -283,7 +284,7 @@ export const POST = withRouteHandler( executionId: enqueueResult.resumeExecutionId, message: 'Resume execution started.', }) - } catch (error: any) { + } catch (error) { logger.error('Resume request failed', { workflowId, executionId, @@ -291,7 +292,7 @@ export const POST = withRouteHandler( error, }) return NextResponse.json( - { error: error.message || 'Failed to queue resume request' }, + { error: toError(error).message || 'Failed to queue resume request' }, { status: 400 } ) } diff --git a/apps/sim/app/api/resume/poll/route.ts b/apps/sim/app/api/resume/poll/route.ts index 09f76ff7f15..12949569575 100644 --- a/apps/sim/app/api/resume/poll/route.ts +++ b/apps/sim/app/api/resume/poll/route.ts @@ -128,6 +128,7 @@ async function dispatchRow(row: DueRow, now: Date): Promise { try { const enqueueResult = await PauseResumeManager.enqueueOrStartResume({ executionId: row.executionId, + workflowId: row.workflowId, contextId: point.contextId, resumeInput: {}, userId, diff --git a/apps/sim/app/api/tools/gmail/label/route.ts b/apps/sim/app/api/tools/gmail/label/route.ts index d3a601ab65e..75cd890a522 100644 --- a/apps/sim/app/api/tools/gmail/label/route.ts +++ b/apps/sim/app/api/tools/gmail/label/route.ts @@ -1,21 +1,13 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { gmailLabelSelectorContract } from '@/lib/api/contracts/selectors/google' import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getScopesForService } from '@/lib/oauth/utils' -import { - getServiceAccountToken, - refreshAccessTokenIfNeeded, - resolveOAuthAccountId, - ServiceAccountTokenError, -} from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -25,13 +17,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const session = await getSession() - - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthenticated label request rejected`) - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) - } - const parsed = await parseRequest(gmailLabelSelectorContract, request, {}) if (!parsed.success) return parsed.response const { credentialId, labelId } = parsed.data.query @@ -43,56 +28,22 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: labelIdValidation.error }, { status: 400 }) } - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - if (resolved.workspaceId) { - const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils') - const perm = await getUserEntityPermissions( - session.user.id, - 'workspace', - resolved.workspaceId - ) - if (perm === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } + const credAccess = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!credAccess.ok || !credAccess.credentialOwnerUserId) { + logger.warn(`[${requestId}] Credential access denied`, { error: credAccess.error }) + return NextResponse.json({ error: credAccess.error || 'Unauthorized' }, { status: 401 }) } - let accessToken: string | null = null - - if (resolved.credentialType === 'service_account' && resolved.credentialId) { - accessToken = await getServiceAccountToken( - resolved.credentialId, - getScopesForService('gmail'), - impersonateEmail - ) - } else { - const credentials = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - - if (!credentials.length) { - logger.warn(`[${requestId}] Credential not found`) - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - const accountRow = credentials[0] - - logger.info( - `[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}` - ) - - accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - accountRow.userId, - requestId, - getScopesForService('gmail') - ) - } + const accessToken = await refreshAccessTokenIfNeeded( + credentialId, + credAccess.credentialOwnerUserId, + requestId, + getScopesForService('gmail'), + impersonateEmail + ) if (!accessToken) { return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) diff --git a/apps/sim/app/api/tools/onedrive/files/route.ts b/apps/sim/app/api/tools/onedrive/files/route.ts index fa26c915f53..4b3b7273608 100644 --- a/apps/sim/app/api/tools/onedrive/files/route.ts +++ b/apps/sim/app/api/tools/onedrive/files/route.ts @@ -1,15 +1,12 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { onedriveFilesQuerySchema } from '@/lib/api/contracts/selectors/microsoft' import { getValidationErrorMessage } from '@/lib/api/server' -import { getSession } from '@/lib/auth' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types' export const dynamic = 'force-dynamic' @@ -24,12 +21,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { logger.info(`[${requestId}] OneDrive files request received`) try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthenticated request rejected`) - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) - } - const { searchParams } = new URL(request.url) const validation = onedriveFilesQuerySchema.safeParse({ credentialId: searchParams.get('credentialId') ?? '', @@ -53,38 +44,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => { logger.info(`[${requestId}] Fetching credential`, { credentialId }) - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - if (resolved.workspaceId) { - const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils') - const perm = await getUserEntityPermissions( - session.user.id, - 'workspace', - resolved.workspaceId - ) - if (perm === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - } - - const credentials = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - if (!credentials.length) { - logger.warn(`[${requestId}] Credential not found`, { credentialId }) - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + const credAccess = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!credAccess.ok || !credAccess.credentialOwnerUserId) { + logger.warn(`[${requestId}] Credential access denied`, { error: credAccess.error }) + return NextResponse.json({ error: credAccess.error || 'Unauthorized' }, { status: 401 }) } - const accountRow = credentials[0] - const accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - accountRow.userId, + credentialId, + credAccess.credentialOwnerUserId, requestId ) if (!accessToken) { @@ -92,11 +63,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) } - // Use search endpoint if query provided, otherwise list root children - // Microsoft Graph API doesn't support $filter on file/folder properties for /children endpoint + // $filter is unsupported on the /children endpoint; use search when a query is present let url: string if (query) { - // Use search endpoint with query const searchParams_new = new URLSearchParams() searchParams_new.append( '$select', @@ -105,7 +74,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { searchParams_new.append('$top', '50') url = `https://graph.microsoft.com/v1.0/me/drive/root/search(q='${encodeURIComponent(query)}')?${searchParams_new.toString()}` } else { - // List all children (files and folders) from root const searchParams_new = new URLSearchParams() searchParams_new.append( '$select', @@ -138,27 +106,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const data = await response.json() logger.info(`[${requestId}] Received ${data.value?.length || 0} items from Microsoft Graph`) - // Log what we received to debug filtering - const itemBreakdown = (data.value || []).reduce( - (acc: any, item: MicrosoftGraphDriveItem) => { - if (item.file) acc.files++ - if (item.folder) acc.folders++ - return acc - }, - { files: 0, folders: 0 } - ) - logger.info(`[${requestId}] Item breakdown`, itemBreakdown) - const files = (data.value || []) - .filter((item: MicrosoftGraphDriveItem) => { - const isFile = !!item.file && !item.folder - if (!isFile) { - logger.debug( - `[${requestId}] Filtering out item: ${item.name} (isFolder: ${!!item.folder})` - ) - } - return isFile - }) + .filter((item: MicrosoftGraphDriveItem) => !!item.file && !item.folder) .map((file: MicrosoftGraphDriveItem) => ({ id: file.id, name: file.name, @@ -179,16 +128,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { : [], })) - logger.info( - `[${requestId}] Returning ${files.length} files (filtered from ${data.value?.length || 0} items)` - ) - - // Log the file IDs we're returning - if (files.length > 0) { - logger.info(`[${requestId}] File IDs being returned:`, { - fileIds: files.slice(0, 5).map((f: any) => ({ id: f.id, name: f.name })), - }) - } + logger.info(`[${requestId}] Returning ${files.length} files`, { + totalItems: data.value?.length || 0, + }) return NextResponse.json({ files }, { status: 200 }) } catch (error) { diff --git a/apps/sim/app/api/tools/onedrive/folder/route.ts b/apps/sim/app/api/tools/onedrive/folder/route.ts index 1b44d5ce99a..17ff0b02d8e 100644 --- a/apps/sim/app/api/tools/onedrive/folder/route.ts +++ b/apps/sim/app/api/tools/onedrive/folder/route.ts @@ -1,15 +1,12 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { onedriveFolderQuerySchema } from '@/lib/api/contracts/selectors/microsoft' import { getValidationErrorMessage } from '@/lib/api/server' -import { getSession } from '@/lib/auth' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -19,11 +16,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) - } - const { searchParams } = new URL(request.url) const validation = onedriveFolderQuerySchema.safeParse({ credentialId: searchParams.get('credentialId') ?? '', @@ -42,37 +34,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: fileIdValidation.error }, { status: 400 }) } - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - if (resolved.workspaceId) { - const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils') - const perm = await getUserEntityPermissions( - session.user.id, - 'workspace', - resolved.workspaceId - ) - if (perm === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - } - - const credentials = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - if (!credentials.length) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + const credAccess = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!credAccess.ok || !credAccess.credentialOwnerUserId) { + logger.warn(`[${requestId}] Credential access denied`, { error: credAccess.error }) + return NextResponse.json({ error: credAccess.error || 'Unauthorized' }, { status: 401 }) } - const accountRow = credentials[0] - const accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - accountRow.userId, + credentialId, + credAccess.credentialOwnerUserId, requestId ) if (!accessToken) { diff --git a/apps/sim/app/api/tools/outlook/folders/route.ts b/apps/sim/app/api/tools/outlook/folders/route.ts index 7e56fe86ae9..2cd0addcd85 100644 --- a/apps/sim/app/api/tools/outlook/folders/route.ts +++ b/apps/sim/app/api/tools/outlook/folders/route.ts @@ -1,16 +1,13 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { outlookFoldersSelectorContract } from '@/lib/api/contracts/selectors/microsoft' import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -25,7 +22,6 @@ interface OutlookFolder { export const GET = withRouteHandler(async (request: NextRequest) => { try { - const session = await getSession() const parsed = await parseRequest(outlookFoldersSelectorContract, request, {}) if (!parsed.success) return parsed.response const { credentialId } = parsed.data.query @@ -37,49 +33,29 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } try { - const sessionUserId = session?.user?.id || '' - - if (!sessionUserId) { - logger.error('No user ID found in session') - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - if (resolved.workspaceId) { - const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils') - const perm = await getUserEntityPermissions( - session!.user!.id, - 'workspace', - resolved.workspaceId + const credAccess = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!credAccess.ok || !credAccess.credentialOwnerUserId) { + logger.warn('Credential access denied', { error: credAccess.error }) + return NextResponse.json( + { error: credAccess.error || 'Authentication required' }, + { status: 401 } ) - if (perm === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } } - const creds = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - if (!creds.length) { - logger.warn('Credential not found', { credentialId }) - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - const credentialOwnerUserId = creds[0].userId - const accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - credentialOwnerUserId, + credentialId, + credAccess.credentialOwnerUserId, generateRequestId() ) if (!accessToken) { - logger.error('Failed to get access token', { credentialId, userId: credentialOwnerUserId }) + logger.error('Failed to get access token', { + credentialId, + userId: credAccess.credentialOwnerUserId, + }) return NextResponse.json( { error: 'Could not retrieve access token', diff --git a/apps/sim/app/api/tools/wealthbox/item/route.ts b/apps/sim/app/api/tools/wealthbox/item/route.ts index fd9de60baba..066cfb6fcbe 100644 --- a/apps/sim/app/api/tools/wealthbox/item/route.ts +++ b/apps/sim/app/api/tools/wealthbox/item/route.ts @@ -1,15 +1,12 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { wealthboxItemContract } from '@/lib/api/contracts/selectors/wealthbox' import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validatePathSegment } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -19,13 +16,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const session = await getSession() - - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthenticated request rejected`) - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) - } - const parsed = await parseRequest(wealthboxItemContract, request, {}) if (!parsed.success) return parsed.response const { credentialId, itemId, type } = parsed.data.query @@ -54,39 +44,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) } - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - if (resolved.workspaceId) { - const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils') - const perm = await getUserEntityPermissions( - session.user.id, - 'workspace', - resolved.workspaceId - ) - if (perm === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - } - - const credentials = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - - if (!credentials.length) { - logger.warn(`[${requestId}] Credential not found`, { credentialId }) - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + const credAccess = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!credAccess.ok || !credAccess.credentialOwnerUserId) { + logger.warn(`[${requestId}] Credential access denied`, { error: credAccess.error }) + return NextResponse.json({ error: credAccess.error || 'Unauthorized' }, { status: 401 }) } - const accountRow = credentials[0] - const accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - accountRow.userId, + credentialId, + credAccess.credentialOwnerUserId, requestId ) diff --git a/apps/sim/app/api/tools/wordpress/upload/route.ts b/apps/sim/app/api/tools/wordpress/upload/route.ts index b24274f7481..77a437c0957 100644 --- a/apps/sim/app/api/tools/wordpress/upload/route.ts +++ b/apps/sim/app/api/tools/wordpress/upload/route.ts @@ -11,6 +11,7 @@ import { processSingleFileToUserFile, } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { verifyFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -62,7 +63,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - // Process file - convert to UserFile format if needed const fileData = validatedData.file let userFile @@ -78,6 +78,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + if (userFile.key) { + if (!authResult.userId) { + logger.warn(`[${requestId}] File access check requires userId but none available`) + return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 }) + } + const hasAccess = await verifyFileAccess(userFile.key, authResult.userId) + if (!hasAccess) { + logger.warn(`[${requestId}] File access denied for user`, { + userId: authResult.userId, + key: userFile.key, + }) + return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 }) + } + } + logger.info(`[${requestId}] Downloading file from storage`, { fileName: userFile.name, key: userFile.key, @@ -99,7 +114,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - // Use provided filename or fall back to the original file name const filename = validatedData.filename || userFile.name const mimeType = userFile.type || getMimeTypeFromExtension(getFileExtension(filename)) @@ -110,14 +124,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { size: fileBuffer.length, }) - // Upload to WordPress using multipart form data const formData = new FormData() - // Convert Buffer to Uint8Array for Blob compatibility const uint8Array = new Uint8Array(fileBuffer) const blob = new Blob([uint8Array], { type: mimeType }) formData.append('file', blob, filename) - // Add optional metadata if (validatedData.title) { formData.append('title', validatedData.title) } diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts index 841b92c36fd..02fab158465 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq } from 'drizzle-orm' @@ -22,10 +23,13 @@ const logger = createLogger('CancelExecutionAPI') const PAUSED_CANCELLATION_DB_ATTEMPTS = 3 const PAUSED_CANCELLATION_DB_RETRY_MS = 200 -async function completePausedCancellationWithRetry(executionId: string): Promise { +async function completePausedCancellationWithRetry( + executionId: string, + workflowId: string +): Promise { for (let attempt = 1; attempt <= PAUSED_CANCELLATION_DB_ATTEMPTS; attempt++) { try { - const cancelled = await PauseResumeManager.completePausedCancellation(executionId) + const cancelled = await PauseResumeManager.completePausedCancellation(executionId, workflowId) if (cancelled) { logger.info('Paused execution cancelled in database', { executionId, attempt }) return true @@ -129,7 +133,10 @@ export const POST = withRouteHandler( let pausedCancellationStarted = false let pausedCancelled = false try { - pausedCancellationStarted = await PauseResumeManager.beginPausedCancellation(executionId) + pausedCancellationStarted = await PauseResumeManager.beginPausedCancellation( + executionId, + workflowId + ) } catch (error) { logger.warn('Failed to begin paused execution cancellation in database', { executionId, @@ -138,7 +145,7 @@ export const POST = withRouteHandler( } const pendingPausedCancellation = pausedCancellationStarted ? null - : await PauseResumeManager.getPausedCancellationStatus(executionId) + : await PauseResumeManager.getPausedCancellationStatus(executionId, workflowId) const isPausedCancellationPath = pausedCancellationStarted || pendingPausedCancellation !== null @@ -161,22 +168,26 @@ export const POST = withRouteHandler( } if (!isPausedCancellationPath && (cancellation.durablyRecorded || locallyAborted)) { - await PauseResumeManager.blockQueuedResumesForCancellation(executionId).catch((error) => { - logger.warn('Failed to block queued paused resumes after cancellation', { - executionId, - error, - }) - }) - } else if (!isPausedCancellationPath) { - await PauseResumeManager.clearPausedCancellationIntent(executionId).catch((error) => { - logger.warn( - 'Failed to clear paused cancellation intent after unsuccessful cancellation', - { + await PauseResumeManager.blockQueuedResumesForCancellation(executionId, workflowId).catch( + (error) => { + logger.warn('Failed to block queued paused resumes after cancellation', { executionId, error, - } - ) - }) + }) + } + ) + } else if (!isPausedCancellationPath) { + await PauseResumeManager.clearPausedCancellationIntent(executionId, workflowId).catch( + (error) => { + logger.warn( + 'Failed to clear paused cancellation intent after unsuccessful cancellation', + { + executionId, + error, + } + ) + } + ) } let pausedCancellationPublished = false @@ -188,7 +199,7 @@ export const POST = withRouteHandler( ) pausedCancellationPublishFailed = !pausedCancellationPublished if (pausedCancellationPublished) { - pausedCancelled = await completePausedCancellationWithRetry(executionId) + pausedCancelled = await completePausedCancellationWithRetry(executionId, workflowId) } } else { if (pendingPausedCancellation === 'cancelled') { @@ -205,7 +216,7 @@ export const POST = withRouteHandler( ) pausedCancellationPublishFailed = !pausedCancellationPublished if (pausedCancellationPublished) { - pausedCancelled = await completePausedCancellationWithRetry(executionId) + pausedCancelled = await completePausedCancellationWithRetry(executionId, workflowId) } } } @@ -214,12 +225,14 @@ export const POST = withRouteHandler( pausedCancellationPublishFailed && (pausedCancellationStarted || pendingPausedCancellation === 'cancelling') ) { - await PauseResumeManager.clearPausedCancellationIntent(executionId).catch((error) => { - logger.warn('Failed to clear paused cancellation intent after publish failure', { - executionId, - error, - }) - }) + await PauseResumeManager.clearPausedCancellationIntent(executionId, workflowId).catch( + (error) => { + logger.warn('Failed to clear paused cancellation intent after publish failure', { + executionId, + error, + }) + } + ) } if ((cancellation.durablyRecorded || locallyAborted) && !pausedCancelled) { @@ -281,10 +294,14 @@ export const POST = withRouteHandler( pausedCancelled, reason, }) - } catch (error: any) { - logger.error('Failed to cancel execution', { workflowId, executionId, error: error.message }) + } catch (error) { + logger.error('Failed to cancel execution', { + workflowId, + executionId, + error: toError(error).message, + }) return NextResponse.json( - { error: error.message || 'Failed to cancel execution' }, + { error: toError(error).message || 'Failed to cancel execution' }, { status: 500 } ) } diff --git a/apps/sim/lib/auth/credential-access.ts b/apps/sim/lib/auth/credential-access.ts index 928e39671ff..05e017c87a3 100644 --- a/apps/sim/lib/auth/credential-access.ts +++ b/apps/sim/lib/auth/credential-access.ts @@ -78,44 +78,40 @@ export async function authorizeCredentialUse( return { ok: false, error: 'Credential is not accessible from this workflow workspace' } } - if (actingUserId) { - const requesterPerm = await getUserEntityPermissions( - actingUserId, - 'workspace', - platformCredential.workspaceId - ) + const requesterPerm = await getUserEntityPermissions( + actingUserId, + 'workspace', + platformCredential.workspaceId + ) - const [membership] = await db - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, platformCredential.id), - eq(credentialMember.userId, actingUserId), - eq(credentialMember.status, 'active') - ) + const [membership] = await db + .select({ id: credentialMember.id }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, platformCredential.id), + eq(credentialMember.userId, actingUserId), + eq(credentialMember.status, 'active') ) - .limit(1) - - if (!membership) { - return { - ok: false, - error: - 'You do not have access to this credential. Ask the credential admin to add you as a member.', - } - } - if (requesterPerm === null) { - return { ok: false, error: 'You do not have access to this workspace.' } + ) + .limit(1) + + if (!membership) { + return { + ok: false, + error: + 'You do not have access to this credential. Ask the credential admin to add you as a member.', } - } else if (!workflowContext) { - return { ok: false, error: 'workflowId is required' } + } + if (requesterPerm === null) { + return { ok: false, error: 'You do not have access to this workspace.' } } return { ok: true, authType: auth.authType as CredentialAccessResult['authType'], requesterUserId: auth.userId, - credentialOwnerUserId: actingUserId || auth.userId, + credentialOwnerUserId: actingUserId, workspaceId: platformCredential.workspaceId, resolvedCredentialId: platformCredential.id, } @@ -139,36 +135,34 @@ export async function authorizeCredentialUse( return { ok: false, error: 'Credential account not found' } } - if (actingUserId) { - const requesterPerm = await getUserEntityPermissions( - actingUserId, - 'workspace', - platformCredential.workspaceId - ) + const requesterPerm = await getUserEntityPermissions( + actingUserId, + 'workspace', + platformCredential.workspaceId + ) - const [membership] = await db - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, platformCredential.id), - eq(credentialMember.userId, actingUserId), - eq(credentialMember.status, 'active') - ) + const [membership] = await db + .select({ id: credentialMember.id }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, platformCredential.id), + eq(credentialMember.userId, actingUserId), + eq(credentialMember.status, 'active') ) - .limit(1) + ) + .limit(1) - if (!membership) { - return { - ok: false, - error: `You do not have access to this credential. Ask the credential admin to add you as a member.`, - } + if (!membership) { + return { + ok: false, + error: `You do not have access to this credential. Ask the credential admin to add you as a member.`, } - if (requesterPerm === null) { - return { - ok: false, - error: 'You do not have access to this workspace.', - } + } + if (requesterPerm === null) { + return { + ok: false, + error: 'You do not have access to this workspace.', } } @@ -222,25 +216,23 @@ export async function authorizeCredentialUse( return { ok: false, error: 'Credential account not found' } } - if (actingUserId) { - const [membership] = await db - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, workspaceCredential.id), - eq(credentialMember.userId, actingUserId), - eq(credentialMember.status, 'active') - ) + const [membership] = await db + .select({ id: credentialMember.id }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, workspaceCredential.id), + eq(credentialMember.userId, actingUserId), + eq(credentialMember.status, 'active') ) - .limit(1) + ) + .limit(1) - if (!membership) { - return { - ok: false, - error: - 'You do not have access to this credential. Ask the credential admin to add you as a member.', - } + if (!membership) { + return { + ok: false, + error: + 'You do not have access to this credential. Ask the credential admin to add you as a member.', } } diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index 330fe93e14c..3392a2e7bb8 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -105,6 +105,7 @@ interface PersistPauseResultArgs { interface EnqueueResumeArgs { executionId: string + workflowId: string contextId: string resumeInput: unknown userId: string @@ -262,17 +263,24 @@ export class PauseResumeManager { .where(eq(pausedExecutions.id, existing.id)) }) - await PauseResumeManager.processQueuedResumes(executionId) + await PauseResumeManager.processQueuedResumes(executionId, workflowId) } static async enqueueOrStartResume(args: EnqueueResumeArgs): Promise { - const { executionId, contextId, resumeInput, userId, allowedPauseKinds } = args + const { executionId, workflowId, contextId, resumeInput, userId, allowedPauseKinds } = args return await db.transaction(async (tx) => { const pausedExecution = await tx .select() .from(pausedExecutions) - .where(eq(pausedExecutions.executionId, executionId)) + .where( + workflowId + ? and( + eq(pausedExecutions.executionId, executionId), + eq(pausedExecutions.workflowId, workflowId) + ) + : eq(pausedExecutions.executionId, executionId) + ) .for('update') .limit(1) .then((rows) => rows[0]) @@ -468,10 +476,14 @@ export class PauseResumeManager { failureReason: 'Resume execution cancelled', }) const pausedCancellationStatus = await PauseResumeManager.getPausedCancellationStatus( - pausedExecution.executionId + pausedExecution.executionId, + pausedExecution.workflowId ) if (pausedCancellationStatus === 'cancelling') { - await PauseResumeManager.completePausedCancellation(pausedExecution.executionId) + await PauseResumeManager.completePausedCancellation( + pausedExecution.executionId, + pausedExecution.workflowId + ) } } else { await PauseResumeManager.updateSnapshotAfterResume({ @@ -495,7 +507,10 @@ export class PauseResumeManager { }) } - await PauseResumeManager.processQueuedResumes(pausedExecution.executionId) + await PauseResumeManager.processQueuedResumes( + pausedExecution.executionId, + pausedExecution.workflowId + ) return result } catch (error) { @@ -523,7 +538,10 @@ export class PauseResumeManager { contextId, error, }) - await PauseResumeManager.processQueuedResumes(pausedExecution.executionId) + await PauseResumeManager.processQueuedResumes( + pausedExecution.executionId, + pausedExecution.workflowId + ) throw error } } @@ -1467,10 +1485,7 @@ export class PauseResumeManager { snapshotData.state = executionState } - // Update the DAG incoming edges in the snapshot - // Remove the edge from the resumed pause block if (snapshotData.state) { - // Track completed pause contexts so future resumes remove their edges const completedPauseContexts = new Set( (snapshotData.state.completedPauseContexts ?? []).map((id: string) => PauseResumeManager.normalizePauseBlockId(id) @@ -1482,7 +1497,6 @@ export class PauseResumeManager { const dagIncomingEdges = snapshotData.state.dagIncomingEdges if (dagIncomingEdges) { - // Find all edges from the resumed pause block and remove them from targets const workflowData = snapshotData.workflow const connections = workflowData.connections || [] @@ -1490,7 +1504,6 @@ export class PauseResumeManager { if (conn.source === pauseBlockId) { const targetId = conn.target if (dagIncomingEdges[targetId]) { - // Remove this source from the target's incoming edges dagIncomingEdges[targetId] = dagIncomingEdges[targetId].filter( (sourceId: string) => sourceId !== pauseBlockId ) @@ -1506,7 +1519,6 @@ export class PauseResumeManager { } } - // Update the snapshot in the database const updatedSnapshot: SerializedSnapshot = { snapshot: JSON.stringify(snapshotData), triggerIds: currentSnapshot.triggerIds, @@ -1526,7 +1538,7 @@ export class PauseResumeManager { }) } - static async beginPausedCancellation(executionId: string): Promise { + static async beginPausedCancellation(executionId: string, workflowId: string): Promise { const now = new Date() return await db.transaction(async (tx) => { @@ -1536,6 +1548,7 @@ export class PauseResumeManager { .where( and( eq(pausedExecutions.executionId, executionId), + ...(workflowId ? [eq(pausedExecutions.workflowId, workflowId)] : []), inArray(pausedExecutions.status, [...CANCELLABLE_PAUSED_STATUSES, 'cancelling']) ) ) @@ -1573,14 +1586,24 @@ export class PauseResumeManager { }) } - static async completePausedCancellation(executionId: string): Promise { + static async completePausedCancellation( + executionId: string, + workflowId: string + ): Promise { const now = new Date() return await db.transaction(async (tx) => { const pausedExecution = await tx .select({ id: pausedExecutions.id, status: pausedExecutions.status }) .from(pausedExecutions) - .where(eq(pausedExecutions.executionId, executionId)) + .where( + workflowId + ? and( + eq(pausedExecutions.executionId, executionId), + eq(pausedExecutions.workflowId, workflowId) + ) + : eq(pausedExecutions.executionId, executionId) + ) .for('update') .limit(1) .then((rows) => rows[0]) @@ -1606,7 +1629,10 @@ export class PauseResumeManager { }) } - static async blockQueuedResumesForCancellation(executionId: string): Promise { + static async blockQueuedResumesForCancellation( + executionId: string, + workflowId: string + ): Promise { const now = new Date() return await db.transaction(async (tx) => { @@ -1616,6 +1642,7 @@ export class PauseResumeManager { .where( and( eq(pausedExecutions.executionId, executionId), + ...(workflowId ? [eq(pausedExecutions.workflowId, workflowId)] : []), inArray(pausedExecutions.status, [...CANCELLABLE_PAUSED_STATUSES, 'cancelling']) ) ) @@ -1647,7 +1674,10 @@ export class PauseResumeManager { }) } - static async clearPausedCancellationIntent(executionId: string): Promise { + static async clearPausedCancellationIntent( + executionId: string, + workflowId: string + ): Promise { const now = new Date() await db .update(pausedExecutions) @@ -1658,14 +1688,16 @@ export class PauseResumeManager { .where( and( eq(pausedExecutions.executionId, executionId), + ...(workflowId ? [eq(pausedExecutions.workflowId, workflowId)] : []), eq(pausedExecutions.status, 'cancelling') ) ) - await PauseResumeManager.processQueuedResumes(executionId) + await PauseResumeManager.processQueuedResumes(executionId, workflowId) } static async getPausedCancellationStatus( - executionId: string + executionId: string, + workflowId: string ): Promise<'cancelling' | 'cancelled' | null> { const activeResume = await db .select({ id: resumeQueue.id }) @@ -1681,7 +1713,14 @@ export class PauseResumeManager { const pausedExecution = await db .select({ status: pausedExecutions.status }) .from(pausedExecutions) - .where(eq(pausedExecutions.executionId, executionId)) + .where( + workflowId + ? and( + eq(pausedExecutions.executionId, executionId), + eq(pausedExecutions.workflowId, workflowId) + ) + : eq(pausedExecutions.executionId, executionId) + ) .limit(1) .then((rows) => rows[0]) @@ -1838,7 +1877,7 @@ export class PauseResumeManager { } } - static async processQueuedResumes(parentExecutionId: string): Promise { + static async processQueuedResumes(parentExecutionId: string, workflowId: string): Promise { let pendingEntry: { entry: typeof resumeQueue.$inferSelect pausedExecution: typeof pausedExecutions.$inferSelect @@ -1849,7 +1888,14 @@ export class PauseResumeManager { const pausedExecution = await tx .select() .from(pausedExecutions) - .where(eq(pausedExecutions.executionId, parentExecutionId)) + .where( + workflowId + ? and( + eq(pausedExecutions.executionId, parentExecutionId), + eq(pausedExecutions.workflowId, workflowId) + ) + : eq(pausedExecutions.executionId, parentExecutionId) + ) .for('update') .limit(1) .then((rows) => rows[0]) diff --git a/apps/sim/lib/workflows/executor/pause-persistence.ts b/apps/sim/lib/workflows/executor/pause-persistence.ts index 57036dccf6f..2080668cccd 100644 --- a/apps/sim/lib/workflows/executor/pause-persistence.ts +++ b/apps/sim/lib/workflows/executor/pause-persistence.ts @@ -54,7 +54,7 @@ export async function handlePostExecutionPauseState({ } } else { try { - await PauseResumeManager.processQueuedResumes(executionId) + await PauseResumeManager.processQueuedResumes(executionId, workflowId) } catch (resumeError) { logger.error('Failed to process queued resumes', { executionId,