Skip to content

Commit d23afb9

Browse files
committed
fix(credentials): block usage at execution layer without perms + fix invites
1 parent 8abe717 commit d23afb9

File tree

3 files changed

+80
-25
lines changed

3 files changed

+80
-25
lines changed

apps/sim/app/invite/[id]/invite.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ export default function Invite() {
200200
}, [searchParams, inviteId])
201201

202202
useEffect(() => {
203-
if (!session?.user || !token) return
203+
if (!session?.user) return
204204

205205
async function fetchInvitationDetails() {
206206
setIsLoading(true)
@@ -301,7 +301,7 @@ export default function Invite() {
301301
}
302302

303303
fetchInvitationDetails()
304-
}, [session?.user, inviteId, token])
304+
}, [session?.user, inviteId])
305305

306306
const handleAcceptInvitation = async () => {
307307
if (!session?.user) return

apps/sim/hooks/queries/oauth/oauth-connections.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ async function fetchOAuthConnections(signal?: AbortSignal): Promise<ServiceInfo[
107107

108108
return updatedServices
109109
} catch (error) {
110+
if (error instanceof DOMException && error.name === 'AbortError') {
111+
return defineServices()
112+
}
110113
logger.error('Error fetching OAuth connections:', error)
111114
return defineServices()
112115
}

apps/sim/lib/copilot/orchestrator/tool-executor/integration-tools.ts

Lines changed: 75 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import { db } from '@sim/db'
22
import { account } from '@sim/db/schema'
3-
import { and, eq } from 'drizzle-orm'
3+
import { createLogger } from '@sim/logger'
4+
import { eq } from 'drizzle-orm'
45
import type {
56
ExecutionContext,
67
ToolCallResult,
78
ToolCallState,
89
} from '@/lib/copilot/orchestrator/types'
910
import { isHosted } from '@/lib/core/config/feature-flags'
1011
import { generateRequestId } from '@/lib/core/utils/request'
12+
import { getCredentialActorContext } from '@/lib/credentials/access'
13+
import { getAccessibleOAuthCredentials } from '@/lib/credentials/environment'
1114
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
1215
import { getWorkflowById } from '@/lib/workflows/utils'
1316
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -16,6 +19,8 @@ import { executeTool } from '@/tools'
1619
import type { ToolConfig } from '@/tools/types'
1720
import { resolveToolId } from '@/tools/utils'
1821

22+
const logger = createLogger('CopilotIntegrationTools')
23+
1924
export async function executeIntegrationToolDirect(
2025
toolCall: ToolCallState,
2126
toolConfig: ToolConfig,
@@ -34,40 +39,86 @@ export async function executeIntegrationToolDirect(
3439
const decryptedEnvVars =
3540
context.decryptedEnvVars || (await getEffectiveDecryptedEnv(userId, workspaceId))
3641

37-
// Deep resolution walks nested objects to replace {{ENV_VAR}} references.
38-
// Safe because tool arguments originate from the LLM (not direct user input)
39-
// and env vars belong to the user themselves.
4042
const executionParams = resolveEnvVarReferences(toolArgs, decryptedEnvVars, {
4143
deep: true,
4244
}) as Record<string, unknown>
4345

44-
if (toolConfig.oauth?.required && toolConfig.oauth.provider) {
45-
const provider = toolConfig.oauth.provider
46-
const accounts = await db
47-
.select()
48-
.from(account)
49-
.where(and(eq(account.providerId, provider), eq(account.userId, userId)))
50-
.limit(1)
51-
52-
if (!accounts.length) {
46+
// If the LLM passed a credential/oauthCredential ID directly, verify the user
47+
// has active credential_member access before proceeding. This prevents
48+
// unauthorized credential usage even if the agent hallucinated or received
49+
// a credential ID the user doesn't have access to.
50+
const suppliedCredentialId = (executionParams.oauthCredential || executionParams.credential) as
51+
| string
52+
| undefined
53+
if (suppliedCredentialId) {
54+
const actorCtx = await getCredentialActorContext(suppliedCredentialId, userId)
55+
if (!actorCtx.member) {
56+
logger.warn('Blocked credential use: user lacks credential_member access', {
57+
credentialId: suppliedCredentialId,
58+
userId,
59+
toolName,
60+
})
5361
return {
5462
success: false,
55-
error: `No ${provider} account connected. Please connect your account first.`,
63+
error: `You do not have access to credential "${suppliedCredentialId}". Ask the credential admin to add you as a member, or connect your own account.`,
5664
}
5765
}
66+
}
5867

59-
const acc = accounts[0]
60-
const requestId = generateRequestId()
61-
const { accessToken } = await refreshTokenIfNeeded(requestId, acc, acc.id)
68+
if (toolConfig.oauth?.required && toolConfig.oauth.provider) {
69+
const provider = toolConfig.oauth.provider
6270

63-
if (!accessToken) {
64-
return {
65-
success: false,
66-
error: `OAuth token not available for ${provider}. Please reconnect your account.`,
71+
// If the user already supplied a credential ID that passed the check above,
72+
// skip auto-resolution and let executeTool handle it via the token endpoint.
73+
if (!suppliedCredentialId) {
74+
if (!workspaceId) {
75+
return {
76+
success: false,
77+
error: `Cannot resolve ${provider} credential without a workspace context.`,
78+
}
6779
}
68-
}
6980

70-
executionParams.accessToken = accessToken
81+
const accessibleCreds = await getAccessibleOAuthCredentials(workspaceId, userId)
82+
const match = accessibleCreds.find((c) => c.providerId === provider)
83+
84+
if (!match) {
85+
return {
86+
success: false,
87+
error: `No accessible ${provider} account found. You either don't have a ${provider} account connected in this workspace, or you don't have access to the existing one. Please connect your own account.`,
88+
}
89+
}
90+
91+
// Resolve the credential to its underlying account for token refresh
92+
const matchCtx = await getCredentialActorContext(match.id, userId)
93+
const accountId = matchCtx.credential?.accountId
94+
if (!accountId) {
95+
return {
96+
success: false,
97+
error: `OAuth account for ${provider} not found. Please reconnect your account.`,
98+
}
99+
}
100+
101+
const [acc] = await db.select().from(account).where(eq(account.id, accountId)).limit(1)
102+
103+
if (!acc) {
104+
return {
105+
success: false,
106+
error: `OAuth account for ${provider} not found. Please reconnect your account.`,
107+
}
108+
}
109+
110+
const requestId = generateRequestId()
111+
const { accessToken } = await refreshTokenIfNeeded(requestId, acc, acc.id)
112+
113+
if (!accessToken) {
114+
return {
115+
success: false,
116+
error: `OAuth token not available for ${provider}. Please reconnect your account.`,
117+
}
118+
}
119+
120+
executionParams.accessToken = accessToken
121+
}
71122
}
72123

73124
const hasHostedKeySupport = isHosted && !!toolConfig.hosting
@@ -82,6 +133,7 @@ export async function executeIntegrationToolDirect(
82133
workflowId,
83134
workspaceId,
84135
userId,
136+
enforceCredentialAccess: true,
85137
}
86138

87139
if (toolName === 'function_execute') {

0 commit comments

Comments
 (0)