Skip to content

Feat/google service account#3828

Draft
TheodoreSpeaks wants to merge 3 commits intostagingfrom
feat/google-service-account
Draft

Feat/google service account#3828
TheodoreSpeaks wants to merge 3 commits intostagingfrom
feat/google-service-account

Conversation

@TheodoreSpeaks
Copy link
Copy Markdown
Collaborator

Summary

Add google service support as a integration. Allows users with admin credentials to assume roles on behalf of their google workspace users.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation
  • Other: ___________

Testing

How has this been tested? What should reviewers focus on?

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

Screenshots/Videos

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 28, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Mar 29, 2026 0:43am

Request Review

@TheodoreSpeaks
Copy link
Copy Markdown
Collaborator Author

@cursor review

@TheodoreSpeaks
Copy link
Copy Markdown
Collaborator Author

@greptile review

@cursor
Copy link
Copy Markdown

cursor bot commented Mar 28, 2026

PR Summary

High Risk
Adds a new credential type that stores encrypted Google service account keys and mints access tokens via JWT assertion with optional user impersonation, expanding credential access paths and security-sensitive token handling. Risk centers on key handling, authorization checks, and scope/impersonation correctness across APIs and tools.

Overview
Adds first-class service_account credentials (DB enum + encrypted_service_account_key column + uniqueness/check constraints) and exposes them through the credentials APIs, including create/update validation and workspace membership provisioning.

Extends OAuth-facing endpoints to list and authorize service-account credentials and to mint access tokens for them via a new getServiceAccountToken JWT flow, supporting caller-supplied scopes and optional impersonateEmail.

Updates UI and tooling to support adding service accounts (paste/upload JSON key), selecting them in workflows (with an impersonated-email field), and ensuring copilot/tool execution can resolve and request tokens for either OAuth or service-account credentials.

Written by Cursor Bugbot for commit 7ec0259. This will update automatically on new commits. Configure here.

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Fix All in Cursor

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 isServiceAccount instead of provider-level support.
  • ✅ Fixed: Return type mismatch: undefined instead of false
    • hasExternalApiCredentials now coalesces the optional chaining result with ?? false so it always returns a boolean.
  • ✅ Fixed: Serializer orphan logic is broader than intended
    • The orphan serialization path was narrowed to only include the known impersonateUserEmail key instead of all orphan sub-blocks with values.

Create PR

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.

disabled={effectiveDisabled}
/>
</div>
)}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Impersonation field shown for non-service-account credentials

Medium Severity

The "Impersonated Account" input field (with a required-looking asterisk) is gated on supportsServiceAccount, which checks whether the provider has a service account variant. It renders for every Google credential selector even when the user selects a regular OAuth credential. The condition likely needs to use isServiceAccount (which checks whether the selected credential is a service account) instead of supportsServiceAccount, so the field only appears when relevant.

Additional Locations (1)
Fix in Cursor Fix in Web

if (headers.has(API_KEY_HEADER)) return true
const auth = headers.get('authorization')
return auth !== null && auth.startsWith(BEARER_PREFIX)
return auth?.startsWith(BEARER_PREFIX)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Return type mismatch: undefined instead of false

Medium Severity

hasExternalApiCredentials is declared to return boolean, but auth?.startsWith(BEARER_PREFIX) evaluates to undefined (not false) when auth is null. The previous code auth !== null && auth.startsWith(BEARER_PREFIX) always returned a boolean. While undefined is falsy like false, this breaks strict equality checks and violates the function's type contract.

Fix in Cursor Fix in Web

hasStarterInputFormatValues ||
isLegacyAgentField
isLegacyAgentField ||
isOrphanWithValue
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Serializer orphan logic is broader than intended

Low Severity

The isOrphanWithValue condition serializes any sub-block that has a value but no matching config definition. This was added to pass through impersonateUserEmail, but it will also serialize any other stale or unintended sub-block values (e.g., leftover values from a block type change). A more targeted check like id === 'impersonateUserEmail' would avoid leaking unexpected parameters to tool execution.

Fix in Cursor Fix in Web

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 28, 2026

Greptile Summary

This PR introduces Google Service Account as a new credential type, enabling workspace admins to upload a JSON key file and impersonate Google Workspace users across tools (starting with Gmail). The implementation spans the full stack: a new DB enum value + column + migration, encrypted key storage, a JWT two-legged OAuth (2LO) token generation path in the API, access-control checks that mirror the existing OAuth credential membership model, and UI changes in the integrations manager and credential selector.

