Compare commits

...

28 Commits

Author SHA1 Message Date
Theodore Li
2a51c8f184 fix(migration): remove constraint on service account credential item 2026-04-02 01:18:35 -07:00
Theodore Li
fac5f63bd1 Fix bad test 2026-04-01 23:48:07 -07:00
Theodore Li
369289497e Allow watching param id and subblock ids 2026-04-01 23:36:47 -07:00
Theodore Li
b004cba88a Fix tests 2026-04-01 22:32:43 -07:00
Theodore Li
60a6745336 Fix manual credential input not showing impersonate 2026-04-01 22:21:16 -07:00
Theodore Li
8b411d4c27 Fix lint 2026-04-01 21:54:12 -07:00
Theodore Li
c6ab7962a4 Address bugbot 2026-04-01 21:53:06 -07:00
Theodore Li
8b14ba1dfe Handle tool service accounts 2026-04-01 21:31:21 -07:00
Theodore Li
c3328e0c08 Fix security message 2026-04-01 20:48:27 -07:00
Theodore Li
446de8f459 Simplify sublock values 2026-04-01 19:28:05 -07:00
Theodore Li
9e1d103ee3 Shift conditional render out of subblock 2026-04-01 19:06:52 -07:00
Theodore Li
ab50c2d246 create credentialCondition 2026-04-01 18:04:59 -07:00
Theodore Li
f7b5055dfa Fix issue with credential selector, remove bigquery and ad support 2026-04-01 15:36:26 -07:00
Theodore Li
8f0efca279 Fix documentation scopes listed for google service accounts 2026-04-01 14:42:36 -07:00
Theodore Li
76e2235427 Fix build error 2026-03-31 19:09:09 -07:00
Theodore Li
748f0dd8b8 Fix lint 2026-03-31 17:11:34 -07:00
Theodore Li
6ee02a85ad Simplify subblocks for google service account 2026-03-31 17:08:17 -07:00
Theodore Li
48c417f4b1 Remove hardcoded scopes, remove orphaned migration script 2026-03-31 16:36:19 -07:00
Theodore Li
54683d7971 Address comments 2026-03-31 16:00:41 -07:00
Theodore Li
6241ca909b Fix lint 2026-03-31 14:51:20 -07:00
Theodore Li
299998c25a Merge branch 'staging' into feat/google-service-account 2026-03-31 12:45:14 -07:00
Theodore Li
b32a3884c7 Update documentation for google service accounts 2026-03-31 12:40:33 -07:00
Theodore Li
336c3ef852 Fix lint 2026-03-30 14:53:39 -07:00
Theodore Li
ce345b9112 Directly pass subblock for impersonateUserEmail 2026-03-30 14:53:12 -07:00
Theodore Li
370148a4d8 Switch to adding subblock impersonateUserEmail conditionally 2026-03-30 13:35:50 -07:00
Theodore Li
e8717bb5c0 Refresh creds on typing in impersonated email 2026-03-28 17:43:24 -07:00
Theodore Li
7ec025973e Add gmail support for google services 2026-03-28 13:08:54 -07:00
Theodore Li
e0da2852bd feat(auth): allow google service account 2026-03-27 16:29:40 -07:00
66 changed files with 17020 additions and 184 deletions

View File

