diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index c6a4626813..4228c3f3f2 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -25,13 +25,19 @@ interface AccountInsertData { accessTokenExpiresAt?: Date } -async function resolveOAuthAccountId( +/** + * Resolves a credential ID to its underlying account ID. + * If `credentialId` matches a `credential` row, returns its `accountId` and `workspaceId`. + * Otherwise assumes `credentialId` is already a raw `account.id` (legacy). + */ +export async function resolveOAuthAccountId( credentialId: string -): Promise<{ accountId: string; usedCredentialTable: boolean } | null> { +): Promise<{ accountId: string; workspaceId?: string; usedCredentialTable: boolean } | null> { const [credentialRow] = await db .select({ type: credential.type, accountId: credential.accountId, + workspaceId: credential.workspaceId, }) .from(credential) .where(eq(credential.id, credentialId)) @@ -41,7 +47,11 @@ async function resolveOAuthAccountId( if (credentialRow.type !== 'oauth' || !credentialRow.accountId) { return null } - return { accountId: credentialRow.accountId, usedCredentialTable: true } + return { + accountId: credentialRow.accountId, + workspaceId: credentialRow.workspaceId, + usedCredentialTable: true, + } } return { accountId: credentialId, usedCredentialTable: false } 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 61fc0b591d..ee4b6f1958 100644 --- a/apps/sim/app/api/auth/oauth/wealthbox/item/route.ts +++ b/apps/sim/app/api/auth/oauth/wealthbox/item/route.ts @@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -57,24 +57,41 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: itemIdValidation.error }, { status: 400 }) } - const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + 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 credential = credentials[0] + const accountRow = credentials[0] - if (credential.userId !== session.user.id) { - logger.warn(`[${requestId}] Unauthorized credential access attempt`, { - credentialUserId: credential.userId, - requestUserId: session.user.id, - }) - return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId) + const accessToken = await refreshAccessTokenIfNeeded( + resolved.accountId, + accountRow.userId, + requestId + ) if (!accessToken) { logger.error(`[${requestId}] Failed to obtain valid access token`) diff --git a/apps/sim/app/api/auth/oauth/wealthbox/items/route.ts b/apps/sim/app/api/auth/oauth/wealthbox/items/route.ts index e276111762..aaa1678cac 100644 --- a/apps/sim/app/api/auth/oauth/wealthbox/items/route.ts +++ b/apps/sim/app/api/auth/oauth/wealthbox/items/route.ts @@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -47,27 +47,41 @@ export async function GET(request: NextRequest) { ) } - // Get the credential from the database - const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + 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 credential = credentials[0] - - // Check if the credential belongs to the user - if (credential.userId !== session.user.id) { - logger.warn(`[${requestId}] Unauthorized credential access attempt`, { - credentialUserId: credential.userId, - requestUserId: session.user.id, - }) - return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) - } + const accountRow = credentials[0] - // Refresh access token if needed - const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId) + const accessToken = await refreshAccessTokenIfNeeded( + resolved.accountId, + accountRow.userId, + requestId + ) if (!accessToken) { logger.error(`[${requestId}] Failed to obtain valid access token`) diff --git a/apps/sim/app/api/cron/renew-subscriptions/route.ts b/apps/sim/app/api/cron/renew-subscriptions/route.ts index 57def36986..8b8f9f7159 100644 --- a/apps/sim/app/api/cron/renew-subscriptions/route.ts +++ b/apps/sim/app/api/cron/renew-subscriptions/route.ts @@ -4,18 +4,27 @@ import { createLogger } from '@sim/logger' import { and, eq, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' const logger = createLogger('TeamsSubscriptionRenewal') -async function getCredentialOwnerUserId(credentialId: string): Promise { +async function getCredentialOwner( + credentialId: string +): Promise<{ userId: string; accountId: string } | null> { + const resolved = await resolveOAuthAccountId(credentialId) + if (!resolved) { + logger.error(`Failed to resolve OAuth account for credential ${credentialId}`) + return null + } const [credentialRecord] = await db .select({ userId: account.userId }) .from(account) - .where(eq(account.id, credentialId)) + .where(eq(account.id, resolved.accountId)) .limit(1) - return credentialRecord?.userId ?? null + return credentialRecord + ? { userId: credentialRecord.userId, accountId: resolved.accountId } + : null } /** @@ -88,8 +97,8 @@ export async function GET(request: NextRequest) { continue } - const credentialOwnerUserId = await getCredentialOwnerUserId(credentialId) - if (!credentialOwnerUserId) { + const credentialOwner = await getCredentialOwner(credentialId) + if (!credentialOwner) { logger.error(`Credential owner not found for credential ${credentialId}`) totalFailed++ continue @@ -97,8 +106,8 @@ export async function GET(request: NextRequest) { // Get fresh access token const accessToken = await refreshAccessTokenIfNeeded( - credentialId, - credentialOwnerUserId, + credentialOwner.accountId, + credentialOwner.userId, `renewal-${webhook.id}` ) diff --git a/apps/sim/app/api/providers/route.ts b/apps/sim/app/api/providers/route.ts index 6b7cc934d1..556240f33b 100644 --- a/apps/sim/app/api/providers/route.ts +++ b/apps/sim/app/api/providers/route.ts @@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' -import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' import type { StreamingExecution } from '@/executor/types' import { executeProviderRequest } from '@/providers' @@ -360,15 +360,20 @@ function sanitizeObject(obj: any): any { async function resolveVertexCredential(requestId: string, credentialId: string): Promise { logger.info(`[${requestId}] Resolving Vertex AI credential: ${credentialId}`) + const resolved = await resolveOAuthAccountId(credentialId) + if (!resolved) { + throw new Error(`Vertex AI credential not found: ${credentialId}`) + } + const credential = await db.query.account.findFirst({ - where: eq(account.id, credentialId), + where: eq(account.id, resolved.accountId), }) if (!credential) { throw new Error(`Vertex AI credential not found: ${credentialId}`) } - const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId) + const { accessToken } = await refreshTokenIfNeeded(requestId, credential, resolved.accountId) if (!accessToken) { throw new Error('Failed to get Vertex AI access token') diff --git a/apps/sim/app/api/tools/gmail/label/route.ts b/apps/sim/app/api/tools/gmail/label/route.ts index 7994c91fd0..26437d267a 100644 --- a/apps/sim/app/api/tools/gmail/label/route.ts +++ b/apps/sim/app/api/tools/gmail/label/route.ts @@ -1,12 +1,12 @@ import { db } from '@sim/db' import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -41,10 +41,27 @@ export async function GET(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 credentials = await db .select() .from(account) - .where(and(eq(account.id, credentialId), eq(account.userId, session.user.id))) + .where(eq(account.id, resolved.accountId)) .limit(1) if (!credentials.length) { @@ -52,13 +69,17 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) } - const credential = credentials[0] + const accountRow = credentials[0] logger.info( - `[${requestId}] Using credential: ${credential.id}, provider: ${credential.providerId}` + `[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}` ) - const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId) + const accessToken = await refreshAccessTokenIfNeeded( + resolved.accountId, + accountRow.userId, + requestId + ) if (!accessToken) { return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) diff --git a/apps/sim/app/api/tools/gmail/labels/route.ts b/apps/sim/app/api/tools/gmail/labels/route.ts index 36d9040ca4..6aed016040 100644 --- a/apps/sim/app/api/tools/gmail/labels/route.ts +++ b/apps/sim/app/api/tools/gmail/labels/route.ts @@ -1,12 +1,12 @@ import { db } from '@sim/db' import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' const logger = createLogger('GmailLabelsAPI') @@ -45,27 +45,45 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) } - let credentials = await db + 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(and(eq(account.id, credentialId), eq(account.userId, session.user.id))) + .where(eq(account.id, resolved.accountId)) .limit(1) if (!credentials.length) { - credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) - if (!credentials.length) { - logger.warn(`[${requestId}] Credential not found`) - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } + logger.warn(`[${requestId}] Credential not found`) + return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) } - const credential = credentials[0] + const accountRow = credentials[0] logger.info( - `[${requestId}] Using credential: ${credential.id}, provider: ${credential.providerId}` + `[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}` ) - const accessToken = await refreshAccessTokenIfNeeded(credentialId, credential.userId, requestId) + const accessToken = await refreshAccessTokenIfNeeded( + resolved.accountId, + accountRow.userId, + requestId + ) if (!accessToken) { return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) diff --git a/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts b/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts index 67566ad8a8..eecfdb48c7 100644 --- a/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts +++ b/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts @@ -6,7 +6,7 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' import type { PlannerTask } from '@/tools/microsoft_planner/types' const logger = createLogger('MicrosoftPlannerTasksAPI') @@ -42,24 +42,41 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: planIdValidation.error }, { status: 400 }) } - const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + 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 credential = credentials[0] - - if (credential.userId !== session.user.id) { - logger.warn(`[${requestId}] Unauthorized credential access attempt`, { - credentialUserId: credential.userId, - requestUserId: session.user.id, - }) - return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) - } + const accountRow = credentials[0] - const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId) + const accessToken = await refreshAccessTokenIfNeeded( + resolved.accountId, + accountRow.userId, + requestId + ) if (!accessToken) { logger.error(`[${requestId}] Failed to obtain valid access token`) diff --git a/apps/sim/app/api/tools/onedrive/files/route.ts b/apps/sim/app/api/tools/onedrive/files/route.ts index c894834576..4f6828c48c 100644 --- a/apps/sim/app/api/tools/onedrive/files/route.ts +++ b/apps/sim/app/api/tools/onedrive/files/route.ts @@ -6,7 +6,7 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -45,22 +45,40 @@ export async function GET(request: NextRequest) { logger.info(`[${requestId}] Fetching credential`, { credentialId }) - const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + 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 credential = credentials[0] - if (credential.userId !== session.user.id) { - logger.warn(`[${requestId}] Unauthorized credential access attempt`, { - credentialUserId: credential.userId, - requestUserId: session.user.id, - }) - return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) - } + const accountRow = credentials[0] - const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId) + const accessToken = await refreshAccessTokenIfNeeded( + resolved.accountId, + accountRow.userId, + requestId + ) if (!accessToken) { logger.error(`[${requestId}] Failed to obtain valid access token`) return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) diff --git a/apps/sim/app/api/tools/onedrive/folder/route.ts b/apps/sim/app/api/tools/onedrive/folder/route.ts index 2cf68fa533..a4e80b66f9 100644 --- a/apps/sim/app/api/tools/onedrive/folder/route.ts +++ b/apps/sim/app/api/tools/onedrive/folder/route.ts @@ -6,7 +6,7 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -34,17 +34,39 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: fileIdValidation.error }, { status: 400 }) } - const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) - if (!credentials.length) { + const resolved = await resolveOAuthAccountId(credentialId) + if (!resolved) { return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) } - const credential = credentials[0] - if (credential.userId !== session.user.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) + 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 accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId) + 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 accountRow = credentials[0] + + const accessToken = await refreshAccessTokenIfNeeded( + resolved.accountId, + accountRow.userId, + requestId + ) if (!accessToken) { return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) } diff --git a/apps/sim/app/api/tools/onedrive/folders/route.ts b/apps/sim/app/api/tools/onedrive/folders/route.ts index 1eac6c2678..271c4e69f7 100644 --- a/apps/sim/app/api/tools/onedrive/folders/route.ts +++ b/apps/sim/app/api/tools/onedrive/folders/route.ts @@ -6,7 +6,7 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -40,17 +40,39 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) } - const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) - if (!credentials.length) { + const resolved = await resolveOAuthAccountId(credentialId) + if (!resolved) { return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) } - const credential = credentials[0] - if (credential.userId !== session.user.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) + 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 accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId) + 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 accountRow = credentials[0] + + const accessToken = await refreshAccessTokenIfNeeded( + resolved.accountId, + accountRow.userId, + requestId + ) if (!accessToken) { return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) } diff --git a/apps/sim/app/api/tools/outlook/folders/route.ts b/apps/sim/app/api/tools/outlook/folders/route.ts index 7be86ebff0..8bf9e906d1 100644 --- a/apps/sim/app/api/tools/outlook/folders/route.ts +++ b/apps/sim/app/api/tools/outlook/folders/route.ts @@ -6,7 +6,7 @@ import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -44,7 +44,28 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } - const creds = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + 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 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 }) @@ -52,7 +73,7 @@ export async function GET(request: Request) { const credentialOwnerUserId = creds[0].userId const accessToken = await refreshAccessTokenIfNeeded( - credentialId, + resolved.accountId, credentialOwnerUserId, generateRequestId() ) diff --git a/apps/sim/app/api/tools/sharepoint/site/route.ts b/apps/sim/app/api/tools/sharepoint/site/route.ts index 2ffecce942..24941f034d 100644 --- a/apps/sim/app/api/tools/sharepoint/site/route.ts +++ b/apps/sim/app/api/tools/sharepoint/site/route.ts @@ -6,7 +6,7 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -34,17 +34,39 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: siteIdValidation.error }, { status: 400 }) } - const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) - if (!credentials.length) { + const resolved = await resolveOAuthAccountId(credentialId) + if (!resolved) { return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) } - const credential = credentials[0] - if (credential.userId !== session.user.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) + 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 accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId) + 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 accountRow = credentials[0] + + const accessToken = await refreshAccessTokenIfNeeded( + resolved.accountId, + accountRow.userId, + requestId + ) if (!accessToken) { return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) } diff --git a/apps/sim/app/api/tools/sharepoint/sites/route.ts b/apps/sim/app/api/tools/sharepoint/sites/route.ts index 7e98bf6212..de161b9730 100644 --- a/apps/sim/app/api/tools/sharepoint/sites/route.ts +++ b/apps/sim/app/api/tools/sharepoint/sites/route.ts @@ -6,7 +6,7 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { validateAlphanumericId } from '@/lib/core/security/input-validation' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' import type { SharepointSite } from '@/tools/sharepoint/types' export const dynamic = 'force-dynamic' @@ -39,17 +39,39 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) } - const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) - if (!credentials.length) { + const resolved = await resolveOAuthAccountId(credentialId) + if (!resolved) { return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) } - const credential = credentials[0] - if (credential.userId !== session.user.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) + 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 accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId) + 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 accountRow = credentials[0] + + const accessToken = await refreshAccessTokenIfNeeded( + resolved.accountId, + accountRow.userId, + requestId + ) if (!accessToken) { return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) } diff --git a/apps/sim/app/api/tools/wealthbox/item/route.ts b/apps/sim/app/api/tools/wealthbox/item/route.ts index b618470e6f..ae2afd4cc0 100644 --- a/apps/sim/app/api/tools/wealthbox/item/route.ts +++ b/apps/sim/app/api/tools/wealthbox/item/route.ts @@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -64,24 +64,41 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) } - const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + 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 credential = credentials[0] - - if (credential.userId !== session.user.id) { - logger.warn(`[${requestId}] Unauthorized credential access attempt`, { - credentialUserId: credential.userId, - requestUserId: session.user.id, - }) - return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) - } + const accountRow = credentials[0] - const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId) + const accessToken = await refreshAccessTokenIfNeeded( + resolved.accountId, + accountRow.userId, + requestId + ) if (!accessToken) { logger.error(`[${requestId}] Failed to obtain valid access token`) diff --git a/apps/sim/app/api/tools/wealthbox/items/route.ts b/apps/sim/app/api/tools/wealthbox/items/route.ts index a07ff62c41..efdda2b3c5 100644 --- a/apps/sim/app/api/tools/wealthbox/items/route.ts +++ b/apps/sim/app/api/tools/wealthbox/items/route.ts @@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -64,24 +64,41 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: typeValidation.error }, { status: 400 }) } - const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + 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 credential = credentials[0] - - if (credential.userId !== session.user.id) { - logger.warn(`[${requestId}] Unauthorized credential access attempt`, { - credentialUserId: credential.userId, - requestUserId: session.user.id, - }) - return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) - } + const accountRow = credentials[0] - const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId) + const accessToken = await refreshAccessTokenIfNeeded( + resolved.accountId, + accountRow.userId, + requestId + ) if (!accessToken) { logger.error(`[${requestId}] Failed to obtain valid access token`) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index b5a76f2902..76b7c4de2e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -24,6 +24,7 @@ import { useCredentialSets } from '@/hooks/queries/credential-sets' import { useOAuthCredentials } from '@/hooks/queries/oauth-credentials' import { useOrganizations } from '@/hooks/queries/organization' import { useSubscriptionData } from '@/hooks/queries/subscription' +import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers' import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -54,6 +55,7 @@ export function CredentialSelector({ const [isEditing, setIsEditing] = useState(false) const { activeWorkflowId } = useWorkflowRegistry() const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) + const { collaborativeSetSubblockValue } = useCollaborativeWorkflow() const requiredScopes = subBlock.requiredScopes || [] const label = subBlock.placeholder || 'Select credential' @@ -135,7 +137,9 @@ export function CredentialSelector({ const data = await response.json() if (!cancelled && data.credential?.displayName) { if (data.credential.id !== selectedId) { - setStoreValue(data.credential.id) + collaborativeSetSubblockValue(blockId, subBlock.id, data.credential.id, { + skipDependsOn: true, + }) } setInaccessibleCredentialName(data.credential.displayName) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx index 2222b26909..9c04f96113 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx @@ -153,7 +153,7 @@ const allNavigationItems: NavigationItem[] = [ requiresHosted: true, requiresTeam: true, }, - { id: 'credentials', label: 'Credentials', icon: Connections, section: 'tools' }, + { id: 'credentials', label: 'Credentials', icon: Connections, section: 'account' }, { id: 'custom-tools', label: 'Custom Tools', icon: Wrench, section: 'tools' }, { id: 'skills', label: 'Skills', icon: AgentSkillsIcon, section: 'tools' }, { id: 'mcp', label: 'MCP Tools', icon: McpIcon, section: 'tools' }, diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 54c4571c11..075438dd38 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -3,7 +3,7 @@ import { account, mcpServers } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, inArray, isNull } from 'drizzle-orm' import { createMcpToolId } from '@/lib/mcp/utils' -import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' import { getAllBlocks } from '@/blocks' import type { BlockOutput } from '@/blocks/types' import { @@ -1103,15 +1103,20 @@ export class AgentBlockHandler implements BlockHandler { logger.info(`[${requestId}] Resolving Vertex AI credential: ${credentialId}`) + const resolved = await resolveOAuthAccountId(credentialId) + if (!resolved) { + throw new Error(`Vertex AI credential is not a valid OAuth credential: ${credentialId}`) + } + const credential = await db.query.account.findFirst({ - where: eq(account.id, credentialId), + where: eq(account.id, resolved.accountId), }) if (!credential) { throw new Error(`Vertex AI credential not found: ${credentialId}`) } - const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId) + const { accessToken } = await refreshTokenIfNeeded(requestId, credential, resolved.accountId) if (!accessToken) { throw new Error('Failed to get Vertex AI access token') diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts index 8cbbb0bbf4..2ad3b5e90b 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts @@ -1,6 +1,16 @@ import '@sim/testing/mocks/executor' import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' + +vi.mock('@/app/api/auth/oauth/utils', () => ({ + resolveOAuthAccountId: vi + .fn() + .mockResolvedValue({ accountId: 'test-vertex-credential-id', usedCredentialTable: false }), + refreshTokenIfNeeded: vi + .fn() + .mockResolvedValue({ accessToken: 'mock-access-token', refreshed: false }), +})) + import { BlockType } from '@/executor/constants' import { EvaluatorBlockHandler } from '@/executor/handlers/evaluator/evaluator-handler' import type { ExecutionContext } from '@/executor/types' diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts index 65ea2f9eae..bd8fe04a24 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts @@ -2,7 +2,7 @@ import { db } from '@sim/db' import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' -import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' import type { BlockOutput } from '@/blocks/types' import { validateModelProvider } from '@/ee/access-control/utils/permission-check' import { BlockType, DEFAULTS, EVALUATOR } from '@/executor/constants' @@ -284,15 +284,20 @@ export class EvaluatorBlockHandler implements BlockHandler { logger.info(`[${requestId}] Resolving Vertex AI credential: ${credentialId}`) + const resolved = await resolveOAuthAccountId(credentialId) + if (!resolved) { + throw new Error(`Vertex AI credential is not a valid OAuth credential: ${credentialId}`) + } + const credential = await db.query.account.findFirst({ - where: eq(account.id, credentialId), + where: eq(account.id, resolved.accountId), }) if (!credential) { throw new Error(`Vertex AI credential not found: ${credentialId}`) } - const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId) + const { accessToken } = await refreshTokenIfNeeded(requestId, credential, resolved.accountId) if (!accessToken) { throw new Error('Failed to get Vertex AI access token') diff --git a/apps/sim/executor/handlers/router/router-handler.test.ts b/apps/sim/executor/handlers/router/router-handler.test.ts index cde4323740..5defd79784 100644 --- a/apps/sim/executor/handlers/router/router-handler.test.ts +++ b/apps/sim/executor/handlers/router/router-handler.test.ts @@ -1,6 +1,16 @@ import '@sim/testing/mocks/executor' import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' + +vi.mock('@/app/api/auth/oauth/utils', () => ({ + resolveOAuthAccountId: vi + .fn() + .mockResolvedValue({ accountId: 'test-vertex-credential-id', usedCredentialTable: false }), + refreshTokenIfNeeded: vi + .fn() + .mockResolvedValue({ accessToken: 'mock-access-token', refreshed: false }), +})) + import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router' import { BlockType } from '@/executor/constants' import { RouterBlockHandler } from '@/executor/handlers/router/router-handler' diff --git a/apps/sim/executor/handlers/router/router-handler.ts b/apps/sim/executor/handlers/router/router-handler.ts index 5c22a1c49a..723ba94393 100644 --- a/apps/sim/executor/handlers/router/router-handler.ts +++ b/apps/sim/executor/handlers/router/router-handler.ts @@ -3,7 +3,7 @@ import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' -import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router' import type { BlockOutput } from '@/blocks/types' import { validateModelProvider } from '@/ee/access-control/utils/permission-check' @@ -425,15 +425,20 @@ export class RouterBlockHandler implements BlockHandler { logger.info(`[${requestId}] Resolving Vertex AI credential: ${credentialId}`) + const resolved = await resolveOAuthAccountId(credentialId) + if (!resolved) { + throw new Error(`Vertex AI credential is not a valid OAuth credential: ${credentialId}`) + } + const credential = await db.query.account.findFirst({ - where: eq(account.id, credentialId), + where: eq(account.id, resolved.accountId), }) if (!credential) { throw new Error(`Vertex AI credential not found: ${credentialId}`) } - const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId) + const { accessToken } = await refreshTokenIfNeeded(requestId, credential, resolved.accountId) if (!accessToken) { throw new Error('Failed to get Vertex AI access token') diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index 83fdd5ed17..8a6c8d09b1 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -1232,7 +1232,12 @@ export function useCollaborativeWorkflow() { ) const collaborativeSetSubblockValue = useCallback( - (blockId: string, subblockId: string, value: any, options?: { _visited?: Set }) => { + ( + blockId: string, + subblockId: string, + value: any, + options?: { _visited?: Set; skipDependsOn?: boolean } + ) => { if (isApplyingRemoteChange.current) return if (isBaselineDiffView) { @@ -1258,6 +1263,8 @@ export function useCollaborativeWorkflow() { }) } + if (options?.skipDependsOn) return + // Handle dependent subblock clearing (recursive calls) try { const visited = options?._visited || new Set() diff --git a/apps/sim/lib/webhooks/gmail-polling-service.ts b/apps/sim/lib/webhooks/gmail-polling-service.ts index 9b391002e9..1e89f28371 100644 --- a/apps/sim/lib/webhooks/gmail-polling-service.ts +++ b/apps/sim/lib/webhooks/gmail-polling-service.ts @@ -12,7 +12,11 @@ import { nanoid } from 'nanoid' import { isOrganizationOnTeamOrEnterprisePlan } from '@/lib/billing' import { pollingIdempotency } from '@/lib/core/idempotency/service' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' -import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { + getOAuthToken, + refreshAccessTokenIfNeeded, + resolveOAuthAccountId, +} from '@/app/api/auth/oauth/utils' import type { GmailAttachment } from '@/tools/gmail/types' import { downloadAttachments, extractAttachmentInfo } from '@/tools/gmail/utils' import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants' @@ -198,7 +202,20 @@ export async function pollGmailWebhooks() { let accessToken: string | null = null if (credentialId) { - const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + const resolved = await resolveOAuthAccountId(credentialId) + if (!resolved) { + logger.error( + `[${requestId}] Failed to resolve OAuth account for credential ${credentialId}, webhook ${webhookId}` + ) + await markWebhookFailed(webhookId) + failureCount++ + return + } + const rows = await db + .select() + .from(account) + .where(eq(account.id, resolved.accountId)) + .limit(1) if (rows.length === 0) { logger.error( `[${requestId}] Credential ${credentialId} not found for webhook ${webhookId}` @@ -208,7 +225,7 @@ export async function pollGmailWebhooks() { return } const ownerUserId = rows[0].userId - accessToken = await refreshAccessTokenIfNeeded(credentialId, ownerUserId, requestId) + accessToken = await refreshAccessTokenIfNeeded(resolved.accountId, ownerUserId, requestId) } else if (userId) { // Legacy fallback for webhooks without credentialId accessToken = await getOAuthToken(userId, 'google-email') diff --git a/apps/sim/lib/webhooks/outlook-polling-service.ts b/apps/sim/lib/webhooks/outlook-polling-service.ts index 19a8079281..d5e963cea3 100644 --- a/apps/sim/lib/webhooks/outlook-polling-service.ts +++ b/apps/sim/lib/webhooks/outlook-polling-service.ts @@ -13,7 +13,11 @@ import { nanoid } from 'nanoid' import { isOrganizationOnTeamOrEnterprisePlan } from '@/lib/billing' import { pollingIdempotency } from '@/lib/core/idempotency' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' -import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { + getOAuthToken, + refreshAccessTokenIfNeeded, + resolveOAuthAccountId, +} from '@/app/api/auth/oauth/utils' import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants' const logger = createLogger('OutlookPollingService') @@ -246,7 +250,20 @@ export async function pollOutlookWebhooks() { let accessToken: string | null = null if (credentialId) { - const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + const resolved = await resolveOAuthAccountId(credentialId) + if (!resolved) { + logger.error( + `[${requestId}] Failed to resolve OAuth account for credential ${credentialId}, webhook ${webhookId}` + ) + await markWebhookFailed(webhookId) + failureCount++ + return + } + const rows = await db + .select() + .from(account) + .where(eq(account.id, resolved.accountId)) + .limit(1) if (!rows.length) { logger.error( `[${requestId}] Credential ${credentialId} not found for webhook ${webhookId}` @@ -256,7 +273,7 @@ export async function pollOutlookWebhooks() { return } const ownerUserId = rows[0].userId - accessToken = await refreshAccessTokenIfNeeded(credentialId, ownerUserId, requestId) + accessToken = await refreshAccessTokenIfNeeded(resolved.accountId, ownerUserId, requestId) } else if (userId) { accessToken = await getOAuthToken(userId, 'outlook') } diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index 5b65764f70..82c8801939 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -25,6 +25,7 @@ import { verifyProviderWebhook, } from '@/lib/webhooks/utils.server' import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' +import { resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' import { executeWebhookJob } from '@/background/webhook-execution' import { resolveEnvVarReferences } from '@/executor/utils/reference-validation' import { isGitHubEventMatch } from '@/triggers/github/utils' @@ -992,10 +993,17 @@ export async function queueWebhookExecution( const credentialId = providerConfig.credentialId as string | undefined let credentialAccountUserId: string | undefined if (credentialId) { + const resolved = await resolveOAuthAccountId(credentialId) + if (!resolved) { + logger.error( + `[${options.requestId}] Failed to resolve OAuth account for credential ${credentialId}` + ) + return formatProviderErrorResponse(foundWebhook, 'Failed to resolve credential', 500) + } const [credentialRecord] = await db .select({ userId: account.userId }) .from(account) - .where(eq(account.id, credentialId)) + .where(eq(account.id, resolved.accountId)) .limit(1) credentialAccountUserId = credentialRecord?.userId } diff --git a/apps/sim/lib/webhooks/provider-subscriptions.ts b/apps/sim/lib/webhooks/provider-subscriptions.ts index 9d64b4ce51..10c1205711 100644 --- a/apps/sim/lib/webhooks/provider-subscriptions.ts +++ b/apps/sim/lib/webhooks/provider-subscriptions.ts @@ -5,7 +5,11 @@ import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { validateAirtableId, validateAlphanumericId } from '@/lib/core/security/input-validation' import { getBaseUrl } from '@/lib/core/utils/urls' -import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { + getOAuthToken, + refreshAccessTokenIfNeeded, + resolveOAuthAccountId, +} from '@/app/api/auth/oauth/utils' const teamsLogger = createLogger('TeamsSubscription') const telegramLogger = createLogger('TelegramWebhook') @@ -25,14 +29,21 @@ function getNotificationUrl(webhook: any): string { return `${getBaseUrl()}/api/webhooks/trigger/${webhook.path}` } -async function getCredentialOwnerUserId( +async function getCredentialOwner( credentialId: string, requestId: string -): Promise { +): Promise<{ userId: string; accountId: string } | null> { + const resolved = await resolveOAuthAccountId(credentialId) + if (!resolved) { + providerSubscriptionsLogger.warn( + `[${requestId}] Failed to resolve OAuth account for credentialId ${credentialId}` + ) + return null + } const [credentialRecord] = await db .select({ userId: account.userId }) .from(account) - .where(eq(account.id, credentialId)) + .where(eq(account.id, resolved.accountId)) .limit(1) if (!credentialRecord?.userId) { @@ -42,7 +53,7 @@ async function getCredentialOwnerUserId( return null } - return credentialRecord.userId + return { userId: credentialRecord.userId, accountId: resolved.accountId } } /** @@ -80,9 +91,9 @@ export async function createTeamsSubscription( ) } - const credentialOwnerUserId = await getCredentialOwnerUserId(credentialId, requestId) - const accessToken = credentialOwnerUserId - ? await refreshAccessTokenIfNeeded(credentialId, credentialOwnerUserId, requestId) + const credentialOwner = await getCredentialOwner(credentialId, requestId) + const accessToken = credentialOwner + ? await refreshAccessTokenIfNeeded(credentialOwner.accountId, credentialOwner.userId, requestId) : null if (!accessToken) { teamsLogger.error( @@ -216,9 +227,13 @@ export async function deleteTeamsSubscription( return } - const credentialOwnerUserId = await getCredentialOwnerUserId(credentialId, requestId) - const accessToken = credentialOwnerUserId - ? await refreshAccessTokenIfNeeded(credentialId, credentialOwnerUserId, requestId) + const credentialOwner = await getCredentialOwner(credentialId, requestId) + const accessToken = credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) : null if (!accessToken) { teamsLogger.warn( @@ -407,9 +422,13 @@ export async function deleteAirtableWebhook( return } - const credentialOwnerUserId = await getCredentialOwnerUserId(credentialId, requestId) - const accessToken = credentialOwnerUserId - ? await refreshAccessTokenIfNeeded(credentialId, credentialOwnerUserId, requestId) + const credentialOwner = await getCredentialOwner(credentialId, requestId) + const accessToken = credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) : null if (!accessToken) { airtableLogger.warn( @@ -917,9 +936,13 @@ export async function deleteWebflowWebhook( return } - const credentialOwnerUserId = await getCredentialOwnerUserId(credentialId, requestId) - const accessToken = credentialOwnerUserId - ? await refreshAccessTokenIfNeeded(credentialId, credentialOwnerUserId, requestId) + const credentialOwner = await getCredentialOwner(credentialId, requestId) + const accessToken = credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) : null if (!accessToken) { webflowLogger.warn( @@ -1229,12 +1252,14 @@ export async function createAirtableWebhookSubscription( throw new Error(tableIdValidation.error) } - const credentialOwnerUserId = credentialId - ? await getCredentialOwnerUserId(credentialId, requestId) - : null + const credentialOwner = credentialId ? await getCredentialOwner(credentialId, requestId) : null const accessToken = credentialId - ? credentialOwnerUserId - ? await refreshAccessTokenIfNeeded(credentialId, credentialOwnerUserId, requestId) + ? credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) : null : await getOAuthToken(userId, 'airtable') if (!accessToken) { @@ -1480,12 +1505,14 @@ export async function createWebflowWebhookSubscription( throw new Error('Trigger type is required to create Webflow webhook') } - const credentialOwnerUserId = credentialId - ? await getCredentialOwnerUserId(credentialId, requestId) - : null + const credentialOwner = credentialId ? await getCredentialOwner(credentialId, requestId) : null const accessToken = credentialId - ? credentialOwnerUserId - ? await refreshAccessTokenIfNeeded(credentialId, credentialOwnerUserId, requestId) + ? credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) : null : await getOAuthToken(userId, 'webflow') if (!accessToken) { diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts index 974e9552b8..cb54f57914 100644 --- a/apps/sim/lib/webhooks/utils.server.ts +++ b/apps/sim/lib/webhooks/utils.server.ts @@ -17,6 +17,7 @@ import { getProviderIdFromServiceId } from '@/lib/oauth' import { getCredentialsForCredentialSet, refreshAccessTokenIfNeeded, + resolveOAuthAccountId, } from '@/app/api/auth/oauth/utils' const logger = createLogger('WebhookUtils') @@ -228,16 +229,25 @@ async function formatTeamsGraphNotification( }) } else { try { - const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) - if (rows.length === 0) { - logger.error('Teams credential not found', { credentialId, chatId: resolvedChatId }) + const resolved = await resolveOAuthAccountId(credentialId) + if (!resolved) { + logger.error('Teams credential could not be resolved', { credentialId }) } else { - const effectiveUserId = rows[0].userId - accessToken = await refreshAccessTokenIfNeeded( - credentialId, - effectiveUserId, - 'teams-graph-notification' - ) + const rows = await db + .select() + .from(account) + .where(eq(account.id, resolved.accountId)) + .limit(1) + if (rows.length === 0) { + logger.error('Teams credential not found', { credentialId, chatId: resolvedChatId }) + } else { + const effectiveUserId = rows[0].userId + accessToken = await refreshAccessTokenIfNeeded( + resolved.accountId, + effectiveUserId, + 'teams-graph-notification' + ) + } } if (accessToken) { @@ -1657,9 +1667,21 @@ export async function fetchAndProcessAirtablePayloads( return } + const resolvedAirtable = await resolveOAuthAccountId(credentialId) + if (!resolvedAirtable) { + logger.error( + `[${requestId}] Could not resolve credential ${credentialId} for Airtable webhook` + ) + return + } + let ownerUserId: string | null = null try { - const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + const rows = await db + .select() + .from(account) + .where(eq(account.id, resolvedAirtable.accountId)) + .limit(1) ownerUserId = rows.length ? rows[0].userId : null } catch (_e) { ownerUserId = null @@ -1717,7 +1739,11 @@ export async function fetchAndProcessAirtablePayloads( let accessToken: string | null = null try { - accessToken = await refreshAccessTokenIfNeeded(credentialId, ownerUserId, requestId) + accessToken = await refreshAccessTokenIfNeeded( + resolvedAirtable.accountId, + ownerUserId, + requestId + ) if (!accessToken) { logger.error( `[${requestId}] Failed to obtain valid Airtable access token via credential ${credentialId}.` @@ -2443,8 +2469,19 @@ export async function configureGmailPolling(webhookData: any, requestId: string) return false } - // Verify credential exists and get userId - const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + const resolvedGmail = await resolveOAuthAccountId(credentialId) + if (!resolvedGmail) { + logger.error( + `[${requestId}] Could not resolve credential ${credentialId} for Gmail webhook ${webhookData.id}` + ) + return false + } + + const rows = await db + .select() + .from(account) + .where(eq(account.id, resolvedGmail.accountId)) + .limit(1) if (rows.length === 0) { logger.error( `[${requestId}] Credential ${credentialId} not found for Gmail webhook ${webhookData.id}` @@ -2454,8 +2491,11 @@ export async function configureGmailPolling(webhookData: any, requestId: string) const effectiveUserId = rows[0].userId - // Verify token can be refreshed - const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId) + const accessToken = await refreshAccessTokenIfNeeded( + resolvedGmail.accountId, + effectiveUserId, + requestId + ) if (!accessToken) { logger.error( `[${requestId}] Failed to refresh/access Gmail token for credential ${credentialId}` @@ -2529,8 +2569,19 @@ export async function configureOutlookPolling( return false } - // Verify credential exists and get userId - const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + const resolvedOutlook = await resolveOAuthAccountId(credentialId) + if (!resolvedOutlook) { + logger.error( + `[${requestId}] Could not resolve credential ${credentialId} for Outlook webhook ${webhookData.id}` + ) + return false + } + + const rows = await db + .select() + .from(account) + .where(eq(account.id, resolvedOutlook.accountId)) + .limit(1) if (rows.length === 0) { logger.error( `[${requestId}] Credential ${credentialId} not found for Outlook webhook ${webhookData.id}` @@ -2540,8 +2591,11 @@ export async function configureOutlookPolling( const effectiveUserId = rows[0].userId - // Verify token can be refreshed - const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId) + const accessToken = await refreshAccessTokenIfNeeded( + resolvedOutlook.accountId, + effectiveUserId, + requestId + ) if (!accessToken) { logger.error( `[${requestId}] Failed to refresh/access Outlook token for credential ${credentialId}`