Key changes:

  • New service_account credential type with encrypted key storage (encrypted_service_account_key), partial unique index, and a DB CHECK constraint enforcing key/provider presence.
  • getServiceAccountToken in oauth/utils.ts manually constructs and signs a JWT (RS256) and exchanges it with Google's token endpoint — no third-party JWT library used; implementation is correct.
  • authorizeCredentialUse and the credentials GET route updated to gate service accounts behind workspace membership checks.
  • Credential selector adds an "Impersonated Account" email field, and the serializer is updated to forward the dynamically-added impersonateUserEmail sub-block value to the executor.

Issues found:

  • hybrid.ts: auth?.startsWith(BEARER_PREFIX) returns boolean | undefined — a TypeScript type error under strict mode (the function is typed boolean).
  • credential-selector.tsx: The "Impersonated Account *" field is shown whenever the provider supports service accounts (supportsServiceAccount), even when a regular OAuth credential is selected. The condition should also check isServiceAccount (credential-level).
  • serializer/index.ts: isOrphanWithValue now forwards any non-empty orphaned sub-block value to execution params, not just impersonateUserEmail — stale sub-blocks from removed configs could be silently included.
  • integrations-manager.tsx: The delete confirmation button label only checks disconnectOAuthService.isPending, so it shows "Disconnect" (not "Disconnecting…") while a service account deletion is in flight.
  • oauth.ts: Only google-gmail receives serviceAccountProviderId, so Drive, Sheets, Calendar, etc. don't benefit from service account credential matching.

Confidence Score: 4/5

Safe to merge after fixing the TypeScript type regression in hybrid.ts and the impersonation field visibility bug in credential-selector.tsx.

Two P1 issues remain: a TypeScript type error that will break builds under strict mode, and a UX/logic bug where the impersonation email field (marked required with an asterisk) appears for regular OAuth credentials. The remaining findings are P2 (label text, serializer scope, limited service coverage).

apps/sim/lib/auth/hybrid.ts and apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx require attention before merging.

Important Files Changed