@@ -0,0 +1,206 @@
---
title: Google Service 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 { Image } from '@/components/ui/image'
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.
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.
## 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**
<div className="flex justify-center">
<Image
src="/static/credentials/gcp-create-service-account.png"
alt="Google Cloud Console — Create service account form"
width={700}
height={500}
className="my-4"
/>
</div>
</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
<div className="flex justify-center">
<Image
src="/static/credentials/gcp-create-private-key.png"
alt="Google Cloud Console — Create private key dialog with JSON selected"
width={700}
height={400}
className="my-4"
/>
</div>
</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
In the Google Cloud Console, go to **APIs & Services** → **Library** and enable the APIs for the services your workflows will use. See the [scopes reference](#scopes-reference) below for the full list of APIs by service.
### 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, then add the OAuth scopes for the services your workflows need. Copy the full scope URLs from the [scopes reference](#scopes-reference) below — only authorize scopes for services you plan to use.
<div className="flex justify-center">
<Image
src="/static/credentials/gcp-add-client-id.png"
alt="Google Workspace Admin Console — Add a new client ID with OAuth scopes"
width={350}
height={300}
className="my-4"
/>
</div>
</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>
### Scopes Reference
The table below lists every Google service that supports service account authentication in Sim, the API to enable in Google Cloud Console, and the delegation scopes to authorize. Copy the scope string for each service you need and paste it into the Google Workspace Admin Console.
<table>
<thead>
<tr>
<th className="whitespace-nowrap">Service</th>
<th className="whitespace-nowrap">API to Enable</th>
<th>Delegation Scopes</th>
</tr>
</thead>
<tbody>
<tr><td>Gmail</td><td>Gmail API</td><td><code>{'https://www.googleapis.com/auth/gmail.send'}</code><br/><code>{'https://www.googleapis.com/auth/gmail.modify'}</code><br/><code>{'https://www.googleapis.com/auth/gmail.labels'}</code></td></tr>
<tr><td>Google Sheets</td><td>Google Sheets API, Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/drive.file'}</code></td></tr>
<tr><td>Google Drive</td><td>Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/drive.file'}</code></td></tr>
<tr><td>Google Docs</td><td>Google Docs API, Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/drive.file'}</code></td></tr>
<tr><td>Google Slides</td><td>Google Slides API, Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/drive.file'}</code></td></tr>
<tr><td>Google Forms</td><td>Google Forms API, Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/forms.body'}</code><br/><code>{'https://www.googleapis.com/auth/forms.responses.readonly'}</code></td></tr>
<tr><td>Google Calendar</td><td>Google Calendar API</td><td><code>{'https://www.googleapis.com/auth/calendar'}</code></td></tr>
<tr><td>Google Contacts</td><td>People API</td><td><code>{'https://www.googleapis.com/auth/contacts'}</code></td></tr>
<tr><td>BigQuery</td><td>BigQuery API</td><td><code>{'https://www.googleapis.com/auth/bigquery'}</code></td></tr>
<tr><td>Google Tasks</td><td>Tasks API</td><td><code>{'https://www.googleapis.com/auth/tasks'}</code></td></tr>
<tr><td>Google Vault</td><td>Vault API, Cloud Storage API</td><td><code>{'https://www.googleapis.com/auth/ediscovery'}</code><br/><code>{'https://www.googleapis.com/auth/devstorage.read_only'}</code></td></tr>
<tr><td>Google Groups</td><td>Admin SDK API</td><td><code>{'https://www.googleapis.com/auth/admin.directory.group'}</code><br/><code>{'https://www.googleapis.com/auth/admin.directory.group.member'}</code></td></tr>
<tr><td>Google Meet</td><td>Google Meet API</td><td><code>{'https://www.googleapis.com/auth/meetings.space.created'}</code><br/><code>{'https://www.googleapis.com/auth/meetings.space.readonly'}</code></td></tr>
</tbody>
</table>
<Callout type="info">
You only need to enable APIs and authorize scopes for the services you plan to use. When authorizing multiple services, combine their scope strings with commas into a single entry in the Admin Console.
</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>
Search for "Google Service Account" and click **Connect**
<div className="flex justify-center">
<Image
src="/static/credentials/integrations-service-account.png"
alt="Integrations page showing Google Service Account"
width={800}
height={150}
className="my-4"
/>
</div>
</Step>
<Step>
Paste the full contents of your JSON key file into the text area
<div className="flex justify-center">
<Image
src="/static/credentials/add-service-account.png"
alt="Add Google Service Account dialog"
width={350}
height={420}
className="my-6"
/>
</div>
</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.
<div className="flex justify-center">
<Image
src="/static/credentials/workflow-impersonated-account.png"
alt="Gmail block in a workflow showing the Impersonated Account field with a service account credential"
width={800}
height={350}
className="my-4"
/>
</div>
<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>
<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,5 @@
{
"title": "Credentials",
"pages": ["index", "google-service-account"],
"defaultOpen": false
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -7,7 +7,10 @@ import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
import {
getCanonicalScopesForProvider,
getServiceAccountProviderForProviderId,
} from '@/lib/oauth/utils'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
@@ -36,7 +39,8 @@ function toCredentialResponse(
displayName: string,
providerId: string,
updatedAt: Date,
scope: string | null
scope: string | null,
credentialType: 'oauth' | 'service_account' = 'oauth'
) {
const storedScope = scope?.trim()
// Some providers (e.g. Box) don't return scopes in their token response,
@@ -52,6 +56,7 @@ function toCredentialResponse(
id,
name: displayName,
provider: providerId,
type: credentialType,
lastUsed: updatedAt.toISOString(),
isDefault: featureType === 'default',
scopes,
@@ -149,6 +154,7 @@ export async function GET(request: NextRequest) {
displayName: credential.displayName,
providerId: credential.providerId,
accountId: credential.accountId,
updatedAt: credential.updatedAt,
accountProviderId: account.providerId,
accountScope: account.scope,
accountUpdatedAt: account.updatedAt,
@@ -159,6 +165,49 @@ export async function GET(request: NextRequest) {
.limit(1)
if (platformCredential) {
if (platformCredential.type === 'service_account') {
if (
workflowId &&
(!effectiveWorkspaceId || platformCredential.workspaceId !== effectiveWorkspaceId)
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
if (!workflowId) {
const [membership] = await db
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, platformCredential.id),
eq(credentialMember.userId, requesterUserId),
eq(credentialMember.status, 'active')
)
)
.limit(1)
if (!membership) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
return NextResponse.json(
{
credentials: [
toCredentialResponse(
platformCredential.id,
platformCredential.displayName,
platformCredential.providerId || 'google-service-account',
platformCredential.updatedAt,
null,
'service_account'
),
],
},
{ status: 200 }
)
}
if (platformCredential.type !== 'oauth' || !platformCredential.accountId) {
return NextResponse.json({ credentials: [] }, { status: 200 })
}
@@ -238,14 +287,52 @@ export async function GET(request: NextRequest) {
)
)
return NextResponse.json(
{
credentials: credentialsData.map((row) =>
toCredentialResponse(row.id, row.displayName, row.providerId, row.updatedAt, row.scope)
),
},
{ status: 200 }
const results = credentialsData.map((row) =>
toCredentialResponse(row.id, row.displayName, row.providerId, row.updatedAt, row.scope)
)
const saProviderId = getServiceAccountProviderForProviderId(providerParam)
if (saProviderId) {
const serviceAccountCreds = await db
.select({
id: credential.id,
displayName: credential.displayName,
providerId: credential.providerId,
updatedAt: credential.updatedAt,
})
.from(credential)
.innerJoin(
credentialMember,
and(
eq(credentialMember.credentialId, credential.id),
eq(credentialMember.userId, requesterUserId),
eq(credentialMember.status, 'active')
)
)
.where(
and(
eq(credential.workspaceId, effectiveWorkspaceId),
eq(credential.type, 'service_account'),
eq(credential.providerId, saProviderId)
)
)
for (const sa of serviceAccountCreds) {
results.push(
toCredentialResponse(
sa.id,
sa.displayName,
sa.providerId || saProviderId,
sa.updatedAt,
null,
'service_account'
)
)
}
}
return NextResponse.json({ credentials: results }, { status: 200 })
}
return NextResponse.json({ credentials: [] }, { status: 200 })

View File

@@ -11,6 +11,8 @@ const {
mockGetCredential,
mockRefreshTokenIfNeeded,
mockGetOAuthToken,
mockResolveOAuthAccountId,
mockGetServiceAccountToken,
mockAuthorizeCredentialUse,
mockCheckSessionOrInternalAuth,
mockLogger,
@@ -29,6 +31,8 @@ const {
mockGetCredential: vi.fn(),
mockRefreshTokenIfNeeded: vi.fn(),
mockGetOAuthToken: vi.fn(),
mockResolveOAuthAccountId: vi.fn(),
mockGetServiceAccountToken: vi.fn(),
mockAuthorizeCredentialUse: vi.fn(),
mockCheckSessionOrInternalAuth: vi.fn(),
mockLogger: logger,
@@ -40,6 +44,8 @@ vi.mock('@/app/api/auth/oauth/utils', () => ({
getCredential: mockGetCredential,
refreshTokenIfNeeded: mockRefreshTokenIfNeeded,
getOAuthToken: mockGetOAuthToken,
resolveOAuthAccountId: mockResolveOAuthAccountId,
getServiceAccountToken: mockGetServiceAccountToken,
}))
vi.mock('@sim/logger', () => ({
@@ -50,6 +56,10 @@ vi.mock('@/lib/auth/credential-access', () => ({
authorizeCredentialUse: mockAuthorizeCredentialUse,
}))
vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn().mockReturnValue('test-request-id'),
}))
vi.mock('@/lib/auth/hybrid', () => ({
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
checkHybridAuth: vi.fn(),
@@ -62,6 +72,7 @@ import { GET, POST } from '@/app/api/auth/oauth/token/route'
describe('OAuth Token API Routes', () => {
beforeEach(() => {
vi.clearAllMocks()
mockResolveOAuthAccountId.mockResolvedValue(null)
})
/**

View File

@@ -4,7 +4,13 @@ import { z } from 'zod'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { getCredential, getOAuthToken, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import {
getCredential,
getOAuthToken,
getServiceAccountToken,
refreshTokenIfNeeded,
resolveOAuthAccountId,
} from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -18,6 +24,8 @@ const tokenRequestSchema = z
credentialAccountUserId: z.string().min(1).optional(),
providerId: z.string().min(1).optional(),
workflowId: z.string().min(1).nullish(),
scopes: z.array(z.string()).optional(),
impersonateEmail: z.string().email().optional(),
})
.refine(
(data) => data.credentialId || (data.credentialAccountUserId && data.providerId),
@@ -63,7 +71,14 @@ export async function POST(request: NextRequest) {
)
}
const { credentialId, credentialAccountUserId, providerId, workflowId } = parseResult.data
const {
credentialId,
credentialAccountUserId,
providerId,
workflowId,
scopes,
impersonateEmail,
} = parseResult.data
if (credentialAccountUserId && providerId) {
logger.info(`[${requestId}] Fetching token by credentialAccountUserId + providerId`, {
@@ -112,6 +127,31 @@ export async function POST(request: NextRequest) {
const callerUserId = new URL(request.url).searchParams.get('userId') || undefined
const resolved = await resolveOAuthAccountId(credentialId)
if (resolved?.credentialType === 'service_account' && resolved.credentialId) {
const authz = await authorizeCredentialUse(request, {
credentialId,
workflowId: workflowId ?? undefined,
requireWorkflowIdForInternal: false,
callerUserId,
})
if (!authz.ok) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
try {
const accessToken = await getServiceAccountToken(
resolved.credentialId,
scopes ?? [],
impersonateEmail
)
return NextResponse.json({ accessToken }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Service account token error:`, error)
return NextResponse.json({ error: 'Failed to get service account token' }, { status: 401 })
}
}
const authz = await authorizeCredentialUse(request, {
credentialId,
workflowId: workflowId ?? undefined,

View File

@@ -160,7 +160,12 @@ describe('OAuth Utils', () => {
describe('refreshAccessTokenIfNeeded', () => {
it('should return valid access token without refresh if not expired', async () => {
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
const mockResolvedCredential = {
id: 'credential-id',
type: 'oauth',
accountId: 'account-id',
workspaceId: 'workspace-id',
}
const mockAccountRow = {
id: 'account-id',
accessToken: 'valid-token',
@@ -169,7 +174,7 @@ describe('OAuth Utils', () => {
providerId: 'google',
userId: 'test-user-id',
}
mockSelectChain([mockCredentialRow])
mockSelectChain([mockResolvedCredential])
mockSelectChain([mockAccountRow])
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
@@ -179,7 +184,12 @@ describe('OAuth Utils', () => {
})
it('should refresh token when expired', async () => {
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
const mockResolvedCredential = {
id: 'credential-id',
type: 'oauth',
accountId: 'account-id',
workspaceId: 'workspace-id',
}
const mockAccountRow = {
id: 'account-id',
accessToken: 'expired-token',
@@ -188,7 +198,7 @@ describe('OAuth Utils', () => {
providerId: 'google',
userId: 'test-user-id',
}
mockSelectChain([mockCredentialRow])
mockSelectChain([mockResolvedCredential])
mockSelectChain([mockAccountRow])
mockUpdateChain()
@@ -215,7 +225,12 @@ describe('OAuth Utils', () => {
})
it('should return null if refresh fails', async () => {
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
const mockResolvedCredential = {
id: 'credential-id',
type: 'oauth',
accountId: 'account-id',
workspaceId: 'workspace-id',
}
const mockAccountRow = {
id: 'account-id',
accessToken: 'expired-token',
@@ -224,7 +239,7 @@ describe('OAuth Utils', () => {
providerId: 'google',
userId: 'test-user-id',
}
mockSelectChain([mockCredentialRow])
mockSelectChain([mockResolvedCredential])
mockSelectChain([mockAccountRow])
mockRefreshOAuthToken.mockResolvedValueOnce(null)

View File

@@ -1,7 +1,9 @@
import { createSign } from 'crypto'
import { db } from '@sim/db'
import { account, credential, credentialSetMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq, inArray } from 'drizzle-orm'
import { decryptSecret } from '@/lib/core/security/encryption'
import { refreshOAuthToken } from '@/lib/oauth'
import {
getMicrosoftRefreshTokenExpiry,
@@ -11,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
@@ -25,16 +37,26 @@ interface AccountInsertData {
accessTokenExpiresAt?: Date
}
export interface ResolvedCredential {
accountId: string
workspaceId?: string
usedCredentialTable: boolean
credentialType?: string
credentialId?: string
}
/**
* Resolves a credential ID to its underlying account ID.
* If `credentialId` matches a `credential` row, returns its `accountId` and `workspaceId`.
* For service_account credentials, returns credentialId and type instead of accountId.
* Otherwise assumes `credentialId` is already a raw `account.id` (legacy).
*/
export async function resolveOAuthAccountId(
credentialId: string
): Promise<{ accountId: string; workspaceId?: string; usedCredentialTable: boolean } | null> {
): Promise<ResolvedCredential | null> {
const [credentialRow] = await db
.select({
id: credential.id,
type: credential.type,
accountId: credential.accountId,
workspaceId: credential.workspaceId,
@@ -44,6 +66,16 @@ export async function resolveOAuthAccountId(
.limit(1)
if (credentialRow) {
if (credentialRow.type === 'service_account') {
return {
accountId: '',
credentialId: credentialRow.id,
credentialType: 'service_account',
workspaceId: credentialRow.workspaceId,
usedCredentialTable: true,
}
}
if (credentialRow.type !== 'oauth' || !credentialRow.accountId) {
return null
}
@@ -57,6 +89,124 @@ export async function resolveOAuthAccountId(
return { accountId: credentialId, usedCredentialTable: false }
}
/**
* Userinfo scopes are excluded because service accounts don't represent a user
* and cannot request user identity information. Google rejects token requests
* that include these scopes for service account credentials.
*/
const SA_EXCLUDED_SCOPES = new Set([
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
])
/**
* Generates a short-lived access token for a Google service account credential
* using the two-legged OAuth JWT flow (RFC 7523).
*
* @param impersonateEmail - Optional. Required for Google Workspace APIs (Gmail, Drive, Calendar, etc.)
* where the service account must impersonate a domain user via domain-wide delegation.
* Not needed for project-scoped APIs like BigQuery or Vertex AI where the service account
* authenticates directly with its own IAM permissions.
*/
export async function getServiceAccountToken(
credentialId: string,
scopes: string[],
impersonateEmail?: string
): Promise<string> {
const [credentialRow] = await db
.select({
encryptedServiceAccountKey: credential.encryptedServiceAccountKey,
})
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (!credentialRow?.encryptedServiceAccountKey) {
throw new Error('Service account key not found')
}
const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey)
const keyData = JSON.parse(decrypted) as {
client_email: string
private_key: string
token_uri?: string
}
const filteredScopes = scopes.filter((s) => !SA_EXCLUDED_SCOPES.has(s))
const now = Math.floor(Date.now() / 1000)
const ALLOWED_TOKEN_URIS = new Set(['https://oauth2.googleapis.com/token'])
const tokenUri =
keyData.token_uri && ALLOWED_TOKEN_URIS.has(keyData.token_uri)
? keyData.token_uri
: 'https://oauth2.googleapis.com/token'
const header = { alg: 'RS256', typ: 'JWT' }
const payload: Record<string, unknown> = {
iss: keyData.client_email,
scope: filteredScopes.join(' '),
aud: tokenUri,
iat: now,
exp: now + 3600,
}
if (impersonateEmail) {
payload.sub = impersonateEmail
}
logger.info('Service account JWT payload', {
iss: keyData.client_email,
sub: impersonateEmail || '(none)',
scopes: filteredScopes.join(' '),
aud: tokenUri,
})
const toBase64Url = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString('base64url')
const signingInput = `${toBase64Url(header)}.${toBase64Url(payload)}`
const signer = createSign('RSA-SHA256')
signer.update(signingInput)
const signature = signer.sign(keyData.private_key, 'base64url')
const jwt = `${signingInput}.${signature}`
const response = await fetch(tokenUri, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: jwt,
}),
})
if (!response.ok) {
const errorBody = await response.text()
logger.error('Service account token exchange failed', {
status: response.status,
body: errorBody,
})
let description = `Token exchange failed: ${response.status}`
try {
const parsed = JSON.parse(errorBody) as { error_description?: string }
if (parsed.error_description) {
const raw = parsed.error_description
if (raw.includes('SignatureException') || raw.includes('Invalid signature')) {
description = 'Invalid account credentials.'
} else {
description = raw
}
}
} catch {
// use default description
}
throw new ServiceAccountTokenError(response.status, description)
}
const tokenData = (await response.json()) as { access_token: string }
return tokenData.access_token
}
/**
* Safely inserts an account record, handling duplicate constraint violations gracefully.
* If a duplicate is detected (unique constraint violation), logs a warning and returns success.
@@ -81,19 +231,13 @@ export async function safeAccountInsert(
}
/**
* Get a credential by ID and verify it belongs to the user
* Get a credential by resolved account ID and verify it belongs to the user.
*/
export async function getCredential(requestId: string, credentialId: string, userId: string) {
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
logger.warn(`[${requestId}] Credential is not an OAuth credential`)
return undefined
}
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) {
@@ -103,10 +247,22 @@ export async function getCredential(requestId: string, credentialId: string, use
return {
...credentials[0],
resolvedCredentialId: resolved.accountId,
resolvedCredentialId: accountId,
}
}
/**
* Get a credential by ID and verify it belongs to the user.
*/
export async function getCredential(requestId: string, credentialId: string, userId: string) {
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
logger.warn(`[${requestId}] Credential is not an OAuth credential`)
return undefined
}
return getCredentialByAccountId(requestId, resolved.accountId, userId)
}
export async function getOAuthToken(userId: string, providerId: string): Promise<string | null> {
const connections = await db
.select({
@@ -196,19 +352,36 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
}
/**
* Refreshes an OAuth token if needed based on credential information
* Refreshes an OAuth token if needed based on credential information.
* Also handles service account credentials by generating a JWT-based token.
* @param credentialId The ID of the credential to check and potentially refresh
* @param userId The user ID who owns the credential (for security verification)
* @param requestId Request ID for log correlation
* @param scopes Optional scopes for service account token generation
* @returns The valid access token or null if refresh fails
*/
export async function refreshAccessTokenIfNeeded(
credentialId: string,
userId: string,
requestId: string
requestId: string,
scopes?: string[],
impersonateEmail?: string
): Promise<string | null> {
// Get the credential directly using the getCredential helper
const credential = await getCredential(requestId, credentialId, userId)
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return null
}
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
if (!scopes?.length) {
throw new Error('Scopes are required for service account credentials')
}
logger.info(`[${requestId}] Using service account token for credential`)
return getServiceAccountToken(resolved.credentialId, scopes, impersonateEmail)
}
// Use the already-resolved account ID to avoid a redundant resolveOAuthAccountId query
const credential = await getCredentialByAccountId(requestId, resolved.accountId, userId)
if (!credential) {
return null

View File

@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { encryptSecret } from '@/lib/core/security/encryption'
import { getCredentialActorContext } from '@/lib/credentials/access'
import {
syncPersonalEnvCredentialsForUser,
@@ -17,12 +18,19 @@ const updateCredentialSchema = z
.object({
displayName: z.string().trim().min(1).max(255).optional(),
description: z.string().trim().max(500).nullish(),
serviceAccountJson: z.string().min(1).optional(),
})
.strict()
.refine((data) => data.displayName !== undefined || data.description !== undefined, {
message: 'At least one field must be provided',
path: ['displayName'],
})
.refine(
(data) =>
data.displayName !== undefined ||
data.description !== undefined ||
data.serviceAccountJson !== undefined,
{
message: 'At least one field must be provided',
path: ['displayName'],
}
)
async function getCredentialResponse(credentialId: string, userId: string) {
const [row] = await db
@@ -106,12 +114,37 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
updates.description = parseResult.data.description ?? null
}
if (parseResult.data.displayName !== undefined && access.credential.type === 'oauth') {
if (
parseResult.data.displayName !== undefined &&
(access.credential.type === 'oauth' || access.credential.type === 'service_account')
) {
updates.displayName = parseResult.data.displayName
}
if (
parseResult.data.serviceAccountJson !== undefined &&
access.credential.type === 'service_account'
) {
let parsed: Record<string, unknown>
try {
parsed = JSON.parse(parseResult.data.serviceAccountJson)
} catch {
return NextResponse.json({ error: 'Invalid JSON format' }, { status: 400 })
}
if (
parsed.type !== 'service_account' ||
typeof parsed.client_email !== 'string' ||
typeof parsed.private_key !== 'string' ||
typeof parsed.project_id !== 'string'
) {
return NextResponse.json({ error: 'Invalid service account JSON key' }, { status: 400 })
}
const { encrypted } = await encryptSecret(parseResult.data.serviceAccountJson)
updates.encryptedServiceAccountKey = encrypted
}
if (Object.keys(updates).length === 0) {
if (access.credential.type === 'oauth') {
if (access.credential.type === 'oauth' || access.credential.type === 'service_account') {
return NextResponse.json(
{
error: 'No updatable fields provided.',
@@ -134,6 +167,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const row = await getCredentialResponse(id, session.user.id)
return NextResponse.json({ credential: row }, { status: 200 })
} catch (error) {
if (error instanceof Error && error.message.includes('unique')) {
return NextResponse.json(
{ error: 'A service account credential with this name already exists in the workspace' },
{ status: 409 }
)
}
logger.error('Failed to update credential', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}

View File

@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request'
import { getWorkspaceMemberUserIds } from '@/lib/credentials/environment'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
@@ -14,7 +15,7 @@ import { isValidEnvVarName } from '@/executor/constants'
const logger = createLogger('CredentialsAPI')
const credentialTypeSchema = z.enum(['oauth', 'env_workspace', 'env_personal'])
const credentialTypeSchema = z.enum(['oauth', 'env_workspace', 'env_personal', 'service_account'])
function normalizeEnvKeyInput(raw: string): string {
const trimmed = raw.trim()
@@ -29,6 +30,56 @@ const listCredentialsSchema = z.object({
credentialId: z.string().optional(),
})
const serviceAccountJsonSchema = z
.string()
.min(1, 'Service account JSON key is required')
.transform((val, ctx) => {
try {
const parsed = JSON.parse(val)
if (parsed.type !== 'service_account') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'JSON key must have type "service_account"',
})
return z.NEVER
}
if (!parsed.client_email || typeof parsed.client_email !== 'string') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'JSON key must contain a valid client_email',
})
return z.NEVER
}
if (!parsed.private_key || typeof parsed.private_key !== 'string') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'JSON key must contain a valid private_key',
})
return z.NEVER
}
if (!parsed.project_id || typeof parsed.project_id !== 'string') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'JSON key must contain a valid project_id',
})
return z.NEVER
}
return parsed as {
type: 'service_account'
client_email: string
private_key: string
project_id: string
[key: string]: unknown
}
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid JSON format',
})
return z.NEVER
}
})
const createCredentialSchema = z
.object({
workspaceId: z.string().uuid('Workspace ID must be a valid UUID'),
@@ -39,6 +90,7 @@ const createCredentialSchema = z
accountId: z.string().trim().min(1).optional(),
envKey: z.string().trim().min(1).optional(),
envOwnerUserId: z.string().trim().min(1).optional(),
serviceAccountJson: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.type === 'oauth') {
@@ -66,6 +118,17 @@ const createCredentialSchema = z
return
}
if (data.type === 'service_account') {
if (!data.serviceAccountJson) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'serviceAccountJson is required for service account credentials',
path: ['serviceAccountJson'],
})
}
return
}
const normalizedEnvKey = data.envKey ? normalizeEnvKeyInput(data.envKey) : ''
if (!normalizedEnvKey) {
ctx.addIssue({
@@ -87,14 +150,16 @@ const createCredentialSchema = z
interface ExistingCredentialSourceParams {
workspaceId: string
type: 'oauth' | 'env_workspace' | 'env_personal'
type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account'
accountId?: string | null
envKey?: string | null
envOwnerUserId?: string | null
displayName?: string | null
providerId?: string | null
}
async function findExistingCredentialBySource(params: ExistingCredentialSourceParams) {
const { workspaceId, type, accountId, envKey, envOwnerUserId } = params
const { workspaceId, type, accountId, envKey, envOwnerUserId, displayName, providerId } = params
if (type === 'oauth' && accountId) {
const [row] = await db
@@ -142,6 +207,22 @@ async function findExistingCredentialBySource(params: ExistingCredentialSourcePa
return row ?? null
}
if (type === 'service_account' && displayName && providerId) {
const [row] = await db
.select()
.from(credential)
.where(
and(
eq(credential.workspaceId, workspaceId),
eq(credential.type, 'service_account'),
eq(credential.providerId, providerId),
eq(credential.displayName, displayName)
)
)
.limit(1)
return row ?? null
}
return null
}
@@ -288,6 +369,7 @@ export async function POST(request: NextRequest) {
accountId,
envKey,
envOwnerUserId,
serviceAccountJson,
} = parseResult.data
const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id)
@@ -301,6 +383,7 @@ export async function POST(request: NextRequest) {
let resolvedAccountId: string | null = accountId ?? null
const resolvedEnvKey: string | null = envKey ? normalizeEnvKeyInput(envKey) : null
let resolvedEnvOwnerUserId: string | null = null
let resolvedEncryptedServiceAccountKey: string | null = null
if (type === 'oauth') {
const [accountRow] = await db
@@ -335,6 +418,33 @@ export async function POST(request: NextRequest) {
resolvedDisplayName =
getServiceConfigByProviderId(accountRow.providerId)?.name || accountRow.providerId
}
} else if (type === 'service_account') {
if (!serviceAccountJson) {
return NextResponse.json(
{ error: 'serviceAccountJson is required for service account credentials' },
{ status: 400 }
)
}
const jsonParseResult = serviceAccountJsonSchema.safeParse(serviceAccountJson)
if (!jsonParseResult.success) {
return NextResponse.json(
{ error: jsonParseResult.error.errors[0]?.message || 'Invalid service account JSON' },
{ status: 400 }
)
}
const parsed = jsonParseResult.data
resolvedProviderId = 'google-service-account'
resolvedAccountId = null
resolvedEnvOwnerUserId = null
if (!resolvedDisplayName) {
resolvedDisplayName = parsed.client_email
}
const { encrypted } = await encryptSecret(serviceAccountJson)
resolvedEncryptedServiceAccountKey = encrypted
} else if (type === 'env_personal') {
resolvedEnvOwnerUserId = envOwnerUserId ?? session.user.id
if (resolvedEnvOwnerUserId !== session.user.id) {
@@ -363,6 +473,8 @@ export async function POST(request: NextRequest) {
accountId: resolvedAccountId,
envKey: resolvedEnvKey,
envOwnerUserId: resolvedEnvOwnerUserId,
displayName: resolvedDisplayName,
providerId: resolvedProviderId,
})
if (existingCredential) {
@@ -441,12 +553,13 @@ export async function POST(request: NextRequest) {
accountId: resolvedAccountId,
envKey: resolvedEnvKey,
envOwnerUserId: resolvedEnvOwnerUserId,
encryptedServiceAccountKey: resolvedEncryptedServiceAccountKey,
createdBy: session.user.id,
createdAt: now,
updatedAt: now,
})
if (type === 'env_workspace' && workspaceRow?.ownerId) {
if ((type === 'env_workspace' || type === 'service_account') && workspaceRow?.ownerId) {
const workspaceUserIds = await getWorkspaceMemberUserIds(workspaceId)
if (workspaceUserIds.length > 0) {
for (const memberUserId of workspaceUserIds) {

View File

@@ -6,7 +6,11 @@ import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
import { refreshTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import {
getServiceAccountToken,
refreshTokenIfNeeded,
resolveOAuthAccountId,
} from '@/app/api/auth/oauth/utils'
import type { StreamingExecution } from '@/executor/types'
import { executeProviderRequest } from '@/providers'
@@ -365,6 +369,14 @@ async function resolveVertexCredential(requestId: string, credentialId: string):
throw new Error(`Vertex AI credential not found: ${credentialId}`)
}
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
const accessToken = await getServiceAccountToken(resolved.credentialId, [
'https://www.googleapis.com/auth/cloud-platform',
])
logger.info(`[${requestId}] Successfully resolved Vertex AI service account credential`)
return accessToken
}
const credential = await db.query.account.findFirst({
where: eq(account.id, resolved.accountId),
})

View File

@@ -4,7 +4,8 @@ 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 { getScopesForService } from '@/lib/oauth/utils'
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('GoogleDriveFileAPI')
@@ -26,6 +27,7 @@ export async function GET(request: NextRequest) {
const credentialId = searchParams.get('credentialId')
const fileId = searchParams.get('fileId')
const workflowId = searchParams.get('workflowId') || undefined
const impersonateEmail = searchParams.get('impersonateEmail') || undefined
if (!credentialId || !fileId) {
logger.warn(`[${requestId}] Missing required parameters`)
@@ -46,7 +48,9 @@ export async function GET(request: NextRequest) {
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
authz.credentialOwnerUserId,
requestId
requestId,
getScopesForService('google-drive'),
impersonateEmail
)
if (!accessToken) {
@@ -157,6 +161,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,8 @@ 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 { getScopesForService } from '@/lib/oauth/utils'
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('GoogleDriveFilesAPI')
@@ -85,6 +86,7 @@ export async function GET(request: NextRequest) {
const query = searchParams.get('query') || ''
const folderId = searchParams.get('folderId') || searchParams.get('parentId') || ''
const workflowId = searchParams.get('workflowId') || undefined
const impersonateEmail = searchParams.get('impersonateEmail') || undefined
if (!credentialId) {
logger.warn(`[${requestId}] Missing credential ID`)
@@ -100,7 +102,9 @@ export async function GET(request: NextRequest) {
const accessToken = await refreshAccessTokenIfNeeded(
credentialId!,
authz.credentialOwnerUserId,
requestId
requestId,
getScopesForService('google-drive'),
impersonateEmail
)
if (!accessToken) {
@@ -175,6 +179,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

@@ -6,7 +6,13 @@ import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { getScopesForService } from '@/lib/oauth/utils'
import {
getServiceAccountToken,
refreshAccessTokenIfNeeded,
resolveOAuthAccountId,
ServiceAccountTokenError,
} from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -26,6 +32,7 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const labelId = searchParams.get('labelId')
const impersonateEmail = searchParams.get('impersonateEmail') || undefined
if (!credentialId || !labelId) {
logger.warn(`[${requestId}] Missing required parameters`)
@@ -58,29 +65,40 @@ export async function GET(request: NextRequest) {
}
}
const credentials = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
let accessToken: string | null = null
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`)
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
accessToken = await getServiceAccountToken(
resolved.credentialId,
getScopesForService('gmail'),
impersonateEmail
)
} else {
const credentials = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`)
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const accountRow = credentials[0]
logger.info(
`[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}`
)
accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId,
getScopesForService('gmail')
)
}
const accountRow = credentials[0]
logger.info(
`[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}`
)
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
@@ -127,6 +145,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

@@ -6,7 +6,13 @@ import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { getScopesForService } from '@/lib/oauth/utils'
import {
getServiceAccountToken,
refreshAccessTokenIfNeeded,
resolveOAuthAccountId,
ServiceAccountTokenError,
} from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('GmailLabelsAPI')
@@ -33,6 +39,7 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const query = searchParams.get('query')
const impersonateEmail = searchParams.get('impersonateEmail') || undefined
if (!credentialId) {
logger.warn(`[${requestId}] Missing credentialId parameter`)
@@ -62,29 +69,40 @@ export async function GET(request: NextRequest) {
}
}
const credentials = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
let accessToken: string | null = null
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`)
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
accessToken = await getServiceAccountToken(
resolved.credentialId,
getScopesForService('gmail'),
impersonateEmail
)
} else {
const credentials = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`)
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const accountRow = credentials[0]
logger.info(
`[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}`
)
accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId,
getScopesForService('gmail')
)
}
const accountRow = credentials[0]
logger.info(
`[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}`
)
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
@@ -139,6 +157,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,8 @@ 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 { getScopesForService } from '@/lib/oauth/utils'
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
const logger = createLogger('GoogleBigQueryDatasetsAPI')
@@ -20,7 +21,7 @@ export async function POST(request: Request) {
const requestId = generateRequestId()
try {
const body = await request.json()
const { credential, workflowId, projectId } = body
const { credential, workflowId, projectId, impersonateEmail } = body
if (!credential) {
logger.error('Missing credential in request')
@@ -43,7 +44,9 @@ export async function POST(request: Request) {
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
requestId,
getScopesForService('google-bigquery'),
impersonateEmail
)
if (!accessToken) {
logger.error('Failed to get access token', {
@@ -91,6 +94,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,8 @@ 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 { getScopesForService } from '@/lib/oauth/utils'
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
const logger = createLogger('GoogleBigQueryTablesAPI')
@@ -12,7 +13,7 @@ export async function POST(request: Request) {
const requestId = generateRequestId()
try {
const body = await request.json()
const { credential, workflowId, projectId, datasetId } = body
const { credential, workflowId, projectId, datasetId, impersonateEmail } = body
if (!credential) {
logger.error('Missing credential in request')
@@ -40,7 +41,9 @@ export async function POST(request: Request) {
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
requestId,
getScopesForService('google-bigquery'),
impersonateEmail
)
if (!accessToken) {
logger.error('Failed to get access token', {
@@ -85,6 +88,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,8 @@ 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 { getScopesForService } from '@/lib/oauth/utils'
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('GoogleCalendarAPI')
@@ -28,6 +29,7 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const workflowId = searchParams.get('workflowId') || undefined
const impersonateEmail = searchParams.get('impersonateEmail') || undefined
if (!credentialId) {
logger.warn(`[${requestId}] Missing credentialId parameter`)
@@ -41,7 +43,9 @@ export async function GET(request: NextRequest) {
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
authz.credentialOwnerUserId,
requestId
requestId,
getScopesForService('google-calendar'),
impersonateEmail
)
if (!accessToken) {
@@ -98,6 +102,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,8 @@ 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 { getScopesForService } from '@/lib/oauth/utils'
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -40,6 +41,7 @@ export async function GET(request: NextRequest) {
const credentialId = searchParams.get('credentialId')
const spreadsheetId = searchParams.get('spreadsheetId')
const workflowId = searchParams.get('workflowId') || undefined
const impersonateEmail = searchParams.get('impersonateEmail') || undefined
if (!credentialId) {
logger.warn(`[${requestId}] Missing credentialId parameter`)
@@ -59,7 +61,9 @@ export async function GET(request: NextRequest) {
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
authz.credentialOwnerUserId,
requestId
requestId,
getScopesForService('google-sheets'),
impersonateEmail
)
if (!accessToken) {
@@ -114,6 +118,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,8 @@ 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 { getScopesForService } from '@/lib/oauth/utils'
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
const logger = createLogger('GoogleTasksTaskListsAPI')
@@ -12,7 +13,7 @@ export async function POST(request: Request) {
const requestId = generateRequestId()
try {
const body = await request.json()
const { credential, workflowId } = body
const { credential, workflowId, impersonateEmail } = body
if (!credential) {
logger.error('Missing credential in request')
@@ -30,7 +31,9 @@ export async function POST(request: Request) {
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
requestId,
getScopesForService('google-tasks'),
impersonateEmail
)
if (!accessToken) {
logger.error('Failed to get access token', {
@@ -70,6 +73,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

@@ -23,6 +23,7 @@ import {
} from '@/components/emcn'
import { Input as UiInput } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import {
clearPendingCredentialCreateRequest,
PENDING_CREDENTIAL_CREATE_REQUEST_EVENT,
@@ -91,6 +92,13 @@ export function IntegrationsManager() {
| { type: 'kb-connectors'; knowledgeBaseId: string }
| undefined
>(undefined)
const [saJsonInput, setSaJsonInput] = useState('')
const [saDisplayName, setSaDisplayName] = useState('')
const [saDescription, setSaDescription] = useState('')
const [saError, setSaError] = useState<string | null>(null)
const [saIsSubmitting, setSaIsSubmitting] = useState(false)
const [saDragActive, setSaDragActive] = useState(false)
const { data: session } = useSession()
const currentUserId = session?.user?.id || ''
@@ -110,7 +118,7 @@ export function IntegrationsManager() {
const { data: workspacePermissions } = useWorkspacePermissionsQuery(workspaceId || null)
const oauthCredentials = useMemo(
() => credentials.filter((c) => c.type === 'oauth'),
() => credentials.filter((c) => c.type === 'oauth' || c.type === 'service_account'),
[credentials]
)
@@ -348,11 +356,7 @@ export function IntegrationsManager() {
const isSelectedAdmin = selectedCredential?.role === 'admin'
const selectedOAuthServiceConfig = useMemo(() => {
if (
!selectedCredential ||
selectedCredential.type !== 'oauth' ||
!selectedCredential.providerId
) {
if (!selectedCredential?.providerId) {
return null
}
@@ -366,6 +370,10 @@ export function IntegrationsManager() {
setCreateError(null)
setCreateStep(1)
setServiceSearch('')
setSaJsonInput('')
setSaDisplayName('')
setSaDescription('')
setSaError(null)
pendingReturnOriginRef.current = undefined
}
@@ -456,25 +464,30 @@ export function IntegrationsManager() {
setDeleteError(null)
try {
if (!credentialToDelete.accountId || !credentialToDelete.providerId) {
const errorMessage =
'Cannot disconnect: missing account information. Please try reconnecting this credential first.'
setDeleteError(errorMessage)
logger.error('Cannot disconnect OAuth credential: missing accountId or providerId')
return
}
await disconnectOAuthService.mutateAsync({
provider: credentialToDelete.providerId.split('-')[0] || credentialToDelete.providerId,
providerId: credentialToDelete.providerId,
serviceId: credentialToDelete.providerId,
accountId: credentialToDelete.accountId,
})
await refetchCredentials()
window.dispatchEvent(
new CustomEvent('oauth-credentials-updated', {
detail: { providerId: credentialToDelete.providerId, workspaceId },
if (credentialToDelete.type === 'service_account') {
await deleteCredential.mutateAsync(credentialToDelete.id)
await refetchCredentials()
} else {
if (!credentialToDelete.accountId || !credentialToDelete.providerId) {
const errorMessage =
'Cannot disconnect: missing account information. Please try reconnecting this credential first.'
setDeleteError(errorMessage)
logger.error('Cannot disconnect OAuth credential: missing accountId or providerId')
return
}
await disconnectOAuthService.mutateAsync({
provider: credentialToDelete.providerId.split('-')[0] || credentialToDelete.providerId,
providerId: credentialToDelete.providerId,
serviceId: credentialToDelete.providerId,
accountId: credentialToDelete.accountId,
})
)
await refetchCredentials()
window.dispatchEvent(
new CustomEvent('oauth-credentials-updated', {
detail: { providerId: credentialToDelete.providerId, workspaceId },
})
)
}
if (selectedCredentialId === credentialToDelete.id) {
setSelectedCredentialId(null)
@@ -624,6 +637,117 @@ export function IntegrationsManager() {
setShowCreateModal(true)
}, [])
const validateServiceAccountJson = (raw: string): { valid: boolean; error?: string } => {
let parsed: Record<string, unknown>
try {
parsed = JSON.parse(raw)
} catch {
return { valid: false, error: 'Invalid JSON. Paste the full service account key file.' }
}
if (parsed.type !== 'service_account') {
return { valid: false, error: 'JSON key must have "type": "service_account".' }
}
if (!parsed.client_email || typeof parsed.client_email !== 'string') {
return { valid: false, error: 'Missing "client_email" field.' }
}
if (!parsed.private_key || typeof parsed.private_key !== 'string') {
return { valid: false, error: 'Missing "private_key" field.' }
}
if (!parsed.project_id || typeof parsed.project_id !== 'string') {
return { valid: false, error: 'Missing "project_id" field.' }
}
return { valid: true }
}
const handleCreateServiceAccount = async () => {
setSaError(null)
const trimmed = saJsonInput.trim()
if (!trimmed) {
setSaError('Paste the service account JSON key.')
return
}
const validation = validateServiceAccountJson(trimmed)
if (!validation.valid) {
setSaError(validation.error ?? 'Invalid JSON')
return
}
setSaIsSubmitting(true)
try {
await createCredential.mutateAsync({
workspaceId,
type: 'service_account',
displayName: saDisplayName.trim() || undefined,
description: saDescription.trim() || undefined,
serviceAccountJson: trimmed,
})
setShowCreateModal(false)
resetCreateForm()
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to add service account'
setSaError(message)
logger.error('Failed to create service account credential', error)
} finally {
setSaIsSubmitting(false)
}
}
const readSaJsonFile = useCallback(
(file: File) => {
if (!file.name.endsWith('.json')) {
setSaError('Only .json files are supported')
return
}
const reader = new FileReader()
reader.onload = (e) => {
const text = e.target?.result
if (typeof text === 'string') {
setSaJsonInput(text)
setSaError(null)
try {
const parsed = JSON.parse(text)
if (parsed.client_email && !saDisplayName.trim()) {
setSaDisplayName(parsed.client_email)
}
} catch {
// validation will catch this on submit
}
}
}
reader.readAsText(file)
},
[saDisplayName]
)
const handleSaFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
readSaJsonFile(file)
event.target.value = ''
}
const handleSaDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault()
event.stopPropagation()
setSaDragActive(true)
}, [])
const handleSaDragLeave = useCallback((event: React.DragEvent) => {
event.preventDefault()
event.stopPropagation()
setSaDragActive(false)
}, [])
const handleSaDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault()
event.stopPropagation()
setSaDragActive(false)
const file = event.dataTransfer.files[0]
if (file) readSaJsonFile(file)
},
[readSaJsonFile]
)
const filteredServices = useMemo(() => {
if (!serviceSearch.trim()) return oauthServiceOptions
const q = serviceSearch.toLowerCase()
@@ -700,7 +824,7 @@ export function IntegrationsManager() {
</Button>
</ModalFooter>
</>
) : (
) : selectedOAuthService?.authType !== 'service_account' ? (
<>
<ModalHeader>
<div className='flex items-center gap-2.5'>
@@ -827,6 +951,160 @@ export function IntegrationsManager() {
</Button>
</ModalFooter>
</>
) : (
<>
<ModalHeader>
<div className='flex items-center gap-2.5'>
<button
type='button'
onClick={() => {
setCreateStep(1)
setSaError(null)
}}
className='flex h-6 w-6 items-center justify-center rounded-[4px] text-[var(--text-muted)] hover:bg-[var(--surface-5)] hover:text-[var(--text-primary)]'
aria-label='Back'
>
</button>
<span>
Add {selectedOAuthService?.name || resolveProviderLabel(createOAuthProviderId)}
</span>
</div>
</ModalHeader>
<ModalBody>
{saError && (
<div className='mb-3'>
<Badge variant='red' size='lg' dot className='max-w-full'>
{saError}
</Badge>
</div>
)}
<div className='flex flex-col gap-4'>
<div className='flex items-center gap-3'>
<div className='flex h-[40px] w-[40px] flex-shrink-0 items-center justify-center rounded-[8px] bg-[var(--surface-5)]'>
{selectedOAuthService &&
createElement(selectedOAuthService.icon, { className: 'h-[18px] w-[18px]' })}
</div>
<div>
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
Add {selectedOAuthService?.name || 'service account'}
</p>
<p className='text-[12px] text-[var(--text-tertiary)]'>
{selectedOAuthService?.description || 'Paste or upload the JSON key file'}
</p>
<a
href='https://docs.sim.ai/credentials/google-service-account'
target='_blank'
rel='noopener noreferrer'
className='text-[12px] text-[var(--accent)] hover:underline'
>
View setup guide
</a>
</div>
</div>
<div>
<Label>
JSON Key<span className='ml-1'>*</span>
</Label>
<div
onDragOver={handleSaDragOver}
onDragLeave={handleSaDragLeave}
onDrop={handleSaDrop}
className={cn(
'relative mt-1.5 rounded-md border-2 border-dashed transition-colors',
saDragActive
? 'border-[var(--accent)] bg-[var(--accent)]/5'
: 'border-transparent'
)}
>
{saDragActive && (
<div className='pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-md bg-[var(--accent)]/5'>
<p className='font-medium text-[13px] text-[var(--accent)]'>
Drop JSON key file here
</p>
</div>
)}
<Textarea
value={saJsonInput}
onChange={(event) => {
setSaJsonInput(event.target.value)
setSaError(null)
if (!saDisplayName.trim()) {
try {
const parsed = JSON.parse(event.target.value)
if (parsed.client_email) setSaDisplayName(parsed.client_email)
} catch {
// not valid yet
}
}
}}
placeholder='Paste your service account JSON key here or drag & drop a .json file...'
autoComplete='off'
data-lpignore='true'
className={cn(
'min-h-[120px] resize-none border-0 font-mono text-[12px]',
saDragActive && 'opacity-30'
)}
autoFocus
/>
</div>
<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'
accept='.json'
onChange={handleSaFileUpload}
className='hidden'
/>
Or upload a .json file
</label>
</div>
</div>
<div>
<Label>Display name</Label>
<Input
value={saDisplayName}
onChange={(event) => setSaDisplayName(event.target.value)}
placeholder='Auto-populated from client_email'
autoComplete='off'
data-lpignore='true'
className='mt-1.5'
/>
</div>
<div>
<Label>Description</Label>
<Textarea
value={saDescription}
onChange={(event) => setSaDescription(event.target.value)}
placeholder='Optional description'
maxLength={500}
autoComplete='off'
data-lpignore='true'
className='mt-1.5 min-h-[80px] resize-none'
/>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button
variant='default'
onClick={() => {
setCreateStep(1)
setSaError(null)
}}
>
Back
</Button>
<Button
variant='primary'
onClick={handleCreateServiceAccount}
disabled={!saJsonInput.trim() || saIsSubmitting}
>
{saIsSubmitting ? 'Adding...' : 'Add Service Account'}
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
@@ -869,9 +1147,11 @@ export function IntegrationsManager() {
<Button
variant='destructive'
onClick={handleConfirmDelete}
disabled={disconnectOAuthService.isPending}
disabled={disconnectOAuthService.isPending || deleteCredential.isPending}
>
{disconnectOAuthService.isPending ? 'Disconnecting...' : 'Disconnect'}
{disconnectOAuthService.isPending || deleteCredential.isPending
? 'Disconnecting...'
: 'Disconnect'}
</Button>
</ModalFooter>
</ModalContent>
@@ -920,10 +1200,14 @@ export function IntegrationsManager() {
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-2'>
<p className='truncate font-medium text-[var(--text-primary)] text-base'>
{resolveProviderLabel(selectedCredential.providerId) || 'Unknown service'}
{selectedOAuthServiceConfig?.name ||
resolveProviderLabel(selectedCredential.providerId) ||
'Unknown service'}
</p>
<Badge variant='gray-secondary' size='sm'>
oauth
{selectedOAuthServiceConfig?.authType === 'service_account'
? 'service account'
: 'oauth'}
</Badge>
{selectedCredential.role && (
<Badge variant='gray-secondary' size='sm'>
@@ -931,7 +1215,9 @@ export function IntegrationsManager() {
</Badge>
)}
</div>
<p className='text-[var(--text-muted)] text-small'>Connected service</p>
<p className='text-[var(--text-muted)] text-small'>
{selectedOAuthServiceConfig?.description || 'Connected service'}
</p>
</div>
</div>
@@ -1116,15 +1402,17 @@ export function IntegrationsManager() {
<div className='flex items-center gap-2'>
{isSelectedAdmin && (
<>
<Button
variant='default'
onClick={handleReconnectOAuth}
disabled={connectOAuthService.isPending}
>
{`Reconnect to ${
resolveProviderLabel(selectedCredential.providerId) || 'service'
}`}
</Button>
{selectedOAuthServiceConfig?.authType !== 'service_account' && (
<Button
variant='default'
onClick={handleReconnectOAuth}
disabled={connectOAuthService.isPending}
>
{`Reconnect to ${
resolveProviderLabel(selectedCredential.providerId) || 'service'
}`}
</Button>
)}
{(workspaceUserOptions.length > 0 || isShareingWithWorkspace) && (
<Button
variant='default'
@@ -1138,7 +1426,7 @@ export function IntegrationsManager() {
<Button
variant='ghost'
onClick={() => handleDeleteClick(selectedCredential)}
disabled={disconnectOAuthService.isPending}
disabled={disconnectOAuthService.isPending || deleteCredential.isPending}
>
Disconnect
</Button>
@@ -1234,7 +1522,11 @@ export function IntegrationsManager() {
<Button
variant='ghost'
onClick={() => handleDeleteClick(credential)}
disabled={disconnectOAuthService.isPending}
disabled={
credential.type === 'service_account'
? deleteCredential.isPending
: disconnectOAuthService.isPending
}
>
Disconnect
</Button>

View File

@@ -98,8 +98,10 @@ export function CredentialSelector({
)
const provider = effectiveProviderId
const isTriggerMode = subBlock.mode === 'trigger'
const {
data: credentials = [],
data: rawCredentials = [],
isFetching: credentialsLoading,
refetch: refetchCredentials,
} = useOAuthCredentials(effectiveProviderId, {
@@ -108,11 +110,24 @@ export function CredentialSelector({
workflowId: activeWorkflowId || undefined,
})
const credentials = useMemo(
() =>
isTriggerMode
? rawCredentials.filter((cred) => cred.type !== 'service_account')
: rawCredentials,
[rawCredentials, isTriggerMode]
)
const selectedCredential = useMemo(
() => credentials.find((cred) => cred.id === selectedId),
[credentials, selectedId]
)
const isServiceAccount = useMemo(
() => selectedCredential?.type === 'service_account',
[selectedCredential]
)
const selectedCredentialSet = useMemo(
() => credentialSets.find((cs) => cs.id === selectedCredentialSetId),
[credentialSets, selectedCredentialSetId]
@@ -151,6 +166,7 @@ export function CredentialSelector({
const needsUpdate =
hasSelection &&
!isServiceAccount &&
missingRequiredScopes.length > 0 &&
!effectiveDisabled &&
!isPreview &&
@@ -230,6 +246,7 @@ export function CredentialSelector({
const credentialItems = credentials.map((cred) => ({
label: cred.name,
value: cred.id,
iconElement: getProviderIcon((cred.provider ?? provider) as OAuthProvider),
}))
credentialItems.push({
label:
@@ -237,6 +254,7 @@ export function CredentialSelector({
? `Connect another ${getProviderName(provider)} account`
: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
iconElement: <ExternalLink className='h-3 w-3' />,
})
groups.push({
@@ -250,6 +268,7 @@ export function CredentialSelector({
const options = credentials.map((cred) => ({
label: cred.name,
value: cred.id,
iconElement: getProviderIcon((cred.provider ?? provider) as OAuthProvider),
}))
options.push({
@@ -258,6 +277,7 @@ export function CredentialSelector({
? `Connect another ${getProviderName(provider)} account`
: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
iconElement: <ExternalLink className='h-3 w-3' />,
})
return { comboboxOptions: options, comboboxGroups: undefined }
@@ -265,6 +285,7 @@ export function CredentialSelector({
credentials,
provider,
effectiveProviderId,
getProviderIcon,
getProviderName,
canUseCredentialSets,
credentialSets,
@@ -300,6 +321,7 @@ export function CredentialSelector({
selectedCredentialProvider,
isCredentialSetSelected,
selectedCredentialSet,
isServiceAccount,
])
const handleComboboxChange = useCallback(

View File

@@ -51,6 +51,7 @@ import { getAllBlocks } from '@/blocks'
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
import { BUILT_IN_TOOL_TYPES } from '@/blocks/utils'
import { useMcpTools } from '@/hooks/mcp/use-mcp-tools'
import { useWorkspaceCredential } from '@/hooks/queries/credentials'
import {
type CustomTool as CustomToolDefinition,
useCustomTools,
@@ -88,6 +89,7 @@ import {
evaluateSubBlockCondition,
isCanonicalPair,
resolveCanonicalMode,
resolveDependencyValue,
type SubBlockCondition,
} from '@/tools/params-resolver'
@@ -482,6 +484,42 @@ export const ToolInput = memo(function ToolInput({
? (value as StoredTool[])
: []
// Look up credential type for reactive condition filtering (e.g. service account detection).
// Uses canonical resolution so the active field (basic vs advanced) is respected.
const toolCredentialId = useMemo(() => {
const allBlocks = getAllBlocks()
for (const tool of selectedTools) {
const blockConfig = allBlocks.find((b: { type: string }) => b.type === tool.type)
if (!blockConfig?.subBlocks) continue
const toolCanonical = buildCanonicalIndex(blockConfig.subBlocks)
const scopedOverrides: CanonicalModeOverrides = {}
if (canonicalModeOverrides) {
for (const [key, val] of Object.entries(canonicalModeOverrides)) {
const prefix = `${tool.type}:`
if (key.startsWith(prefix) && val) {
scopedOverrides[key.slice(prefix.length)] = val as 'basic' | 'advanced'
}
}
}
const reactiveSubBlock = blockConfig.subBlocks.find(
(sb: { reactiveCondition?: unknown }) => sb.reactiveCondition
)
const reactiveCond = reactiveSubBlock?.reactiveCondition as
| { watchFields: string[]; requiredType: string }
| undefined
if (!reactiveCond) continue
for (const field of reactiveCond.watchFields) {
const val = resolveDependencyValue(field, tool.params || {}, toolCanonical, scopedOverrides)
if (val && typeof val === 'string') return val
}
}
return undefined
}, [selectedTools, canonicalModeOverrides])
const { data: toolCredential } = useWorkspaceCredential(
toolCredentialId,
Boolean(toolCredentialId)
)
const hasReferenceOnlyCustomTools = selectedTools.some(
(tool) => tool.type === 'custom-tool' && tool.customToolId && !tool.code
)
@@ -1637,7 +1675,11 @@ export const ToolInput = memo(function ToolInput({
? mcpToolParams
: toolParams?.userInputParameters || []
const displaySubBlocks: BlockSubBlockConfig[] = useSubBlocks
? subBlocksResult!.subBlocks
? subBlocksResult!.subBlocks.filter(
(sb) =>
!sb.reactiveCondition ||
toolCredential?.type === sb.reactiveCondition.requiredType
)
: []
const hasOperations = !isCustomTool && !isMcpTool && hasMultipleOperations(tool.type)

View File

@@ -9,6 +9,7 @@ import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
import { useEnvironmentStore } from '@/stores/settings/environment'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useDependsOnGate } from './use-depends-on-gate'
import { useSubBlockValue } from './use-sub-block-value'
/**
* Resolves all selector configuration from a sub-block's declarative properties.
@@ -39,6 +40,8 @@ export function useSelectorSetup(
opts
)
const [impersonateUserEmail] = useSubBlockValue<string | null>(blockId, 'impersonateUserEmail')
const resolvedDependencyValues = useMemo(() => {
const resolved: Record<string, unknown> = {}
for (const [key, value] of Object.entries(dependencyValues)) {
@@ -75,8 +78,18 @@ export function useSelectorSetup(
}
}
if (context.oauthCredential && impersonateUserEmail) {
context.impersonateUserEmail = impersonateUserEmail
}
return context
}, [resolvedDependencyValues, canonicalIndex, workflowId, subBlock.mimeType])
}, [
resolvedDependencyValues,
canonicalIndex,
workflowId,
subBlock.mimeType,
impersonateUserEmail,
])
return {
selectorKey: (subBlock.selectorKey ?? null) as SelectorKey | null,

View File

@@ -1,17 +1,77 @@
import { useCallback, useMemo } from 'react'
import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility'
import {
buildCanonicalIndex,
evaluateSubBlockCondition,
isSubBlockFeatureEnabled,
isSubBlockHiddenByHostedKey,
isSubBlockVisibleForMode,
resolveDependencyValue,
} from '@/lib/workflows/subblocks/visibility'
import type { BlockConfig, SubBlockConfig, SubBlockType } from '@/blocks/types'
import { useWorkspaceCredential } from '@/hooks/queries/credentials'
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'
/**
* Evaluates reactive conditions for subblocks. Always calls the same hooks
* regardless of whether a reactive condition exists (Rules of Hooks).
*
* Returns a Set of subblock IDs that should be hidden.
*/
function useReactiveConditions(
subBlocks: SubBlockConfig[],
blockId: string,
activeWorkflowId: string | null,
canonicalModeOverrides?: CanonicalModeOverrides
): Set<string> {
const reactiveSubBlock = useMemo(() => subBlocks.find((sb) => sb.reactiveCondition), [subBlocks])
const reactiveCond = reactiveSubBlock?.reactiveCondition
const canonicalIndex = useMemo(() => buildCanonicalIndex(subBlocks), [subBlocks])
// Resolve watchFields through canonical index to get the active credential value
const watchedCredentialId = useSubBlockStore(
useCallback(
(state) => {
if (!reactiveCond || !activeWorkflowId) return ''
const blockValues = state.workflowValues[activeWorkflowId]?.[blockId] ?? {}
for (const field of reactiveCond.watchFields) {
const val = resolveDependencyValue(
field,
blockValues,
canonicalIndex,
canonicalModeOverrides
)
if (val && typeof val === 'string') return val
}
return ''
},
[reactiveCond, activeWorkflowId, blockId, canonicalIndex, canonicalModeOverrides]
)
)
// Always call useWorkspaceCredential (stable hook count), disable when not needed
const { data: credential } = useWorkspaceCredential(
watchedCredentialId || undefined,
Boolean(reactiveCond && watchedCredentialId)
)
return useMemo(() => {
const hidden = new Set<string>()
if (!reactiveSubBlock || !reactiveCond) return hidden
const conditionMet = credential?.type === reactiveCond.requiredType
if (!conditionMet) {
hidden.add(reactiveSubBlock.id)
}
return hidden
}, [reactiveSubBlock, reactiveCond, credential?.type])
}
/**
* Custom hook for computing subblock layout in the editor panel.
* Determines which subblocks should be visible based on mode, conditions, and feature flags.
@@ -39,6 +99,14 @@ export function useEditorSubblockLayout(
)
const { config: permissionConfig } = usePermissionConfig()
// Evaluate reactive conditions (hooks-based, must be called before useMemo)
const hiddenByReactiveCondition = useReactiveConditions(
config?.subBlocks || [],
blockId,
activeWorkflowId,
blockDataFromStore?.canonicalModes
)
return useMemo(() => {
// Guard against missing config or block selection
if (!config || !Array.isArray((config as any).subBlocks) || !blockId) {
@@ -100,9 +168,18 @@ export function useEditorSubblockLayout(
const effectiveAdvanced = displayAdvancedMode
const canonicalModeOverrides = blockData?.canonicalModes
// Expose canonical mode overrides to condition functions so they can
// react to basic/advanced credential toggles (e.g. SERVICE_ACCOUNT_SUBBLOCKS).
if (canonicalModeOverrides) {
rawValues.__canonicalModes = canonicalModeOverrides
}
const visibleSubBlocks = (config.subBlocks || []).filter((block) => {
if (block.hidden) return false
// Filter by reactive condition (evaluated via hooks before useMemo)
if (hiddenByReactiveCondition.has(block.id)) return false
// Hide skill-input subblock when skills are disabled via permissions
if (block.type === 'skill-input' && permissionConfig.disableSkills) return false
@@ -158,6 +235,7 @@ export function useEditorSubblockLayout(
activeWorkflowId,
isSnapshotView,
blockDataFromStore,
hiddenByReactiveCondition,
permissionConfig.disableSkills,
])
}

View File

@@ -2,7 +2,11 @@ import { GmailIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import { createVersionedToolSelector, normalizeFileInput } from '@/blocks/utils'
import {
createVersionedToolSelector,
normalizeFileInput,
SERVICE_ACCOUNT_SUBBLOCKS,
} from '@/blocks/utils'
import type { GmailToolResponse } from '@/tools/gmail/types'
import { getTrigger } from '@/triggers'
@@ -95,6 +99,7 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
placeholder: 'Enter credential ID',
required: true,
},
...SERVICE_ACCOUNT_SUBBLOCKS,
// Send Email Fields
{
id: 'to',

View File

@@ -52,7 +52,6 @@ export const GoogleAdsBlock: BlockConfig = {
placeholder: 'Enter credential ID',
required: true,
},
{
id: 'developerToken',
title: 'Developer Token',

View File

@@ -51,7 +51,6 @@ export const GoogleBigQueryBlock: BlockConfig = {
placeholder: 'Enter credential ID',
required: true,
},
{
id: 'projectId',
title: 'Project ID',

View File

@@ -2,7 +2,7 @@ import { GoogleCalendarIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import { createVersionedToolSelector } from '@/blocks/utils'
import { createVersionedToolSelector, SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils'
import type { GoogleCalendarResponse } from '@/tools/google_calendar/types'
export const GoogleCalendarBlock: BlockConfig<GoogleCalendarResponse> = {
@@ -58,6 +58,7 @@ export const GoogleCalendarBlock: BlockConfig<GoogleCalendarResponse> = {
placeholder: 'Enter credential ID',
required: true,
},
...SERVICE_ACCOUNT_SUBBLOCKS,
// Calendar selector (basic mode) - not needed for list_calendars
{
id: 'calendarId',

View File

@@ -2,6 +2,7 @@ import { GoogleContactsIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import { SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils'
import type { GoogleContactsResponse } from '@/tools/google_contacts/types'
export const GoogleContactsBlock: BlockConfig<GoogleContactsResponse> = {
@@ -52,6 +53,7 @@ export const GoogleContactsBlock: BlockConfig<GoogleContactsResponse> = {
placeholder: 'Enter credential ID',
required: true,
},
...SERVICE_ACCOUNT_SUBBLOCKS,
// Create Contact Fields
{

View File

@@ -2,6 +2,7 @@ import { GoogleDocsIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import { SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils'
import type { GoogleDocsResponse } from '@/tools/google_docs/types'
export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
@@ -51,6 +52,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
placeholder: 'Enter credential ID',
required: true,
},
...SERVICE_ACCOUNT_SUBBLOCKS,
// Document selector (basic mode)
{
id: 'documentId',

View File

@@ -2,7 +2,7 @@ import { GoogleDriveIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import { normalizeFileInput } from '@/blocks/utils'
import { normalizeFileInput, SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils'
import type { GoogleDriveResponse } from '@/tools/google_drive/types'
export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
@@ -63,6 +63,7 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
placeholder: 'Enter credential ID',
required: true,
},
...SERVICE_ACCOUNT_SUBBLOCKS,
// Create/Upload File Fields
{
id: 'fileName',

View File

@@ -2,6 +2,7 @@ import { GoogleFormsIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { IntegrationType } from '@/blocks/types'
import { SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils'
import { getTrigger } from '@/triggers'
export const GoogleFormsBlock: BlockConfig = {
@@ -54,6 +55,7 @@ export const GoogleFormsBlock: BlockConfig = {
placeholder: 'Enter credential ID',
required: true,
},
...SERVICE_ACCOUNT_SUBBLOCKS,
// Form selector (basic mode)
{
id: 'formSelector',

View File

@@ -2,6 +2,7 @@ import { GoogleGroupsIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import { SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils'
export const GoogleGroupsBlock: BlockConfig = {
type: 'google_groups',
@@ -61,6 +62,7 @@ export const GoogleGroupsBlock: BlockConfig = {
placeholder: 'Enter credential ID',
required: true,
},
...SERVICE_ACCOUNT_SUBBLOCKS,
{
id: 'customer',

View File

@@ -2,6 +2,7 @@ import { GoogleMeetIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import { SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils'
import type { GoogleMeetResponse } from '@/tools/google_meet/types'
export const GoogleMeetBlock: BlockConfig<GoogleMeetResponse> = {
@@ -52,6 +53,7 @@ export const GoogleMeetBlock: BlockConfig<GoogleMeetResponse> = {
placeholder: 'Enter credential ID',
required: true,
},
...SERVICE_ACCOUNT_SUBBLOCKS,
// Create Space Fields
{

View File

@@ -2,7 +2,7 @@ import { GoogleSheetsIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import { createVersionedToolSelector } from '@/blocks/utils'
import { createVersionedToolSelector, SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils'
import type { GoogleSheetsResponse, GoogleSheetsV2Response } from '@/tools/google_sheets/types'
// Legacy block - hidden from toolbar
@@ -55,6 +55,7 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
placeholder: 'Enter credential ID',
required: true,
},
...SERVICE_ACCOUNT_SUBBLOCKS,
// Spreadsheet Selector
{
id: 'spreadsheetId',
@@ -350,6 +351,7 @@ export const GoogleSheetsV2Block: BlockConfig<GoogleSheetsV2Response> = {
placeholder: 'Enter credential ID',
required: true,
},
...SERVICE_ACCOUNT_SUBBLOCKS,
// Spreadsheet Selector (basic mode) - not for create operation
{
id: 'spreadsheetId',

View File

@@ -3,7 +3,7 @@ import { getScopesForService } from '@/lib/oauth/utils'
import { resolveHttpsUrlFromFileInput } from '@/lib/uploads/utils/file-utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import { normalizeFileInput } from '@/blocks/utils'
import { normalizeFileInput, SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils'
import type { GoogleSlidesResponse } from '@/tools/google_slides/types'
export const GoogleSlidesBlock: BlockConfig<GoogleSlidesResponse> = {
@@ -65,6 +65,7 @@ export const GoogleSlidesBlock: BlockConfig<GoogleSlidesResponse> = {
placeholder: 'Enter credential ID',
required: true,
},
...SERVICE_ACCOUNT_SUBBLOCKS,
// Presentation selector (basic mode) - for operations that need an existing presentation
{
id: 'presentationId',

View File

@@ -2,6 +2,7 @@ import { GoogleTasksIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import { SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils'
import type { GoogleTasksResponse } from '@/tools/google_tasks/types'
export const GoogleTasksBlock: BlockConfig<GoogleTasksResponse> = {
@@ -53,6 +54,7 @@ export const GoogleTasksBlock: BlockConfig<GoogleTasksResponse> = {
placeholder: 'Enter credential ID',
required: true,
},
...SERVICE_ACCOUNT_SUBBLOCKS,
// Task List - shown for all task operations (not list_task_lists)
{

View File

@@ -2,6 +2,7 @@ import { GoogleVaultIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import { SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils'
export const GoogleVaultBlock: BlockConfig = {
type: 'google_vault',
@@ -53,6 +54,7 @@ export const GoogleVaultBlock: BlockConfig = {
placeholder: 'Enter credential ID',
required: true,
},
...SERVICE_ACCOUNT_SUBBLOCKS,
// Create Hold inputs
{
id: 'matterId',

View File

@@ -356,6 +356,18 @@ export interface SubBlockConfig {
not?: boolean
}
})
/**
* Credential-type visibility gate. The first non-empty string value from
* `watchFields` is treated as a credential ID and fetched via the credentials
* API. The subblock is hidden unless `credential.type` matches `requiredType`.
*
* Only one subblock per block may use this. The serializer ignores it —
* the field is always serialized when it has a value.
*/
reactiveCondition?: {
watchFields: string[]
requiredType: 'oauth' | 'service_account'
}
// Props specific to 'code' sub-block type
language?: 'javascript' | 'json' | 'python'
generationType?: GenerationType

View File

@@ -8,6 +8,26 @@ import {
} from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers/store'
/**
* Standard subblocks for Google service account impersonation.
* Uses a reactive condition that fetches the credential by ID to check if it's
* a service account — works in both block editor and agent tool-input contexts.
*/
export const SERVICE_ACCOUNT_SUBBLOCKS: SubBlockConfig[] = [
{
id: 'impersonateUserEmail',
title: 'Impersonated Account',
type: 'short-input',
placeholder: 'Email to impersonate (for service accounts)',
paramVisibility: 'user-only',
reactiveCondition: {
watchFields: ['oauthCredential'],
requiredType: 'service_account',
},
mode: 'both',
},
]
/**
* Returns model options for combobox subblocks, combining all provider sources.
*/

View File

@@ -2,7 +2,11 @@ import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { refreshTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import {
getServiceAccountToken,
refreshTokenIfNeeded,
resolveOAuthAccountId,
} from '@/app/api/auth/oauth/utils'
const logger = createLogger('VertexCredential')
@@ -23,6 +27,14 @@ export async function resolveVertexCredential(
throw new Error(`Vertex AI credential is not a valid OAuth credential: ${credentialId}`)
}
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
const accessToken = await getServiceAccountToken(resolved.credentialId, [
'https://www.googleapis.com/auth/cloud-platform',
])
logger.info(`[${requestId}] Successfully resolved Vertex AI service account credential`)
return accessToken
}
const credential = await db.query.account.findFirst({
where: eq(account.id, resolved.accountId),
})

View File

@@ -5,7 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { environmentKeys } from '@/hooks/queries/environment'
import { fetchJson } from '@/hooks/selectors/helpers'
export type WorkspaceCredentialType = 'oauth' | 'env_workspace' | 'env_personal'
export type WorkspaceCredentialType = 'oauth' | 'env_workspace' | 'env_personal' | 'service_account'
export type WorkspaceCredentialRole = 'admin' | 'member'
export type WorkspaceCredentialMemberStatus = 'active' | 'pending' | 'revoked'
@@ -173,6 +173,7 @@ export function useCreateWorkspaceCredential() {
accountId?: string
envKey?: string
envOwnerUserId?: string
serviceAccountJson?: string
}) => {
const response = await fetch('/api/credentials', {
method: 'POST',
@@ -204,6 +205,7 @@ export function useUpdateWorkspaceCredential() {
displayName?: string
description?: string | null
accountId?: string
serviceAccountJson?: string
}) => {
const response = await fetch(`/api/credentials/${payload.credentialId}`, {
method: 'PUT',
@@ -212,6 +214,7 @@ export function useUpdateWorkspaceCredential() {
displayName: payload.displayName,
description: payload.description,
accountId: payload.accountId,
serviceAccountJson: payload.serviceAccountJson,
}),
})
if (!response.ok) {

View File

@@ -252,6 +252,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
'bigquery.datasets',
context.oauthCredential ?? 'none',
context.projectId ?? 'none',
context.impersonateUserEmail ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential && context.projectId),
fetchList: async ({ context }: SelectorQueryArgs) => {
@@ -261,6 +262,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
credential: credentialId,
workflowId: context.workflowId,
projectId: context.projectId,
impersonateEmail: context.impersonateUserEmail,
})
const data = await fetchJson<{ datasets: BigQueryDataset[] }>(
'/api/tools/google_bigquery/datasets',
@@ -278,6 +280,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
credential: credentialId,
workflowId: context.workflowId,
projectId: context.projectId,
impersonateEmail: context.impersonateUserEmail,
})
const data = await fetchJson<{ datasets: BigQueryDataset[] }>(
'/api/tools/google_bigquery/datasets',
@@ -301,6 +304,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
context.oauthCredential ?? 'none',
context.projectId ?? 'none',
context.datasetId ?? 'none',
context.impersonateUserEmail ?? 'none',
],
enabled: ({ context }) =>
Boolean(context.oauthCredential && context.projectId && context.datasetId),
@@ -313,6 +317,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
workflowId: context.workflowId,
projectId: context.projectId,
datasetId: context.datasetId,
impersonateEmail: context.impersonateUserEmail,
})
const data = await fetchJson<{ tables: BigQueryTable[] }>(
'/api/tools/google_bigquery/tables',
@@ -331,6 +336,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
workflowId: context.workflowId,
projectId: context.projectId,
datasetId: context.datasetId,
impersonateEmail: context.impersonateUserEmail,
})
const data = await fetchJson<{ tables: BigQueryTable[] }>(
'/api/tools/google_bigquery/tables',
@@ -557,11 +563,16 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
'selectors',
'google.tasks.lists',
context.oauthCredential ?? 'none',
context.impersonateUserEmail ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'google.tasks.lists')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
const body = JSON.stringify({
credential: credentialId,
workflowId: context.workflowId,
impersonateEmail: context.impersonateUserEmail,
})
const data = await fetchJson<{ taskLists: GoogleTaskList[] }>(
'/api/tools/google_tasks/task-lists',
{ method: 'POST', body }
@@ -571,7 +582,11 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
fetchById: async ({ context, detailId }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'google.tasks.lists')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
const body = JSON.stringify({
credential: credentialId,
workflowId: context.workflowId,
impersonateEmail: context.impersonateUserEmail,
})
const data = await fetchJson<{ taskLists: GoogleTaskList[] }>(
'/api/tools/google_tasks/task-lists',
{ method: 'POST', body }
@@ -877,11 +892,15 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
'selectors',
'gmail.labels',
context.oauthCredential ?? 'none',
context.impersonateUserEmail ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const data = await fetchJson<{ labels: FolderResponse[] }>('/api/tools/gmail/labels', {
searchParams: { credentialId: context.oauthCredential },
searchParams: {
credentialId: context.oauthCredential,
impersonateEmail: context.impersonateUserEmail,
},
})
return (data.labels || []).map((label) => ({
id: label.id,
@@ -915,12 +934,18 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
'selectors',
'google.calendar',
context.oauthCredential ?? 'none',
context.impersonateUserEmail ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const data = await fetchJson<{ calendars: { id: string; summary: string }[] }>(
'/api/tools/google_calendar/calendars',
{ searchParams: { credentialId: context.oauthCredential } }
{
searchParams: {
credentialId: context.oauthCredential,
impersonateEmail: context.impersonateUserEmail,
},
}
)
return (data.calendars || []).map((calendar) => ({
id: calendar.id,
@@ -1393,6 +1418,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
context.mimeType ?? 'any',
context.fileId ?? 'root',
search ?? '',
context.impersonateUserEmail ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, search }: SelectorQueryArgs) => {
@@ -1406,6 +1432,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
parentId: context.fileId,
query: search,
workflowId: context.workflowId,
impersonateEmail: context.impersonateUserEmail,
},
}
)
@@ -1424,6 +1451,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
credentialId,
fileId: detailId,
workflowId: context.workflowId,
impersonateEmail: context.impersonateUserEmail,
},
}
)
@@ -1440,6 +1468,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
'google.sheets',
context.oauthCredential ?? 'none',
context.spreadsheetId ?? 'none',
context.impersonateUserEmail ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential && context.spreadsheetId),
fetchList: async ({ context }: SelectorQueryArgs) => {
@@ -1454,6 +1483,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
credentialId,
spreadsheetId: context.spreadsheetId,
workflowId: context.workflowId,
impersonateEmail: context.impersonateUserEmail,
},
}
)

View File

@@ -77,6 +77,7 @@ export interface SelectorContext {
baseId?: string
datasetId?: string
serviceDeskId?: string
impersonateUserEmail?: string
}
export interface SelectorQueryArgs {

View File

@@ -65,6 +65,54 @@ export async function authorizeCredentialUse(
.limit(1)
if (platformCredential) {
if (platformCredential.type === 'service_account') {
if (workflowContext && workflowContext.workspaceId !== platformCredential.workspaceId) {
return { ok: false, error: 'Credential is not accessible from this workflow workspace' }
}
if (actingUserId) {
const requesterPerm = await getUserEntityPermissions(
actingUserId,
'workspace',
platformCredential.workspaceId
)
const [membership] = await db
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, platformCredential.id),
eq(credentialMember.userId, actingUserId),
eq(credentialMember.status, 'active')
)
)
.limit(1)
if (!membership) {
return {
ok: false,
error:
'You do not have access to this credential. Ask the credential admin to add you as a member.',
}
}
if (requesterPerm === null) {
return { ok: false, error: 'You do not have access to this workspace.' }
}
} else if (!workflowContext) {
return { ok: false, error: 'workflowId is required' }
}
return {
ok: true,
authType: auth.authType as CredentialAccessResult['authType'],
requesterUserId: auth.userId,
credentialOwnerUserId: actingUserId || auth.userId,
workspaceId: platformCredential.workspaceId,
resolvedCredentialId: platformCredential.id,
}
}
if (platformCredential.type !== 'oauth' || !platformCredential.accountId) {
return { ok: false, error: 'Unsupported credential type for OAuth access' }
}

View File

@@ -12,6 +12,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { getCredentialActorContext } from '@/lib/credentials/access'
import { getAccessibleOAuthCredentials } from '@/lib/credentials/environment'
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
import { getServiceAccountProviderForProviderId } from '@/lib/oauth/utils'
import { getTableById, queryRows } from '@/lib/table/service'
import {
downloadWorkspaceFile,
@@ -97,7 +98,10 @@ export async function executeIntegrationToolDirect(
}
const accessibleCreds = await getAccessibleOAuthCredentials(workspaceId, userId)
const match = accessibleCreds.find((c) => c.providerId === provider)
const saProviderId = getServiceAccountProviderForProviderId(provider)
const match =
accessibleCreds.find((c) => c.providerId === provider) ||
(saProviderId ? accessibleCreds.find((c) => c.providerId === saProviderId) : undefined)
if (!match) {
return {
@@ -110,34 +114,39 @@ export async function executeIntegrationToolDirect(
}
const matchCtx = await getCredentialActorContext(resolvedCredentialId, userId)
const accountId = matchCtx.credential?.accountId
if (!accountId) {
return {
success: false,
error: `OAuth account for ${provider} not found. Please reconnect your account.`,
if (matchCtx.credential?.type === 'service_account') {
executionParams.oauthCredential = resolvedCredentialId
} else {
const accountId = matchCtx.credential?.accountId
if (!accountId) {
return {
success: false,
error: `OAuth account for ${provider} not found. Please reconnect your account.`,
}
}
}
const [acc] = await db.select().from(account).where(eq(account.id, accountId)).limit(1)
const [acc] = await db.select().from(account).where(eq(account.id, accountId)).limit(1)
if (!acc) {
return {
success: false,
error: `OAuth account for ${provider} not found. Please reconnect your account.`,
if (!acc) {
return {
success: false,
error: `OAuth account for ${provider} not found. Please reconnect your account.`,
}
}
}
const requestId = generateRequestId()
const { accessToken } = await refreshTokenIfNeeded(requestId, acc, acc.id)
const requestId = generateRequestId()
const { accessToken } = await refreshTokenIfNeeded(requestId, acc, acc.id)
if (!accessToken) {
return {
success: false,
error: `OAuth token not available for ${provider}. Please reconnect your account.`,
if (!accessToken) {
return {
success: false,
error: `OAuth token not available for ${provider}. Please reconnect your account.`,
}
}
}
executionParams.accessToken = accessToken
executionParams.accessToken = accessToken
}
}
const hasHostedKeySupport = isHosted && !!toolConfig.hosting

View File

@@ -25,6 +25,7 @@ const logger = createLogger('WorkspaceContext')
const PROVIDER_SERVICES: Record<string, string[]> = {
google: ['Gmail', 'Sheets', 'Calendar', 'Drive'],
'google-service-account': ['Gmail', 'Sheets', 'Calendar', 'Drive'],
slack: ['Slack'],
github: ['GitHub'],
microsoft: ['Outlook', 'OneDrive'],

View File

@@ -389,7 +389,12 @@ export async function getAccessibleOAuthCredentials(
eq(credentialMember.status, 'active')
)
)
.where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth')))
.where(
and(
eq(credential.workspaceId, workspaceId),
inArray(credential.type, ['oauth', 'service_account'])
)
)
return rows
.filter((row): row is AccessibleOAuthCredential => Boolean(row.providerId))

View File

@@ -72,6 +72,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'https://www.googleapis.com/auth/gmail.modify',
'https://www.googleapis.com/auth/gmail.labels',
],
serviceAccountProviderId: 'google-service-account',
},
'google-drive': {
name: 'Google Drive',
@@ -85,6 +86,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
serviceAccountProviderId: 'google-service-account',
},
'google-docs': {
name: 'Google Docs',
@@ -98,6 +100,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
serviceAccountProviderId: 'google-service-account',
},
'google-sheets': {
name: 'Google Sheets',
@@ -111,6 +114,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
serviceAccountProviderId: 'google-service-account',
},
'google-forms': {
name: 'Google Forms',
@@ -125,6 +129,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'https://www.googleapis.com/auth/forms.body',
'https://www.googleapis.com/auth/forms.responses.readonly',
],
serviceAccountProviderId: 'google-service-account',
},
'google-calendar': {
name: 'Google Calendar',
@@ -137,6 +142,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/calendar',
],
serviceAccountProviderId: 'google-service-account',
},
'google-contacts': {
name: 'Google Contacts',
@@ -149,6 +155,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/contacts',
],
serviceAccountProviderId: 'google-service-account',
},
'google-ads': {
name: 'Google Ads',
@@ -173,6 +180,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/bigquery',
],
serviceAccountProviderId: 'google-service-account',
},
'google-tasks': {
name: 'Google Tasks',
@@ -185,6 +193,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/tasks',
],
serviceAccountProviderId: 'google-service-account',
},
'google-vault': {
name: 'Google Vault',
@@ -198,6 +207,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'https://www.googleapis.com/auth/ediscovery',
'https://www.googleapis.com/auth/devstorage.read_only',
],
serviceAccountProviderId: 'google-service-account',
},
'google-groups': {
name: 'Google Groups',
@@ -211,6 +221,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'https://www.googleapis.com/auth/admin.directory.group',
'https://www.googleapis.com/auth/admin.directory.group.member',
],
serviceAccountProviderId: 'google-service-account',
},
'google-meet': {
name: 'Google Meet',
@@ -224,6 +235,16 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'https://www.googleapis.com/auth/meetings.space.created',
'https://www.googleapis.com/auth/meetings.space.readonly',
],
serviceAccountProviderId: 'google-service-account',
},
'google-service-account': {
name: 'Google Service Account',
description: 'Authenticate with a JSON key file from Google Cloud Console.',
providerId: 'google-service-account',
icon: GoogleIcon,
baseProviderIcon: GoogleIcon,
scopes: [],
authType: 'service_account',
},
'vertex-ai': {
name: 'Vertex AI',

View File

@@ -109,6 +109,8 @@ export interface OAuthProviderConfig {
defaultService: string
}
export type OAuthAuthType = 'oauth' | 'service_account'
export interface OAuthServiceConfig {
name: string
description: string
@@ -116,6 +118,8 @@ export interface OAuthServiceConfig {
icon: (props: { className?: string }) => ReactNode
baseProviderIcon: (props: { className?: string }) => ReactNode
scopes: string[]
authType?: OAuthAuthType
serviceAccountProviderId?: string
}
/**
@@ -132,6 +136,7 @@ export interface Credential {
id: string
name: string
provider: OAuthProvider
type?: 'oauth' | 'service_account'
serviceId?: string
lastUsed?: string
isDefault?: boolean

View File

@@ -488,6 +488,11 @@ export function getServiceConfigByProviderId(providerId: string): OAuthServiceCo
return null
}
export function getServiceAccountProviderForProviderId(providerId: string): string | undefined {
const serviceConfig = getServiceConfigByProviderId(providerId)
return serviceConfig?.serviceAccountProviderId
}
export function getCanonicalScopesForProvider(providerId: string): string[] {
const service = getServiceConfigByProviderId(providerId)
return service?.scopes ? [...service.scopes] : []

View File

@@ -21,6 +21,7 @@ export const SELECTOR_CONTEXT_FIELDS = new Set<keyof SelectorContext>([
'baseId',
'datasetId',
'serviceDeskId',
'impersonateUserEmail',
])
/**

View File

@@ -688,6 +688,16 @@ export async function executeTool(
if (workflowId) {
tokenPayload.workflowId = workflowId
}
if (contextParams.impersonateUserEmail) {
tokenPayload.impersonateEmail = contextParams.impersonateUserEmail as string
}
if (tool?.oauth?.provider) {
const { getCanonicalScopesForProvider } = await import('@/lib/oauth/utils')
const providerScopes = getCanonicalScopesForProvider(tool.oauth.provider)
if (providerScopes.length > 0) {
tokenPayload.scopes = providerScopes
}
}
logger.info(`[${requestId}] Fetching access token from ${baseUrl}/api/auth/oauth/token`)
@@ -754,6 +764,7 @@ export async function executeTool(
}
// Clean up params we don't need to pass to the actual tool
contextParams.credential = undefined
contextParams.impersonateUserEmail = undefined
if (contextParams.workflowId) contextParams.workflowId = undefined
} catch (error: any) {
logger.error(`[${requestId}] Error fetching access token for ${toolId}:`, {

View File

@@ -6,6 +6,7 @@ import {
getCanonicalValues,
isCanonicalPair,
resolveCanonicalMode,
resolveDependencyValue,
type SubBlockCondition,
} from '@/lib/workflows/subblocks/visibility'
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
@@ -17,6 +18,7 @@ export {
evaluateSubBlockCondition,
isCanonicalPair,
resolveCanonicalMode,
resolveDependencyValue,
type SubBlockCondition,
}

View File

@@ -989,8 +989,7 @@ export function getSubBlocksForToolInput(
// Filter by visibility: exclude hidden and llm-only
if (visibility === 'hidden' || visibility === 'llm-only') continue
// Evaluate condition against current values
if (sb.condition) {
if (sb.condition && !sb.reactiveCondition) {
const conditionMet = evaluateSubBlockCondition(
sb.condition as SubBlockCondition,
valuesWithOperation

View File

@@ -190,6 +190,8 @@ export interface OAuthTokenPayload {
credentialAccountUserId?: string
providerId?: string
workflowId?: string
impersonateEmail?: string
scopes?: string[]
}
/**

View File

@@ -0,0 +1,3 @@
ALTER TYPE "public"."credential_type" ADD VALUE 'service_account';--> statement-breakpoint
ALTER TABLE "credential" ADD COLUMN "encrypted_service_account_key" text;--> statement-breakpoint
CREATE UNIQUE INDEX "credential_workspace_service_account_unique" ON "credential" USING btree ("workspace_id","type","provider_id","display_name") WHERE type = 'service_account';

File diff suppressed because it is too large Load Diff

View File

@@ -1282,6 +1282,13 @@
"when": 1774724904239,
"tag": "0183_workable_apocalypse",
"breakpoints": true
},
{
"idx": 184,
"version": "7",
"when": 1775117879646,
"tag": "0184_bumpy_exiles",
"breakpoints": true
}
]
}
}

View File

@@ -2311,6 +2311,7 @@ export const credentialTypeEnum = pgEnum('credential_type', [
'oauth',
'env_workspace',
'env_personal',
'service_account',
])
export const credential = pgTable(
@@ -2327,6 +2328,7 @@ export const credential = pgTable(
accountId: text('account_id').references(() => account.id, { onDelete: 'cascade' }),
envKey: text('env_key'),
envOwnerUserId: text('env_owner_user_id').references(() => user.id, { onDelete: 'cascade' }),
encryptedServiceAccountKey: text('encrypted_service_account_key'),
createdBy: text('created_by')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
@@ -2348,6 +2350,9 @@ export const credential = pgTable(
workspacePersonalEnvUnique: uniqueIndex('credential_workspace_personal_env_unique')
.on(table.workspaceId, table.type, table.envKey, table.envOwnerUserId)
.where(sql`type = 'env_personal'`),
workspaceServiceAccountUnique: uniqueIndex('credential_workspace_service_account_unique')
.on(table.workspaceId, table.type, table.providerId, table.displayName)
.where(sql`type = 'service_account'`),
oauthSourceConstraint: check(
'credential_oauth_source_check',
sql`(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)`