mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
feat(auth): allow google service account
This commit is contained in:
@@ -149,6 +149,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 +160,48 @@ export async function GET(request: NextRequest) {
|
||||
.limit(1)
|
||||
|
||||
if (platformCredential) {
|
||||
if (platformCredential.type === 'service_account') {
|
||||
if (workflowId) {
|
||||
if (
|
||||
!effectiveWorkspaceId ||
|
||||
platformCredential.workspaceId !== effectiveWorkspaceId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
} else {
|
||||
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
|
||||
),
|
||||
],
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
|
||||
if (platformCredential.type !== 'oauth' || !platformCredential.accountId) {
|
||||
return NextResponse.json({ credentials: [] }, { status: 200 })
|
||||
}
|
||||
@@ -238,14 +281,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 isGoogleProvider =
|
||||
providerParam.startsWith('google') || providerParam === 'gmail'
|
||||
|
||||
if (isGoogleProvider) {
|
||||
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, 'google-service-account')
|
||||
)
|
||||
)
|
||||
|
||||
for (const sa of serviceAccountCreds) {
|
||||
results.push(
|
||||
toCredentialResponse(
|
||||
sa.id,
|
||||
sa.displayName,
|
||||
sa.providerId || 'google-service-account',
|
||||
sa.updatedAt,
|
||||
null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ credentials: results }, { status: 200 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ credentials: [] }, { status: 200 })
|
||||
|
||||
@@ -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,35 @@ 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 defaultScopes = ['https://www.googleapis.com/auth/cloud-platform']
|
||||
const accessToken = await getServiceAccountToken(
|
||||
resolved.credentialId,
|
||||
scopes && scopes.length > 0 ? scopes : defaultScopes,
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
@@ -25,16 +27,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 +56,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 +79,83 @@ export async function resolveOAuthAccountId(
|
||||
return { accountId: credentialId, usedCredentialTable: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a short-lived access token for a Google service account credential
|
||||
* using the two-legged OAuth JWT flow (RFC 7523).
|
||||
*/
|
||||
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 now = Math.floor(Date.now() / 1000)
|
||||
const tokenUri = keyData.token_uri || 'https://oauth2.googleapis.com/token'
|
||||
|
||||
const header = { alg: 'RS256', typ: 'JWT' }
|
||||
const payload: Record<string, unknown> = {
|
||||
iss: keyData.client_email,
|
||||
scope: scopes.join(' '),
|
||||
aud: tokenUri,
|
||||
iat: now,
|
||||
exp: now + 3600,
|
||||
}
|
||||
|
||||
if (impersonateEmail) {
|
||||
payload.sub = impersonateEmail
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
throw new Error(`Token exchange failed: ${response.status}`)
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -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,42 @@ 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'
|
||||
) {
|
||||
try {
|
||||
const parsed = JSON.parse(parseResult.data.serviceAccountJson)
|
||||
if (
|
||||
parsed.type !== 'service_account' ||
|
||||
!parsed.client_email ||
|
||||
!parsed.private_key ||
|
||||
!parsed.project_id
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid service account JSON key' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { encrypted } = await encryptSecret(parseResult.data.serviceAccountJson)
|
||||
updates.encryptedServiceAccountKey = encrypted
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON format' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
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.',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { getWorkspaceMemberUserIds } from '@/lib/credentials/environment'
|
||||
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
|
||||
import { getServiceConfigByProviderId } from '@/lib/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) {
|
||||
|
||||
@@ -91,6 +91,12 @@ 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 { data: session } = useSession()
|
||||
const currentUserId = session?.user?.id || ''
|
||||
|
||||
@@ -110,7 +116,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 +354,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 +368,10 @@ export function IntegrationsManager() {
|
||||
setCreateError(null)
|
||||
setCreateStep(1)
|
||||
setServiceSearch('')
|
||||
setSaJsonInput('')
|
||||
setSaDisplayName('')
|
||||
setSaDescription('')
|
||||
setSaError(null)
|
||||
pendingReturnOriginRef.current = undefined
|
||||
}
|
||||
|
||||
@@ -456,25 +462,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 +635,84 @@ 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 handleSaFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) 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)
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
const filteredServices = useMemo(() => {
|
||||
if (!serviceSearch.trim()) return oauthServiceOptions
|
||||
const q = serviceSearch.toLowerCase()
|
||||
@@ -700,7 +789,7 @@ export function IntegrationsManager() {
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
) : (
|
||||
) : selectedOAuthService?.authType !== 'service_account' ? (
|
||||
<>
|
||||
<ModalHeader>
|
||||
<div className='flex items-center gap-2.5'>
|
||||
@@ -827,6 +916,131 @@ 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
JSON Key<span className='ml-1'>*</span>
|
||||
</Label>
|
||||
<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...'
|
||||
autoComplete='off'
|
||||
data-lpignore='true'
|
||||
className='mt-1.5 min-h-[120px] resize-none font-mono text-[12px]'
|
||||
autoFocus
|
||||
/>
|
||||
<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,7 +1083,7 @@ export function IntegrationsManager() {
|
||||
<Button
|
||||
variant='destructive'
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={disconnectOAuthService.isPending}
|
||||
disabled={disconnectOAuthService.isPending || deleteCredential.isPending}
|
||||
>
|
||||
{disconnectOAuthService.isPending ? 'Disconnecting...' : 'Disconnect'}
|
||||
</Button>
|
||||
@@ -920,10 +1134,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 +1149,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 +1336,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 +1360,7 @@ export function IntegrationsManager() {
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => handleDeleteClick(selectedCredential)}
|
||||
disabled={disconnectOAuthService.isPending}
|
||||
disabled={disconnectOAuthService.isPending || deleteCredential.isPending}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
@@ -1234,7 +1456,11 @@ export function IntegrationsManager() {
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => handleDeleteClick(credential)}
|
||||
disabled={disconnectOAuthService.isPending}
|
||||
disabled={
|
||||
credential.type === 'service_account'
|
||||
? deleteCredential.isPending
|
||||
: disconnectOAuthService.isPending
|
||||
}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -65,6 +65,52 @@ 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.' }
|
||||
}
|
||||
}
|
||||
|
||||
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' }
|
||||
}
|
||||
|
||||
@@ -225,6 +225,15 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
'https://www.googleapis.com/auth/meetings.space.readonly',
|
||||
],
|
||||
},
|
||||
'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',
|
||||
description: 'Access Google Cloud Vertex AI for Gemini models with OAuth.',
|
||||
|
||||
@@ -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,7 @@ export interface OAuthServiceConfig {
|
||||
icon: (props: { className?: string }) => ReactNode
|
||||
baseProviderIcon: (props: { className?: string }) => ReactNode
|
||||
scopes: string[]
|
||||
authType?: OAuthAuthType
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
4
packages/db/migrations/0182_cute_emma_frost.sql
Normal file
4
packages/db/migrations/0182_cute_emma_frost.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
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';--> statement-breakpoint
|
||||
ALTER TABLE "credential" ADD CONSTRAINT "credential_service_account_source_check" CHECK ((type <> 'service_account') OR (encrypted_service_account_key IS NOT NULL AND provider_id IS NOT NULL));
|
||||
15192
packages/db/migrations/meta/0182_snapshot.json
Normal file
15192
packages/db/migrations/meta/0182_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1268,6 +1268,13 @@
|
||||
"when": 1774411211528,
|
||||
"tag": "0181_dazzling_the_leader",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 182,
|
||||
"version": "7",
|
||||
"when": 1774643404890,
|
||||
"tag": "0182_cute_emma_frost",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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)`
|
||||
@@ -2360,6 +2365,10 @@ export const credential = pgTable(
|
||||
'credential_personal_env_source_check',
|
||||
sql`(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)`
|
||||
),
|
||||
serviceAccountSourceConstraint: check(
|
||||
'credential_service_account_source_check',
|
||||
sql`(type <> 'service_account') OR (encrypted_service_account_key IS NOT NULL AND provider_id IS NOT NULL)`
|
||||
),
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user