Filename Overview
apps/sim/app/api/auth/oauth/utils.ts Adds getServiceAccountToken (JWT 2LO flow via Node's createSign) and updates resolveOAuthAccountId to recognise service_account credential type; core token logic is sound but no token caching is implemented.
apps/sim/lib/auth/hybrid.ts Type-narrowing regression: auth?.startsWith(BEARER_PREFIX) returns `boolean
apps/sim/lib/auth/credential-access.ts Service account authorization branch added; membership + workspace permission checks mirror the existing OAuth pattern; logic is correct.
apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx Impersonation email field is gated on supportsServiceAccount (provider-level) rather than isServiceAccount (credential-level), causing it to appear even when a regular OAuth credential is selected — misleading UX with a required-looking asterisk.
apps/sim/serializer/index.ts isOrphanWithValue broadens serialization to any sub-block with a value and no matching config — intended to carry impersonateUserEmail to the executor but may inadvertently forward stale/removed sub-block values from other blocks.
apps/sim/app/api/credentials/route.ts Service account creation validated (type, client_email, private_key, project_id), key encrypted before storage, and credential members auto-provisioned from workspace members — logic is well-structured.
packages/db/migrations/0182_cute_emma_frost.sql Adds service_account enum value, encrypted_service_account_key column, a partial unique index, and a CHECK constraint — migration is well-formed; minor: missing newline at EOF.

Sequence Diagram

sequenceDiagram
    participant UI as Credential Selector (UI)
    participant TokenAPI as /api/auth/oauth/token
    participant CredAPI as /api/credentials
    participant Utils as oauth/utils.ts
    participant Google as Google Token Endpoint

    Note over UI,Google: Service Account Setup
    UI->>CredAPI: POST /api/credentials {type:'service_account', serviceAccountJson}
    CredAPI->>CredAPI: Validate JSON (type, client_email, private_key, project_id)
    CredAPI->>CredAPI: encryptSecret(serviceAccountJson)
    CredAPI->>CredAPI: INSERT credential row + provision credentialMembers

    Note over UI,Google: Token Retrieval at Workflow Execution
    UI->>TokenAPI: POST /api/auth/oauth/token {credentialId, scopes, impersonateEmail}
    TokenAPI->>Utils: resolveOAuthAccountId(credentialId)
    Utils-->>TokenAPI: {credentialType:'service_account', credentialId}
    TokenAPI->>TokenAPI: authorizeCredentialUse (membership + workspace check)
    TokenAPI->>Utils: getServiceAccountToken(credentialId, scopes, impersonateEmail)
    Utils->>Utils: decryptSecret(encryptedServiceAccountKey)
    Utils->>Utils: Build JWT {iss, sub, scope, aud, iat, exp}
    Utils->>Utils: createSign('RSA-SHA256').sign(private_key)
    Utils->>Google: POST token_uri {grant_type: jwt-bearer, assertion: jwt}
    Google-->>Utils: {access_token}
    Utils-->>TokenAPI: access_token
    TokenAPI-->>UI: {accessToken}
Loading

Comments Outside Diff (3)

  1. apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx, line 1156-1172 (link)

    P1 Impersonation field shown for regular OAuth credentials

    supportsServiceAccount is true whenever the tool's provider supports service accounts (e.g. any Gmail tool). This means the "Impersonated Account *" field (with its required-looking asterisk) is rendered even when the user has selected a plain Google OAuth credential — not a service account. The impersonateEmail value would then be silently forwarded to the token API and applied to a regular OAuth flow where it has no effect.

    The condition should additionally require that the currently-selected credential is a service account:

  2. apps/sim/serializer/index.ts, line 1472-1483 (link)

    P2 isOrphanWithValue serializes all orphaned sub-block values, not just impersonateUserEmail

    The intent here is to allow impersonateUserEmail — which is written into the Zustand store by CredentialSelector but has no matching block-config entry — to reach the executor. However, isOrphanWithValue applies to every sub-block whose ID has no config entry and whose value is non-empty.

    This means any stale / previously-configured sub-block value that was removed from the block config will now be silently re-serialised into params during execution. Consider narrowing the condition to only the specific known dynamic keys (e.g. id === 'impersonateUserEmail') or using a dedicated allow-list so future orphan values from config drift aren't unexpectedly forwarded to tools.

  3. apps/sim/lib/oauth/oauth.ts, line 1414-1422 (link)

    P2 serviceAccountProviderId only set on Gmail — other Google Workspace services won't match service accounts

    getServiceAccountProviderForProviderId returns undefined for google-drive, google-sheets, google-calendar, google-meet, etc. This means:

    • The "Impersonated Account" field won't appear in the credential selector for those tools.
    • executeIntegrationToolDirect won't fall back to a service account credential when executing Drive/Sheets/Calendar tools via the copilot.

    If the intent is to support all Google Workspace services with service accounts, serviceAccountProviderId: 'google-service-account' needs to be added to each relevant Google provider entry (Drive, Sheets, Calendar, etc.), mirroring what was done for Gmail.

Reviews (1): Last reviewed commit: "Add gmail support for google services" | Re-trigger Greptile

Comment on lines +28 to 29
return auth?.startsWith(BEARER_PREFIX)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 TypeScript return type mismatch — boolean | undefined instead of boolean

Headers.get() returns string | null. When auth is null, the optional chain auth?.startsWith(…) evaluates to undefined, not false. The function is typed as returning boolean, so TypeScript strict mode will reject this as a type error. The original auth !== null && auth.startsWith(BEARER_PREFIX) was correct.

Suggested change
return auth?.startsWith(BEARER_PREFIX)
}
return auth !== null && auth.startsWith(BEARER_PREFIX)

Comment on lines +986 to +989
<div className='mt-1.5'>
<label className='inline-flex cursor-pointer items-center gap-1.5 text-[12px] text-[var(--text-muted)] hover:text-[var(--text-secondary)]'>
<input
type='file'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Delete confirmation button shows "Disconnecting..." while a service-account deletion is in flight

deleteCredential.isPending is already used to disable the button (good), but the label still only checks disconnectOAuthService.isPending. For service accounts the disconnect goes through deleteCredential, so the button will show "Disconnect" (not "Disconnecting…") while the request is pending.

Suggested change
<div className='mt-1.5'>
<label className='inline-flex cursor-pointer items-center gap-1.5 text-[12px] text-[var(--text-muted)] hover:text-[var(--text-secondary)]'>
<input
type='file'
{disconnectOAuthService.isPending || deleteCredential.isPending ? 'Disconnecting...' : 'Disconnect'}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant