Skip to content

Commit e55d41f

Browse files
fix(credentials): credential dependent endpoints (#3309)
* fix(dependent): credential dependent endpoints * fix tests * fix route to not block ws creds" * remove faulty auth checks: * prevent unintended cascade by depends on during migration * address bugbot comments
1 parent 364bb19 commit e55d41f

File tree

29 files changed

+643
-202
lines changed

29 files changed

+643
-202
lines changed

apps/sim/app/api/auth/oauth/utils.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,19 @@ interface AccountInsertData {
2525
accessTokenExpiresAt?: Date
2626
}
2727

28-
async function resolveOAuthAccountId(
28+
/**
29+
* Resolves a credential ID to its underlying account ID.
30+
* If `credentialId` matches a `credential` row, returns its `accountId` and `workspaceId`.
31+
* Otherwise assumes `credentialId` is already a raw `account.id` (legacy).
32+
*/
33+
export async function resolveOAuthAccountId(
2934
credentialId: string
30-
): Promise<{ accountId: string; usedCredentialTable: boolean } | null> {
35+
): Promise<{ accountId: string; workspaceId?: string; usedCredentialTable: boolean } | null> {
3136
const [credentialRow] = await db
3237
.select({
3338
type: credential.type,
3439
accountId: credential.accountId,
40+
workspaceId: credential.workspaceId,
3541
})
3642
.from(credential)
3743
.where(eq(credential.id, credentialId))
@@ -41,7 +47,11 @@ async function resolveOAuthAccountId(
4147
if (credentialRow.type !== 'oauth' || !credentialRow.accountId) {
4248
return null
4349
}
44-
return { accountId: credentialRow.accountId, usedCredentialTable: true }
50+
return {
51+
accountId: credentialRow.accountId,
52+
workspaceId: credentialRow.workspaceId,
53+
usedCredentialTable: true,
54+
}
4555
}
4656

4757
return { accountId: credentialId, usedCredentialTable: false }

apps/sim/app/api/auth/oauth/wealthbox/item/route.ts

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
66
import { getSession } from '@/lib/auth'
77
import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation'
88
import { generateRequestId } from '@/lib/core/utils/request'
9-
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
9+
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
1010

1111
export const dynamic = 'force-dynamic'
1212

@@ -57,24 +57,41 @@ export async function GET(request: NextRequest) {
5757
return NextResponse.json({ error: itemIdValidation.error }, { status: 400 })
5858
}
5959

60-
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
60+
const resolved = await resolveOAuthAccountId(credentialId)
61+
if (!resolved) {
62+
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
63+
}
64+
65+
if (resolved.workspaceId) {
66+
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
67+
const perm = await getUserEntityPermissions(
68+
session.user.id,
69+
'workspace',
70+
resolved.workspaceId
71+
)
72+
if (perm === null) {
73+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
74+
}
75+
}
76+
77+
const credentials = await db
78+
.select()
79+
.from(account)
80+
.where(eq(account.id, resolved.accountId))
81+
.limit(1)
6182

6283
if (!credentials.length) {
6384
logger.warn(`[${requestId}] Credential not found`, { credentialId })
6485
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
6586
}
6687

67-
const credential = credentials[0]
88+
const accountRow = credentials[0]
6889

69-
if (credential.userId !== session.user.id) {
70-
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
71-
credentialUserId: credential.userId,
72-
requestUserId: session.user.id,
73-
})
74-
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
75-
}
76-
77-
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
90+
const accessToken = await refreshAccessTokenIfNeeded(
91+
resolved.accountId,
92+
accountRow.userId,
93+
requestId
94+
)
7895

7996
if (!accessToken) {
8097
logger.error(`[${requestId}] Failed to obtain valid access token`)

apps/sim/app/api/auth/oauth/wealthbox/items/route.ts

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { getSession } from '@/lib/auth'
77
import { generateRequestId } from '@/lib/core/utils/request'
8-
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
8+
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
99

1010
export const dynamic = 'force-dynamic'
1111

@@ -47,27 +47,41 @@ export async function GET(request: NextRequest) {
4747
)
4848
}
4949

50-
// Get the credential from the database
51-
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
50+
const resolved = await resolveOAuthAccountId(credentialId)
51+
if (!resolved) {
52+
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
53+
}
54+
55+
if (resolved.workspaceId) {
56+
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
57+
const perm = await getUserEntityPermissions(
58+
session.user.id,
59+
'workspace',
60+
resolved.workspaceId
61+
)
62+
if (perm === null) {
63+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
64+
}
65+
}
66+
67+
const credentials = await db
68+
.select()
69+
.from(account)
70+
.where(eq(account.id, resolved.accountId))
71+
.limit(1)
5272

5373
if (!credentials.length) {
5474
logger.warn(`[${requestId}] Credential not found`, { credentialId })
5575
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
5676
}
5777

58-
const credential = credentials[0]
59-
60-
// Check if the credential belongs to the user
61-
if (credential.userId !== session.user.id) {
62-
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
63-
credentialUserId: credential.userId,
64-
requestUserId: session.user.id,
65-
})
66-
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
67-
}
78+
const accountRow = credentials[0]
6879

69-
// Refresh access token if needed
70-
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
80+
const accessToken = await refreshAccessTokenIfNeeded(
81+
resolved.accountId,
82+
accountRow.userId,
83+
requestId
84+
)
7185

7286
if (!accessToken) {
7387
logger.error(`[${requestId}] Failed to obtain valid access token`)

apps/sim/app/api/cron/renew-subscriptions/route.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,27 @@ import { createLogger } from '@sim/logger'
44
import { and, eq, or } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { verifyCronAuth } from '@/lib/auth/internal'
7-
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
7+
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
88

99
const logger = createLogger('TeamsSubscriptionRenewal')
1010

11-
async function getCredentialOwnerUserId(credentialId: string): Promise<string | null> {
11+
async function getCredentialOwner(
12+
credentialId: string
13+
): Promise<{ userId: string; accountId: string } | null> {
14+
const resolved = await resolveOAuthAccountId(credentialId)
15+
if (!resolved) {
16+
logger.error(`Failed to resolve OAuth account for credential ${credentialId}`)
17+
return null
18+
}
1219
const [credentialRecord] = await db
1320
.select({ userId: account.userId })
1421
.from(account)
15-
.where(eq(account.id, credentialId))
22+
.where(eq(account.id, resolved.accountId))
1623
.limit(1)
1724

18-
return credentialRecord?.userId ?? null
25+
return credentialRecord
26+
? { userId: credentialRecord.userId, accountId: resolved.accountId }
27+
: null
1928
}
2029

2130
/**
@@ -88,17 +97,17 @@ export async function GET(request: NextRequest) {
8897
continue
8998
}
9099

91-
const credentialOwnerUserId = await getCredentialOwnerUserId(credentialId)
92-
if (!credentialOwnerUserId) {
100+
const credentialOwner = await getCredentialOwner(credentialId)
101+
if (!credentialOwner) {
93102
logger.error(`Credential owner not found for credential ${credentialId}`)
94103
totalFailed++
95104
continue
96105
}
97106

98107
// Get fresh access token
99108
const accessToken = await refreshAccessTokenIfNeeded(
100-
credentialId,
101-
credentialOwnerUserId,
109+
credentialOwner.accountId,
110+
credentialOwner.userId,
102111
`renewal-${webhook.id}`
103112
)
104113

apps/sim/app/api/providers/route.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
66
import { checkInternalAuth } from '@/lib/auth/hybrid'
77
import { generateRequestId } from '@/lib/core/utils/request'
88
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
9-
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
9+
import { refreshTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
1010
import type { StreamingExecution } from '@/executor/types'
1111
import { executeProviderRequest } from '@/providers'
1212

@@ -360,15 +360,20 @@ function sanitizeObject(obj: any): any {
360360
async function resolveVertexCredential(requestId: string, credentialId: string): Promise<string> {
361361
logger.info(`[${requestId}] Resolving Vertex AI credential: ${credentialId}`)
362362

363+
const resolved = await resolveOAuthAccountId(credentialId)
364+
if (!resolved) {
365+
throw new Error(`Vertex AI credential not found: ${credentialId}`)
366+
}
367+
363368
const credential = await db.query.account.findFirst({
364-
where: eq(account.id, credentialId),
369+
where: eq(account.id, resolved.accountId),
365370
})
366371

367372
if (!credential) {
368373
throw new Error(`Vertex AI credential not found: ${credentialId}`)
369374
}
370375

371-
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId)
376+
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, resolved.accountId)
372377

373378
if (!accessToken) {
374379
throw new Error('Failed to get Vertex AI access token')

apps/sim/app/api/tools/gmail/label/route.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { db } from '@sim/db'
22
import { account } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { and, eq } from 'drizzle-orm'
4+
import { eq } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { getSession } from '@/lib/auth'
77
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
88
import { generateRequestId } from '@/lib/core/utils/request'
9-
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
9+
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
1010

1111
export const dynamic = 'force-dynamic'
1212

@@ -41,24 +41,45 @@ export async function GET(request: NextRequest) {
4141
return NextResponse.json({ error: labelIdValidation.error }, { status: 400 })
4242
}
4343

44+
const resolved = await resolveOAuthAccountId(credentialId)
45+
if (!resolved) {
46+
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
47+
}
48+
49+
if (resolved.workspaceId) {
50+
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
51+
const perm = await getUserEntityPermissions(
52+
session.user.id,
53+
'workspace',
54+
resolved.workspaceId
55+
)
56+
if (perm === null) {
57+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
58+
}
59+
}
60+
4461
const credentials = await db
4562
.select()
4663
.from(account)
47-
.where(and(eq(account.id, credentialId), eq(account.userId, session.user.id)))
64+
.where(eq(account.id, resolved.accountId))
4865
.limit(1)
4966

5067
if (!credentials.length) {
5168
logger.warn(`[${requestId}] Credential not found`)
5269
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
5370
}
5471

55-
const credential = credentials[0]
72+
const accountRow = credentials[0]
5673

5774
logger.info(
58-
`[${requestId}] Using credential: ${credential.id}, provider: ${credential.providerId}`
75+
`[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}`
5976
)
6077

61-
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
78+
const accessToken = await refreshAccessTokenIfNeeded(
79+
resolved.accountId,
80+
accountRow.userId,
81+
requestId
82+
)
6283

6384
if (!accessToken) {
6485
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })

apps/sim/app/api/tools/gmail/labels/route.ts

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { db } from '@sim/db'
22
import { account } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { and, eq } from 'drizzle-orm'
4+
import { eq } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { getSession } from '@/lib/auth'
77
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
88
import { generateRequestId } from '@/lib/core/utils/request'
9-
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
9+
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
1010
export const dynamic = 'force-dynamic'
1111

1212
const logger = createLogger('GmailLabelsAPI')
@@ -45,27 +45,45 @@ export async function GET(request: NextRequest) {
4545
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
4646
}
4747

48-
let credentials = await db
48+
const resolved = await resolveOAuthAccountId(credentialId)
49+
if (!resolved) {
50+
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
51+
}
52+
53+
if (resolved.workspaceId) {
54+
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
55+
const perm = await getUserEntityPermissions(
56+
session.user.id,
57+
'workspace',
58+
resolved.workspaceId
59+
)
60+
if (perm === null) {
61+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
62+
}
63+
}
64+
65+
const credentials = await db
4966
.select()
5067
.from(account)
51-
.where(and(eq(account.id, credentialId), eq(account.userId, session.user.id)))
68+
.where(eq(account.id, resolved.accountId))
5269
.limit(1)
5370

5471
if (!credentials.length) {
55-
credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
56-
if (!credentials.length) {
57-
logger.warn(`[${requestId}] Credential not found`)
58-
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
59-
}
72+
logger.warn(`[${requestId}] Credential not found`)
73+
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
6074
}
6175

62-
const credential = credentials[0]
76+
const accountRow = credentials[0]
6377

6478
logger.info(
65-
`[${requestId}] Using credential: ${credential.id}, provider: ${credential.providerId}`
79+
`[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}`
6680
)
6781

68-
const accessToken = await refreshAccessTokenIfNeeded(credentialId, credential.userId, requestId)
82+
const accessToken = await refreshAccessTokenIfNeeded(
83+
resolved.accountId,
84+
accountRow.userId,
85+
requestId
86+
)
6987

7088
if (!accessToken) {
7189
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })

0 commit comments

Comments
 (0)