Skip to content

Commit 41caf90

Browse files
committed
feat(credentials): keyless Google Cloud credentials via Workload Identity Federation
A keyless mode of the existing Google service account credential (google-service-account) for Google Cloud APIs. JSON-key credentials, their connect flow, and their token minting are unchanged. - Flow: Sim signs a short-lived token for the credential's own subject with aud = the customer's provider resource name (https://iam.googleapis.com/projects/<number>/locations/global/ workloadIdentityPools/<pool>/providers/<provider>), exchanges it at sts.googleapis.com/v1/token (token-exchange, jwt subject token, cloud-platform scope, audience //iam.googleapis.com/...), then calls iamcredentials generateAccessToken for the service account with the tool's scopes and a 1h lifetime. Impersonation rather than direct federated access: some Cloud APIs reject federated tokens, and the service account keeps the same identity and roles a JSON key uses. - Inputs validated against Google's documented formats before any request: numeric project number, pool/provider IDs (4-32 [a-z0-9-], no gcp-), and a user-managed *.iam.gserviceaccount.com email, so nothing can steer the STS audience or IAM URL. Requests never follow redirects. - Scope: refuses domain-wide delegation (an impersonated user) and any service but BigQuery and Vertex AI before reading further or contacting Google, with a new shared unsupported_use code and copy pointing to a JSON key. Workspace blocks never offer the mode. - Save check: STS exchange, impersonation, and tokeninfo (no IAM role or enabled API needed), plus a probe that a subject no credential holds cannot impersonate the service account. Subject bound to the saving user with the signed proof; kept across reconnects. - STS/IAM failures map to actionable copy (provider settings, issuer reachability, the exact workloadIdentityUser binding) without Google's descriptions or any token; runtime keeps the invalid_credentials / provider_unavailable split. Tokens cache per credential, secret version, and scopes in an LRU. - Per-credential audiences: audiences.ts gains google without a fixed audience; the setup response omits audience for it and reports a keyless reconnect. Connect dialog shows the issuer, audience, subject, and filled-in gcloud commands (pool, OIDC provider with attribute condition on the subject, IAM binding); docs section added.
1 parent e339088 commit 41caf90

24 files changed

Lines changed: 1750 additions & 80 deletions

File tree

‎apps/docs/content/docs/integrations/google-service-account.mdx‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ Google service accounts can access shared resources directly or impersonate a Go
1111

1212
For example, you could build a workflow that iterates through a list of employees, impersonates each one to read their Google Docs, and uploads the contents to a shared knowledge base — all without requiring any of those users to sign in.
1313

14+
For Google Cloud APIs such as BigQuery and Vertex AI, you can skip the JSON key entirely: see [Keyless Google Cloud access](#keyless-google-cloud-access).
15+
1416
## Prerequisites
1517

1618
Create the service account and enable its APIs in Google Cloud. Configure the Google Workspace Admin Console only when using domain-wide delegation.
@@ -172,3 +174,64 @@ Leave **Impersonated Account** blank to authenticate as the service account itse
172174
Domain-wide delegation requires a Google Workspace super administrator. It authorizes impersonation within that Workspace domain, subject to the authorized scopes and the impersonated user’s access. Consumer Gmail accounts and users outside that domain cannot be impersonated. Access also depends on the license required by the target service. See [Google’s service-account authorization guide](https://developers.google.com/identity/protocols/oauth2/service-account).
173175

174176
A credential can serve multiple supported Google services when their APIs and scopes are configured. To rotate its key, create a replacement JSON key, update the credential in Sim, verify access, then delete the old key in Google Cloud.
177+
178+
## Keyless Google Cloud access [#keyless-google-cloud-access]
179+
180+
A Google service account credential can also be **keyless**: Sim stores no key. Instead, your Google Cloud project trusts Sim through [Workload Identity Federation](https://cloud.google.com/iam/docs/workload-identity-federation-with-other-providers). On each run, Sim signs a short-lived token for this credential's own subject, exchanges it at Google's Security Token Service for a federated token, and uses that to impersonate your service account. The service account's IAM roles decide what workflows can do, exactly as with a JSON key.
181+
182+
<Callout type="info">
183+
Keyless credentials work with Google Cloud APIs that the service account calls as itself: **BigQuery**, and **Vertex AI** wherever a Google service account credential is accepted. They can't use domain-wide delegation, so Gmail, Drive, Sheets, Calendar, and other Google Workspace blocks don't offer them, and a keyless credential refuses an **Impersonated Account**. Use a JSON key for those.
184+
</Callout>
185+
186+
<Steps>
187+
<Step>
188+
In the [Google Cloud Console](https://console.cloud.google.com/), enable the IAM, Resource Manager, Service Account Credentials, and Security Token Service APIs in the project that will hold the workload identity pool, and note its **project number** (on the project's Dashboard; it's numeric, not the project ID).
189+
</Step>
190+
<Step>
191+
Create or pick a service account, and grant it the roles your workflows need (for example, BigQuery Data Viewer and BigQuery Job User, or Vertex AI User). No key is needed.
192+
</Step>
193+
<Step>
194+
In Sim, open the **Google BigQuery** integration, choose **Add service account**, and set **Authentication** to **Workload Identity Federation (keyless, via Sim OIDC)**. Enter the project number, a pool ID and provider ID (4 to 32 lowercase letters, digits, or hyphens, not starting with `gcp-`), and the service account email (`name@project-id.iam.gserviceaccount.com`).
195+
</Step>
196+
<Step>
197+
The dialog shows the **Issuer**, **Audience**, and **Subject**, and three `gcloud` commands with your values filled in. Run them in that project. They create the pool, an OIDC provider for Sim's issuer with the attribute mapping `google.subject=assertion.sub` and an attribute condition that accepts only this credential's subject, and grant that subject `roles/iam.workloadIdentityUser` on the service account:
198+
199+
```bash
200+
gcloud iam workload-identity-pools create POOL_ID \
201+
--location="global" \
202+
--display-name="Sim"
203+
204+
gcloud iam workload-identity-pools providers create-oidc PROVIDER_ID \
205+
--location="global" \
206+
--workload-identity-pool="POOL_ID" \
207+
--issuer-uri="ISSUER" \
208+
--attribute-mapping="google.subject=assertion.sub" \
209+
--attribute-condition="assertion.sub=='SUBJECT'"
210+
211+
gcloud iam service-accounts add-iam-policy-binding SERVICE_ACCOUNT_EMAIL \
212+
--role="roles/iam.workloadIdentityUser" \
213+
--member="principal://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/subject/SUBJECT"
214+
```
215+
216+
The provider sets no allowed audiences, so it accepts its own resource name, which is the **Audience** the dialog shows.
217+
</Step>
218+
<Step>
219+
Save in Sim. Sim exchanges a token at STS, impersonates the service account, and confirms Google accepts the resulting token. It also checks that a subject no credential holds can't impersonate the service account, so only this credential can use it. New IAM bindings can take a few minutes to apply; if saving fails right after you run the commands, wait and try again.
220+
</Step>
221+
</Steps>
222+
223+
<Callout type="warn">
224+
Keep the attribute condition and bind the role to the exact `principal://…/subject/SUBJECT` member. Without the condition, the provider accepts a token for any Sim subject, and a role granted to a `principalSet` for the whole pool would let other Sim credentials act with it. Sim refuses to save a service account that another subject can impersonate, but it can't see roles you grant the pool directly on other resources.
225+
</Callout>
226+
227+
Each keyless credential has its own subject; reconnecting keeps it by default so your provider and binding keep working. Use one provider per credential (a pool can hold many). Keyless credentials are available for workspace credentials, not organization credentials. Sim uses impersonation rather than direct federated access because some Google Cloud APIs don't accept federated tokens, and impersonation keeps the same identity a JSON key would use.
228+
229+
Self-hosted deployments need `WORKLOAD_IDENTITY_SIGNING_KEY` set and an `https` issuer Google can reach over the public internet. See [AWS credentials](/integrations/aws-credentials#self-hosted-deployments).
230+
231+
### Troubleshooting keyless access
232+
233+
- **"Google Cloud refused Sim's token for this workload identity provider":** the project number, pool ID, or provider ID doesn't match; the provider is disabled, has allowed audiences set, or uses a different issuer; or its attribute condition doesn't allow this credential's subject.
234+
- **"Google Cloud could not fetch the signing keys from Sim's issuer":** the issuer isn't reachable over public HTTPS (self-hosted deployments).
235+
- **"The service account … does not let this credential impersonate it":** run the `add-iam-policy-binding` command with the exact member shown, and enable the IAM Service Account Credentials API.
236+
- **"This service account also trusts other Sim credentials":** remove any `principalSet` binding for the pool from the service account and keep the attribute condition.
237+
- **A block fails with "This credential cannot be used here":** the block is a Google Workspace block or sets an **Impersonated Account**. Use a JSON key credential for it.

‎apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,7 @@ function IntegrationBlockDetailContent({ integration, workspaceId }: Integration
355355
atlassianProduct={oauthService?.providerId === 'confluence' ? 'confluence' : 'jira'}
356356
serviceName={serviceAccountTarget.serviceName}
357357
serviceIcon={serviceAccountTarget.serviceIcon}
358+
oauthServiceId={oauthService?.providerId}
358359
/>
359360
)}
360361
<div

‎apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/aws-account-modal.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ function AwsAccountForm({
147147
}
148148

149149
const trustPolicy =
150-
authType === 'oidc_role' && keyless.trust
150+
authType === 'oidc_role' && keyless.trust?.audience
151151
? buildTrustPolicy(
152152
keyless.trust.issuer,
153153
keyless.trust.audience,

‎apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx‎

Lines changed: 150 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
type ClientCredentialAccountProviderId,
2626
getClientCredentialAccountDescriptor,
2727
} from '@/lib/credentials/client-credential-accounts/descriptors'
28+
import { GOOGLE_KEYLESS_SERVICES } from '@/lib/credentials/google-workload-identity'
2829
import type { ServiceAccountSecretFields } from '@/lib/credentials/service-account-secret'
2930
import {
3031
getTokenServiceAccountDescriptor,
@@ -45,6 +46,17 @@ import {
4546
import { AwsAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/aws-account-modal'
4647
import { ClientCredentialAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal'
4748
import { DatabricksAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/databricks-account-modal'
49+
import {
50+
buildGoogleKeylessConfig,
51+
EMPTY_GOOGLE_KEYLESS_INPUT,
52+
GoogleKeylessFields,
53+
type GoogleKeylessInput,
54+
isGoogleKeylessInputComplete,
55+
} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/google-keyless-fields'
56+
import {
57+
useKeylessSetup,
58+
useSavedConnectionSubmit,
59+
} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/keyless-connection'
4860
import { MicrosoftAppAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/microsoft-app-account-modal'
4961
import { OracleDatabaseAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/oracle-database-account-modal'
5062
import { SnowflakeAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/snowflake-account-modal'
@@ -149,6 +161,11 @@ interface ConnectServiceAccountModalProps {
149161
credentialDisplayName?: string
150162
/** Existing description, used to seed reconnect-capable modals. */
151163
credentialDescription?: string
164+
/**
165+
* The OAuth service the dialog was opened for (`google-bigquery`). A Google service account
166+
* offers keyless setup only for a Google Cloud service.
167+
*/
168+
oauthServiceId?: string
152169
/** Called with the new credential id after a successful create (token-paste providers). */
153170
onCreated?: (credentialId: string) => void
154171
onSubmitCredentials?: (
@@ -184,6 +201,7 @@ export function ConnectServiceAccountModal({
184201
credentialId,
185202
credentialDisplayName,
186203
credentialDescription,
204+
oauthServiceId,
187205
onCreated,
188206
onSubmitCredentials,
189207
submitPending,
@@ -365,6 +383,7 @@ export function ConnectServiceAccountModal({
365383
credentialId={credentialId}
366384
initialDisplayName={credentialDisplayName}
367385
initialDescription={credentialDescription}
386+
oauthServiceId={oauthServiceId}
368387
onCreated={onCreated}
369388
/>
370389
)
@@ -597,12 +616,20 @@ function OciApiKeyServiceAccountModal({
597616
)
598617
}
599618

619+
interface GoogleServiceAccountModalProps extends ProviderModalProps {
620+
oauthServiceId?: string
621+
}
622+
623+
type GoogleAuthType = 'json_key' | 'workload_identity'
624+
600625
/**
601626
* Google service-account flow. Accepts the raw JSON key (paste or drag/drop)
602627
* and validates against the shared `serviceAccountJsonSchema` so the same
603-
* shape errors render here as in the server route.
628+
* shape errors render here as in the server route. For a Google Cloud service
629+
* (or a keyless credential being reconnected) on a deployment that can sign
630+
* workload-identity tokens, it also offers keyless Workload Identity Federation.
604631
*/
605-
function GoogleServiceAccountModal(props: ProviderModalProps) {
632+
function GoogleServiceAccountModal(props: GoogleServiceAccountModalProps) {
606633
if (!props.open) return null
607634
return (
608635
<GoogleServiceAccountModalForm
@@ -622,8 +649,31 @@ function GoogleServiceAccountModalForm({
622649
credentialId,
623650
initialDisplayName,
624651
initialDescription,
652+
oauthServiceId,
625653
onCreated,
626-
}: ProviderModalProps) {
654+
}: GoogleServiceAccountModalProps) {
655+
const cloudService = Boolean(oauthServiceId && GOOGLE_KEYLESS_SERVICES.has(oauthServiceId))
656+
const keyless = useKeylessSetup({
657+
provider: 'google',
658+
workspaceId,
659+
organizationId,
660+
credentialId,
661+
enabled: cloudService || Boolean(credentialId),
662+
})
663+
const keylessSubmit = useSavedConnectionSubmit({
664+
providerId: GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID,
665+
workspaceId,
666+
organizationId,
667+
credentialId,
668+
onCreated,
669+
onClose: () => onOpenChange(false),
670+
})
671+
const offerKeyless = Boolean(keyless.available) && (cloudService || keyless.reconnectsKeyless)
672+
const [selectedAuthType, setSelectedAuthType] = useState<GoogleAuthType | null>(null)
673+
const authType: GoogleAuthType = offerKeyless
674+
? (selectedAuthType ?? (keyless.reconnectsKeyless ? 'workload_identity' : 'json_key'))
675+
: 'json_key'
676+
const [keylessInput, setKeylessInput] = useState<GoogleKeylessInput>(EMPTY_GOOGLE_KEYLESS_INPUT)
627677
const [jsonInput, setJsonInput] = useState('')
628678
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null)
629679
const [displayName, setDisplayName] = useState(initialDisplayName ?? '')
@@ -712,8 +762,13 @@ function GoogleServiceAccountModalForm({
712762
}
713763
}
714764

715-
const isPending = createCredential.isPending || updateCredential.isPending
716-
const isDisabled = !jsonInput.trim() || isPending
765+
const isPending =
766+
createCredential.isPending || updateCredential.isPending || keylessSubmit.isPending
767+
const isDisabled =
768+
authType === 'workload_identity'
769+
? isPending || !keyless.ready || !isGoogleKeylessInputComplete(keylessInput)
770+
: !jsonInput.trim() || isPending
771+
const submitError = authType === 'workload_identity' ? keylessSubmit.error : error
717772

718773
return (
719774
<ChipModal
@@ -725,39 +780,84 @@ function GoogleServiceAccountModalForm({
725780
Add {serviceName} service account
726781
</ChipModalHeader>
727782
<ChipModalBody>
728-
<ChipModalField
729-
type='textarea'
730-
title='JSON key'
731-
value={jsonInput}
732-
onChange={(value) => {
733-
setJsonInput(value)
734-
if (uploadedFileName) setUploadedFileName(null)
735-
if (error) setError(null)
736-
maybeFillDisplayNameFromJson(value)
737-
}}
738-
placeholder='Paste your service account JSON key here'
739-
minHeight={120}
740-
required
741-
/>
783+
{offerKeyless && (
784+
<ChipModalField
785+
type='dropdown'
786+
title='Authentication'
787+
value={authType}
788+
onChange={(value) => {
789+
setSelectedAuthType(value as GoogleAuthType)
790+
setError(null)
791+
keylessSubmit.clearError()
792+
}}
793+
options={[
794+
{ value: 'json_key', label: 'JSON key' },
795+
{
796+
value: 'workload_identity',
797+
label: 'Workload Identity Federation (keyless, via Sim OIDC)',
798+
},
799+
]}
800+
required
801+
hint={
802+
authType === 'workload_identity'
803+
? 'No key is stored. Sim signs a short-lived token your workload identity provider trusts, then impersonates the service account. Google Cloud APIs only, without domain-wide delegation.'
804+
: undefined
805+
}
806+
/>
807+
)}
742808

743-
<ChipModalField
744-
type='file'
745-
title='Or upload a file'
746-
accept='.json'
747-
label={
748-
uploadedFileName
749-
? `Uploaded ${uploadedFileName} — click or drop to replace`
750-
: 'Drag & drop a .json file, or click to browse'
751-
}
752-
onChange={handleFileUpload}
753-
/>
809+
{authType === 'workload_identity' && (
810+
<GoogleKeylessFields
811+
input={keylessInput}
812+
onChange={(next) => {
813+
setKeylessInput(next)
814+
keylessSubmit.clearError()
815+
}}
816+
setup={keyless}
817+
/>
818+
)}
819+
820+
{authType === 'json_key' && (
821+
<>
822+
<ChipModalField
823+
type='textarea'
824+
title='JSON key'
825+
value={jsonInput}
826+
onChange={(value) => {
827+
setJsonInput(value)
828+
if (uploadedFileName) setUploadedFileName(null)
829+
if (error) setError(null)
830+
maybeFillDisplayNameFromJson(value)
831+
}}
832+
placeholder='Paste your service account JSON key here'
833+
minHeight={120}
834+
required
835+
/>
836+
837+
<ChipModalField
838+
type='file'
839+
title='Or upload a file'
840+
accept='.json'
841+
label={
842+
uploadedFileName
843+
? `Uploaded ${uploadedFileName} — click or drop to replace`
844+
: 'Drag & drop a .json file, or click to browse'
845+
}
846+
onChange={handleFileUpload}
847+
/>
848+
</>
849+
)}
754850

755851
<ChipModalField
756852
type='input'
757853
title='Display name'
758854
value={displayName}
759855
onChange={setDisplayName}
760-
placeholder='Auto-populated from client_email'
856+
placeholder={
857+
authType === 'workload_identity'
858+
? 'Defaults to the service account email'
859+
: 'Auto-populated from client_email'
860+
}
761861
autoComplete='off'
762862
/>
763863

@@ -771,7 +871,7 @@ function GoogleServiceAccountModalForm({
771871
minHeight={80}
772872
/>
773873

774-
<ChipModalError>{error}</ChipModalError>
874+
<ChipModalError>{submitError}</ChipModalError>
775875
</ChipModalBody>
776876
<ChipModalFooter
777877
onCancel={() => onOpenChange(false)}
@@ -782,8 +882,24 @@ function GoogleServiceAccountModalForm({
782882
},
783883
]}
784884
primaryAction={{
785-
label: isPending ? 'Adding...' : 'Add service account',
786-
onClick: handleSubmit,
885+
label: isPending
886+
? authType === 'workload_identity'
887+
? 'Verifying...'
888+
: 'Adding...'
889+
: 'Add service account',
890+
onClick: () => {
891+
if (authType === 'json_key') {
892+
void handleSubmit()
893+
return
894+
}
895+
if (!isDisabled) {
896+
void keylessSubmit.submit({
897+
config: buildGoogleKeylessConfig(keylessInput, keyless),
898+
displayName,
899+
description,
900+
})
901+
}
902+
},
787903
disabled: isDisabled,
788904
}}
789905
/>

‎apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/databricks-account-modal.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ function DatabricksAccountForm({
122122
}
123123

124124
const federationCommand =
125-
authType === 'oidc_federation' && keyless.trust
125+
authType === 'oidc_federation' && keyless.trust?.audience
126126
? buildFederationPolicyCommand(
127127
keyless.trust.issuer,
128128
keyless.trust.audience,

0 commit comments

Comments
 (0)