feat(credentials) Add google service account support#3828
feat(credentials) Add google service account support#3828TheodoreSpeaks wants to merge 23 commits intostagingfrom
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
|
@cursor review |
|
@greptile review |
PR SummaryHigh Risk Overview Extends credential APIs and UI to create/update/delete service account credentials by pasting/uploading a JSON key, and surfaces these credentials in selectors with a new Impersonated Account field that appears only when a service account is selected. Implements JWT (RFC 7523) service-account token minting (with scope filtering and optional Written by Cursor Bugbot for commit 8b411d4. This will update automatically on new commits. Configure here. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 3 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for all 3 issues found in the latest run.
- ✅ Fixed: Impersonation field shown for non-service-account credentials
- The impersonation input now renders only when the selected credential is a service account by gating on
isServiceAccountinstead of provider-level support.
- The impersonation input now renders only when the selected credential is a service account by gating on
- ✅ Fixed: Return type mismatch:
undefinedinstead offalsehasExternalApiCredentialsnow coalesces the optional chaining result with?? falseso it always returns a boolean.
- ✅ Fixed: Serializer orphan logic is broader than intended
- The orphan serialization path was narrowed to only include the known
impersonateUserEmailkey instead of all orphan sub-blocks with values.
- The orphan serialization path was narrowed to only include the known
Or push these changes by commenting:
@cursor push 035b79165e
Preview (035b79165e)
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
--- 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
@@ -10,7 +10,6 @@
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
- getServiceAccountProviderForProviderId,
OAUTH_PROVIDERS,
type OAuthProvider,
parseProvider,
@@ -122,11 +121,6 @@
[selectedCredential]
)
- const supportsServiceAccount = useMemo(
- () => !!getServiceAccountProviderForProviderId(effectiveProviderId),
- [effectiveProviderId]
- )
-
const selectedCredentialSet = useMemo(
() => credentialSets.find((cs) => cs.id === selectedCredentialSetId),
[credentialSets, selectedCredentialSetId]
@@ -377,7 +371,7 @@
className={overlayContent ? 'pl-7' : ''}
/>
- {supportsServiceAccount && !isPreview && (
+ {isServiceAccount && !isPreview && (
<div className='mt-2.5 flex flex-col gap-2.5'>
<div className='flex items-center gap-1.5 pl-0.5'>
<Label>
diff --git a/apps/sim/lib/auth/hybrid.ts b/apps/sim/lib/auth/hybrid.ts
--- a/apps/sim/lib/auth/hybrid.ts
+++ b/apps/sim/lib/auth/hybrid.ts
@@ -25,7 +25,7 @@
export function hasExternalApiCredentials(headers: Headers): boolean {
if (headers.has(API_KEY_HEADER)) return true
const auth = headers.get('authorization')
- return auth?.startsWith(BEARER_PREFIX)
+ return auth?.startsWith(BEARER_PREFIX) ?? false
}
export interface AuthResult {
diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts
--- a/apps/sim/serializer/index.ts
+++ b/apps/sim/serializer/index.ts
@@ -347,14 +347,17 @@
)
)
- const isOrphanWithValue =
- matchingConfigs.length === 0 && subBlock.value != null && subBlock.value !== ''
+ const isImpersonateUserEmailOrphanWithValue =
+ id === 'impersonateUserEmail' &&
+ matchingConfigs.length === 0 &&
+ subBlock.value != null &&
+ subBlock.value !== ''
if (
(matchingConfigs.length > 0 && shouldInclude) ||
hasStarterInputFormatValues ||
isLegacyAgentField ||
- isOrphanWithValue
+ isImpersonateUserEmailOrphanWithValue
) {
params[id] = subBlock.value
}This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.
...omponents/editor/components/sub-block/components/credential-selector/credential-selector.tsx
Outdated
Show resolved
Hide resolved
Greptile SummaryThis PR adds Google service account (domain-wide delegation) as a first-class credential type in Sim, enabling automated workflows to access Gmail, Drive, Sheets, Calendar, and other Google Workspace APIs without per-user OAuth consent flows. Key changes:
Confidence Score: 5/5Safe to merge — all remaining findings are P2 (non-blocking improvements). The core security properties are solid: service account keys are encrypted at rest, the JWT is signed server-side with the stored private key, scopes are correctly filtered, and authorization checks gate every token-exchange path. DB schema changes are additive with proper constraints. All 13 Google blocks consistently apply the service account subblocks. The only issues found are (1) a swallowed apps/sim/app/api/auth/oauth/token/route.ts — error message from ServiceAccountTokenError is swallowed; apps/sim/app/api/auth/oauth/utils.ts — no token caching. Important Files Changed
Sequence DiagramsequenceDiagram
participant UI as Workflow UI
participant ToolExec as tools/index.ts
participant TokenAPI as /api/auth/oauth/token
participant UtilsFn as getServiceAccountToken
participant DB as Database
participant GoogleToken as Google Token Endpoint
UI->>ToolExec: execute tool (credential=SA, impersonateUserEmail=alice@co)
ToolExec->>ToolExec: build tokenPayload (scopes, impersonateEmail)
ToolExec->>TokenAPI: POST /api/auth/oauth/token
TokenAPI->>DB: resolveOAuthAccountId(credentialId)
DB-->>TokenAPI: {credentialType: 'service_account', credentialId}
TokenAPI->>TokenAPI: authorizeCredentialUse()
TokenAPI->>UtilsFn: getServiceAccountToken(credentialId, scopes, impersonateEmail)
UtilsFn->>DB: SELECT encrypted_service_account_key
DB-->>UtilsFn: encrypted key
UtilsFn->>UtilsFn: decryptSecret → JSON.parse → filter scopes
UtilsFn->>UtilsFn: createSign(RS256) → JWT assertion
UtilsFn->>GoogleToken: POST grant_type=jwt-bearer
GoogleToken-->>UtilsFn: {access_token}
UtilsFn-->>TokenAPI: accessToken
TokenAPI-->>ToolExec: {accessToken}
ToolExec->>ToolExec: call Google API with accessToken
Reviews (4): Last reviewed commit: "Fix build error" | Re-trigger Greptile |
apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx
Show resolved
Hide resolved
|
@BugBot review |
|
@greptile review |
|
@greptile review |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Reactive conditions hook only evaluates first matching subblock
- Updated
useReactiveConditionsto evaluate all subblocks withreactiveConditionand hide each one independently based on its own watched credential type.
- Updated
- ✅ Fixed: Double resolve of credential in refreshAccessTokenIfNeeded path
- Refactored account lookup into
getCredentialByAccountIdand reused the already-resolved account ID inrefreshAccessTokenIfNeededto remove the duplicate resolution query.
- Refactored account lookup into
Or push these changes by commenting:
@cursor push dafd0590a4
Preview (dafd0590a4)
diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts
--- a/apps/sim/app/api/auth/oauth/utils.test.ts
+++ b/apps/sim/app/api/auth/oauth/utils.test.ts
@@ -166,7 +166,6 @@
accountId: 'account-id',
workspaceId: 'workspace-id',
}
- const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
const mockAccountRow = {
id: 'account-id',
accessToken: 'valid-token',
@@ -176,7 +175,6 @@
userId: 'test-user-id',
}
mockSelectChain([mockResolvedCredential])
- mockSelectChain([mockCredentialRow])
mockSelectChain([mockAccountRow])
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
@@ -192,7 +190,6 @@
accountId: 'account-id',
workspaceId: 'workspace-id',
}
- const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
const mockAccountRow = {
id: 'account-id',
accessToken: 'expired-token',
@@ -202,7 +199,6 @@
userId: 'test-user-id',
}
mockSelectChain([mockResolvedCredential])
- mockSelectChain([mockCredentialRow])
mockSelectChain([mockAccountRow])
mockUpdateChain()
@@ -221,7 +217,6 @@
it('should return null if credential not found', async () => {
mockSelectChain([])
- mockSelectChain([])
const token = await refreshAccessTokenIfNeeded('nonexistent-id', 'test-user-id', 'request-id')
@@ -235,7 +230,6 @@
accountId: 'account-id',
workspaceId: 'workspace-id',
}
- const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
const mockAccountRow = {
id: 'account-id',
accessToken: 'expired-token',
@@ -245,7 +239,6 @@
userId: 'test-user-id',
}
mockSelectChain([mockResolvedCredential])
- mockSelectChain([mockCredentialRow])
mockSelectChain([mockAccountRow])
mockRefreshOAuthToken.mockResolvedValueOnce(null)
diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts
--- a/apps/sim/app/api/auth/oauth/utils.ts
+++ b/apps/sim/app/api/auth/oauth/utils.ts
@@ -231,10 +231,14 @@
return undefined
}
+ return getCredentialByAccountId(requestId, resolved.accountId, userId)
+}
+
+async function getCredentialByAccountId(requestId: string, accountId: string, userId: string) {
const credentials = await db
.select()
.from(account)
- .where(and(eq(account.id, resolved.accountId), eq(account.userId, userId)))
+ .where(and(eq(account.id, accountId), eq(account.userId, userId)))
.limit(1)
if (!credentials.length) {
@@ -244,7 +248,7 @@
return {
...credentials[0],
- resolvedCredentialId: resolved.accountId,
+ resolvedCredentialId: accountId,
}
}
@@ -365,8 +369,7 @@
return getServiceAccountToken(resolved.credentialId, scopes, impersonateEmail)
}
- // Get the credential directly using the getCredential helper
- const credential = await getCredential(requestId, credentialId, userId)
+ const credential = await getCredentialByAccountId(requestId, resolved.accountId, userId)
if (!credential) {
return null
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts
@@ -1,4 +1,5 @@
import { useCallback, useMemo } from 'react'
+import { useQueries } from '@tanstack/react-query'
import {
buildCanonicalIndex,
evaluateSubBlockCondition,
@@ -7,13 +8,18 @@
isSubBlockVisibleForMode,
} from '@/lib/workflows/subblocks/visibility'
import type { BlockConfig, SubBlockConfig, SubBlockType } from '@/blocks/types'
-import { useWorkspaceCredential } from '@/hooks/queries/credentials'
+import { type WorkspaceCredential, workspaceCredentialKeys } from '@/hooks/queries/credentials'
+import { fetchJson } from '@/hooks/selectors/helpers'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
+interface CredentialResponse {
+ credential?: WorkspaceCredential | null
+}
+
/**
* Evaluates reactive conditions for subblocks. Always calls the same hooks
* regardless of whether a reactive condition exists (Rules of Hooks).
@@ -26,45 +32,86 @@
activeWorkflowId: string | null,
blockSubBlockValues: Record<string, unknown>
): Set<string> {
- const reactiveSubBlock = useMemo(
- () => subBlocks.find((sb) => sb.reactiveCondition),
+ const reactiveSubBlocks = useMemo(
+ () => subBlocks.filter((subBlock) => subBlock.reactiveCondition),
[subBlocks]
)
- const reactiveCond = reactiveSubBlock?.reactiveCondition
// Subscribe to watched field values — always called (stable hook count)
- const watchedCredentialId = useSubBlockStore(
+ const watchedCredentialIdsBySubBlock = useSubBlockStore(
useCallback(
(state) => {
- if (!reactiveCond || !activeWorkflowId) return ''
+ if (!activeWorkflowId || reactiveSubBlocks.length === 0) return {}
const blockValues = state.workflowValues[activeWorkflowId]?.[blockId] ?? {}
const merged = { ...blockSubBlockValues, ...blockValues }
- for (const field of reactiveCond.watchFields) {
- const val = merged[field]
- if (val && typeof val === 'string') return val
- }
- return ''
+ return reactiveSubBlocks.reduce<Record<string, string>>((acc, subBlock) => {
+ const reactiveCondition = subBlock.reactiveCondition
+ if (!reactiveCondition) return acc
+
+ for (const field of reactiveCondition.watchFields) {
+ const value = merged[field]
+ if (value && typeof value === 'string') {
+ acc[subBlock.id] = value
+ break
+ }
+ }
+
+ return acc
+ }, {})
},
- [reactiveCond, activeWorkflowId, blockId, blockSubBlockValues]
+ [activeWorkflowId, blockId, blockSubBlockValues, reactiveSubBlocks]
)
)
- // Always call useWorkspaceCredential (stable hook count), disable when not needed
- const { data: credential } = useWorkspaceCredential(
- watchedCredentialId || undefined,
- Boolean(reactiveCond && watchedCredentialId)
+ const watchedCredentialIds = useMemo(
+ () => Array.from(new Set(Object.values(watchedCredentialIdsBySubBlock))),
+ [watchedCredentialIdsBySubBlock]
)
+ const credentialQueries = useQueries({
+ queries: watchedCredentialIds.map((credentialId) => ({
+ queryKey: workspaceCredentialKeys.detail(credentialId),
+ queryFn: async ({ signal }: { signal: AbortSignal }) => {
+ const data = await fetchJson<CredentialResponse>(`/api/credentials/${credentialId}`, {
+ signal,
+ })
+ return data.credential ?? null
+ },
+ enabled: Boolean(credentialId) && reactiveSubBlocks.length > 0,
+ staleTime: 60 * 1000,
+ })),
+ })
+
+ const credentialTypeById = useMemo(() => {
+ const typeById = new Map<string, WorkspaceCredential['type']>()
+ watchedCredentialIds.forEach((credentialId, index) => {
+ const credential = credentialQueries[index]?.data
+ if (credential?.type) {
+ typeById.set(credentialId, credential.type)
+ }
+ })
+ return typeById
+ }, [credentialQueries, watchedCredentialIds])
+
return useMemo(() => {
const hidden = new Set<string>()
- if (!reactiveSubBlock || !reactiveCond) return hidden
+ if (reactiveSubBlocks.length === 0) return hidden
- const conditionMet = credential?.type === reactiveCond.requiredType
- if (!conditionMet) {
- hidden.add(reactiveSubBlock.id)
+ for (const subBlock of reactiveSubBlocks) {
+ const reactiveCondition = subBlock.reactiveCondition
+ if (!reactiveCondition) continue
+
+ const watchedCredentialId = watchedCredentialIdsBySubBlock[subBlock.id]
+ const credentialType = watchedCredentialId
+ ? credentialTypeById.get(watchedCredentialId)
+ : undefined
+ if (credentialType !== reactiveCondition.requiredType) {
+ hidden.add(subBlock.id)
+ }
}
+
return hidden
- }, [reactiveSubBlock, reactiveCond, credential?.type])
+ }, [credentialTypeById, reactiveSubBlocks, watchedCredentialIdsBySubBlock])
}
/**This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.
...aceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
| } | ||
| if (requesterPerm === null) { | ||
| return { ok: false, error: 'You do not have access to this workspace.' } | ||
| } |
There was a problem hiding this comment.
Membership check before workspace permission allows info leak
Low Severity
For service account credentials, the membership check runs before the workspace permission check. If a user has a credential membership but was removed from the workspace, they get a "You do not have access to this credential" error (membership passes) followed by "You do not have access to this workspace" — but only if they lack membership. The ordering means a user who lost workspace access but retains active membership passes both checks and gets ok: true, potentially accessing the credential without proper workspace authorization.
| sub: impersonateEmail || '(none)', | ||
| scopes: filteredScopes.join(' '), | ||
| aud: tokenUri, | ||
| }) |
There was a problem hiding this comment.
Logging service account client email and scopes as info
Medium Severity
The getServiceAccountToken function logs the service account's client_email, impersonation target, and requested scopes at info level on every token request. In production, this creates a persistent record of which users are being impersonated and which service account identities are in use. This is sensitive operational data that could aid an attacker who gains access to logs. Debug-level logging would be more appropriate.



Summary
Add google service support as a integration. Allows users with admin credentials to assume roles on behalf of their google workspace users.
Created new credential type
service_account.Added documentation to sim docs.
Type of Change
Testing
Checklist
Screenshots/Videos