Directly pass subblock for impersonateUserEmail

This commit is contained in:
Theodore Li
2026-03-30 14:53:12 -07:00
parent 370148a4d8
commit ce345b9112
14 changed files with 221 additions and 36 deletions

View File

@@ -0,0 +1,153 @@
---
title: Google Workspace Delegated Accounts
description: Set up Google service accounts with domain-wide delegation for Gmail, Sheets, Drive, Calendar, and other Google services
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Step, Steps } from 'fumadocs-ui/components/steps'
import { FAQ } from '@/components/ui/faq'
Google service accounts with domain-wide delegation let your workflows access Google APIs on behalf of users in your Google Workspace domain — without requiring each user to complete an OAuth consent flow. This is ideal for automated workflows that need to send emails, read spreadsheets, or manage files across your organization.
## Prerequisites
Before adding a service account to Sim, you need to configure it in the Google Cloud Console and Google Workspace Admin Console.
### 1. Create a Service Account in Google Cloud
<Steps>
<Step>
Go to the [Google Cloud Console](https://console.cloud.google.com/) and select your project (or create one)
</Step>
<Step>
Navigate to **IAM & Admin** → **Service Accounts**
</Step>
<Step>
Click **Create Service Account**, give it a name and description, then click **Create and Continue**
</Step>
<Step>
Skip the optional role and user access steps and click **Done**
</Step>
<Step>
Click on the newly created service account, go to the **Keys** tab, and click **Add Key** → **Create new key**
</Step>
<Step>
Select **JSON** as the key type and click **Create**. A JSON key file will download — keep this safe
</Step>
</Steps>
<Callout type="warn">
The JSON key file contains your service account's private key. Treat it like a password — do not commit it to source control or share it publicly.
</Callout>
### 2. Enable the Required APIs
Enable the Google APIs your workflows will use. In the Google Cloud Console, go to **APIs & Services** → **Library** and enable the relevant APIs:
- **Gmail API** — for sending and reading emails
- **Google Sheets API** — for reading and writing spreadsheets
- **Google Drive API** — for managing files and folders
- **Google Calendar API** — for managing calendar events
- **Google Docs API** — for reading and creating documents
- **BigQuery API** — for running queries
### 3. Set Up Domain-Wide Delegation
<Steps>
<Step>
In the Google Cloud Console, go to **IAM & Admin** → **Service Accounts**, click on your service account, and copy the **Client ID** (the numeric ID, not the email)
</Step>
<Step>
Open the [Google Workspace Admin Console](https://admin.google.com/) and navigate to **Security** → **Access and data control** → **API controls**
</Step>
<Step>
Click **Manage Domain Wide Delegation**, then click **Add new**
</Step>
<Step>
Paste the **Client ID** from your service account
</Step>
<Step>
Add the OAuth scopes your workflows need. Common scopes include:
- `https://mail.google.com/` — full Gmail access
- `https://www.googleapis.com/auth/spreadsheets` — Google Sheets
- `https://www.googleapis.com/auth/drive` — Google Drive
- `https://www.googleapis.com/auth/calendar` — Google Calendar
- `https://www.googleapis.com/auth/documents` — Google Docs
- `https://www.googleapis.com/auth/bigquery` — BigQuery
</Step>
<Step>
Click **Authorize**
</Step>
</Steps>
<Callout type="info">
Domain-wide delegation must be configured by a Google Workspace admin. If you are not an admin, send the Client ID and required scopes to your admin.
</Callout>
## Adding the Service Account to Sim
Once Google Cloud and Workspace are configured, add the service account as a credential in Sim.
<Steps>
<Step>
Open your workspace **Settings** and go to the **Integrations** tab
</Step>
<Step>
Select the Google service you want to use (e.g., Gmail, Google Sheets)
</Step>
<Step>
Choose **Service Account** as the authentication method
</Step>
<Step>
Paste the full contents of your JSON key file into the text area
</Step>
<Step>
Give the credential a display name (the service account email is used by default)
</Step>
<Step>
Click **Save**
</Step>
</Steps>
The JSON key file is validated for the required fields (`type`, `client_email`, `private_key`, `project_id`) and encrypted before being stored.
## Using Delegated Access in Workflows
When you use a Google block (Gmail, Sheets, Drive, etc.) in a workflow and select a service account credential, an **Impersonate User Email** field appears below the credential selector.
Enter the email address of the Google Workspace user you want the service account to act as. For example, if you enter `alice@yourcompany.com`, the workflow will send emails from Alice's account, read her spreadsheets, or access her calendar — depending on the scopes you authorized.
<Callout type="warn">
The impersonated email must belong to a user in the Google Workspace domain where you configured domain-wide delegation. Impersonating external email addresses will fail.
</Callout>
## How It Works
When a workflow runs with a service account credential:
1. Sim creates a signed JWT using the service account's private key
2. If an impersonation email is set, it is included as the `sub` (subject) claim in the JWT
3. The JWT is exchanged with Google's OAuth2 token endpoint for a short-lived access token (1 hour)
4. The access token is used to call the Google API on behalf of the impersonated user
This flow does not require a browser-based consent screen, making it suitable for fully automated, server-side workflows.
## Service Account vs. OAuth
| | Service Account | OAuth |
|---|---|---|
| **Best for** | Automated server-side workflows | Interactive, user-initiated workflows |
| **Setup** | JSON key + Workspace admin delegation | User clicks "Connect" and authorizes |
| **User consent** | Not required | Required per user |
| **Impersonation** | Can act as any user in the domain | Acts as the connected user only |
| **Token refresh** | New tokens generated via JWT signing | Automatic refresh via refresh token |
| **Scope** | Domain-wide (all authorized users) | Single user |
<FAQ items={[
{ question: "Can I use a service account without domain-wide delegation?", answer: "Yes, but it will only be able to access resources owned by the service account itself (e.g., spreadsheets shared directly with the service account email). Without delegation, you cannot impersonate users or access their personal data like Gmail." },
{ question: "What happens if the impersonation email field is left blank?", answer: "The service account will authenticate as itself. This works for accessing shared resources (like a Google Sheet shared with the service account email) but will fail for user-specific APIs like Gmail." },
{ question: "Can I use the same service account for multiple Google services?", answer: "Yes. A single service account can be used across Gmail, Sheets, Drive, Calendar, and other Google services — as long as the required API is enabled in Google Cloud and the corresponding scopes are authorized in the Workspace admin console." },
{ question: "How do I rotate the service account key?", answer: "Create a new JSON key in the Google Cloud Console under your service account's Keys tab, then update the credential in Sim with the new key. Delete the old key from Google Cloud once the new one is working." },
{ question: "Does the impersonated user need a Google Workspace license?", answer: "Yes. Domain-wide delegation only works with users who have a Google Workspace account in the domain. Consumer Gmail accounts (e.g., @gmail.com) cannot be impersonated." },
]} />

View File

@@ -0,0 +1,8 @@
{
"title": "Credentials",
"pages": [
"index",
"google-service-account"
],
"defaultOpen": false
}

View File

@@ -13,6 +13,16 @@ import {
const logger = createLogger('OAuthUtilsAPI')
export class ServiceAccountTokenError extends Error {
constructor(
public readonly statusCode: number,
public readonly errorDescription: string
) {
super(errorDescription)
this.name = 'ServiceAccountTokenError'
}
}
interface AccountInsertData {
id: string
userId: string
@@ -162,7 +172,16 @@ export async function getServiceAccountToken(
status: response.status,
body: errorBody,
})
throw new Error(`Token exchange failed: ${response.status}`)
let description = `Token exchange failed: ${response.status}`
try {
const parsed = JSON.parse(errorBody) as { error_description?: string }
if (parsed.error_description) {
description = parsed.error_description
}
} catch {
// use default description
}
throw new ServiceAccountTokenError(response.status, description)
}
const tokenData = (await response.json()) as { access_token: string }

View File

@@ -4,7 +4,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('GoogleDriveFileAPI')
@@ -160,6 +160,10 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ file }, { status: 200 })
} catch (error) {
if (error instanceof ServiceAccountTokenError) {
logger.warn(`[${requestId}] Service account token error`, { message: error.message })
return NextResponse.json({ error: error.message }, { status: 400 })
}
logger.error(`[${requestId}] Error fetching file from Google Drive`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}

View File

@@ -4,7 +4,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('GoogleDriveFilesAPI')
@@ -178,6 +178,10 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ files }, { status: 200 })
} catch (error) {
if (error instanceof ServiceAccountTokenError) {
logger.warn(`[${requestId}] Service account token error`, { message: error.message })
return NextResponse.json({ error: error.message }, { status: 400 })
}
logger.error(`[${requestId}] Error fetching files from Google Drive`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}

View File

@@ -10,6 +10,7 @@ import {
getServiceAccountToken,
refreshAccessTokenIfNeeded,
resolveOAuthAccountId,
ServiceAccountTokenError,
} from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -143,6 +144,9 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ label: formattedLabel }, { status: 200 })
} catch (error) {
if (error instanceof ServiceAccountTokenError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
logger.error(`[${requestId}] Error fetching Gmail label:`, error)
return NextResponse.json({ error: 'Failed to fetch Gmail label' }, { status: 500 })
}

View File

@@ -10,6 +10,7 @@ import {
getServiceAccountToken,
refreshAccessTokenIfNeeded,
resolveOAuthAccountId,
ServiceAccountTokenError,
} from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -155,6 +156,9 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ labels: filteredLabels }, { status: 200 })
} catch (error) {
if (error instanceof ServiceAccountTokenError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
logger.error(`[${requestId}] Error fetching Gmail labels:`, error)
return NextResponse.json({ error: 'Failed to fetch Gmail labels' }, { status: 500 })
}

View File

@@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
const logger = createLogger('GoogleBigQueryDatasetsAPI')
@@ -93,6 +93,9 @@ export async function POST(request: Request) {
return NextResponse.json({ datasets })
} catch (error) {
if (error instanceof ServiceAccountTokenError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
logger.error('Error processing BigQuery datasets request:', error)
return NextResponse.json(
{ error: 'Failed to retrieve BigQuery datasets', details: (error as Error).message },

View File

@@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
const logger = createLogger('GoogleBigQueryTablesAPI')
@@ -87,6 +87,9 @@ export async function POST(request: Request) {
return NextResponse.json({ tables })
} catch (error) {
if (error instanceof ServiceAccountTokenError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
logger.error('Error processing BigQuery tables request:', error)
return NextResponse.json(
{ error: 'Failed to retrieve BigQuery tables', details: (error as Error).message },

View File

@@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('GoogleCalendarAPI')
@@ -101,6 +101,10 @@ export async function GET(request: NextRequest) {
})),
})
} catch (error) {
if (error instanceof ServiceAccountTokenError) {
logger.warn(`[${requestId}] Service account token error`, { message: error.message })
return NextResponse.json({ error: error.message }, { status: 400 })
}
logger.error(`[${requestId}] Error fetching Google calendars`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}

View File

@@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -117,6 +117,10 @@ export async function GET(request: NextRequest) {
})),
})
} catch (error) {
if (error instanceof ServiceAccountTokenError) {
logger.warn(`[${requestId}] Service account token error`, { message: error.message })
return NextResponse.json({ error: error.message }, { status: 400 })
}
logger.error(`[${requestId}] Error fetching Google Sheets sheets`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}

View File

@@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
const logger = createLogger('GoogleTasksTaskListsAPI')
@@ -72,6 +72,9 @@ export async function POST(request: Request) {
return NextResponse.json({ taskLists })
} catch (error) {
if (error instanceof ServiceAccountTokenError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
logger.error('Error processing Google Tasks task lists request:', error)
return NextResponse.json(
{ error: 'Failed to retrieve Google Tasks task lists', details: (error as Error).message },

View File

@@ -52,20 +52,6 @@ export const GoogleAdsBlock: BlockConfig = {
placeholder: 'Enter credential ID',
required: true,
},
{
id: 'isServiceAccount',
title: 'Is Service Account',
type: 'short-input',
hidden: true,
},
{
id: 'impersonateUserEmail',
title: 'Impersonated Account',
type: 'short-input',
placeholder: 'Email to impersonate (for service accounts)',
condition: { field: 'isServiceAccount', value: 'true' },
},
{
id: 'developerToken',
title: 'Developer Token',

View File

@@ -51,20 +51,6 @@ export const GoogleBigQueryBlock: BlockConfig = {
placeholder: 'Enter credential ID',
required: true,
},
{
id: 'isServiceAccount',
title: 'Is Service Account',
type: 'short-input',
hidden: true,
},
{
id: 'impersonateUserEmail',
title: 'Impersonated Account',
type: 'short-input',
placeholder: 'Email to impersonate (for service accounts)',
condition: { field: 'isServiceAccount', value: 'true' },
},
{
id: 'projectId',
title: 'Project ID',