Files
simstudioai--sim/apps/sim/lib/credentials/orchestration/index.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

392 lines
14 KiB
TypeScript

import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { credential, environment, webhook, workspaceEnvironment } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq, sql } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { encryptSecret } from '@/lib/core/security/encryption'
import { getCredentialActorContext } from '@/lib/credentials/access'
import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account'
import { type CredentialDeleteReason, deleteCredential } from '@/lib/credentials/deletion'
import {
deleteWorkspaceEnvCredentials,
syncPersonalEnvCredentialsForUser,
} from '@/lib/credentials/environment'
import {
ServiceAccountSecretError,
verifyAndBuildServiceAccountSecret,
} from '@/lib/credentials/service-account-secret'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('CredentialOrchestration')
export type CredentialOrchestrationErrorCode =
| 'not_found'
| 'forbidden'
| 'validation'
| 'conflict'
| 'internal'
interface CredentialActorParams {
credentialId: string
userId: string
actorName?: string | null
actorEmail?: string | null
allowedTypes?: Array<typeof credential.$inferSelect.type>
reason?: CredentialDeleteReason
request?: NextRequest
}
export interface PerformUpdateCredentialParams extends CredentialActorParams {
displayName?: string
description?: string | null
serviceAccountJson?: string
/** Slack custom-bot secret rotation (reconnect). */
signingSecret?: string
botToken?: string
/** Atlassian service-account secret rotation (reconnect). */
apiToken?: string
domain?: string
}
export interface PerformCredentialResult {
success: boolean
error?: string
errorCode?: CredentialOrchestrationErrorCode
/** Provider-specific code (e.g. Atlassian `invalid_credentials`) for client message mapping. */
providerErrorCode?: string
workspaceId?: string
updatedFields?: string[]
}
export async function performUpdateCredential(
params: PerformUpdateCredentialParams
): Promise<PerformCredentialResult> {
try {
const access = await getCredentialActorContext(params.credentialId, params.userId)
if (!access.credential) {
return { success: false, error: 'Credential not found', errorCode: 'not_found' }
}
if (!access.hasWorkspaceAccess || !access.isAdmin) {
return {
success: false,
error: 'Credential admin permission required',
errorCode: 'forbidden',
}
}
if (params.allowedTypes && !params.allowedTypes.includes(access.credential.type)) {
return {
success: false,
error: `Only ${params.allowedTypes.join(', ')} credentials can be managed with this tool.`,
errorCode: 'validation',
}
}
const updates: Record<string, unknown> = {}
if (params.description !== undefined) {
updates.description = params.description ?? null
}
if (
params.displayName !== undefined &&
(access.credential.type === 'oauth' || access.credential.type === 'service_account')
) {
updates.displayName = params.displayName
}
if (params.serviceAccountJson !== undefined && access.credential.type === 'service_account') {
let parsedJson: Record<string, unknown>
try {
parsedJson = JSON.parse(params.serviceAccountJson)
} catch {
return { success: false, error: 'Invalid JSON format', errorCode: 'validation' }
}
if (
parsedJson.type !== 'service_account' ||
typeof parsedJson.client_email !== 'string' ||
typeof parsedJson.private_key !== 'string' ||
typeof parsedJson.project_id !== 'string'
) {
return {
success: false,
error: 'Invalid service account JSON key',
errorCode: 'validation',
}
}
const { encrypted } = await encryptSecret(params.serviceAccountJson)
updates.encryptedServiceAccountKey = encrypted
}
// Reconnect: rotate a Slack/Atlassian service-account secret in place. The
// secret is re-verified against the provider and re-encrypted; the display
// name is preserved (the user may have renamed it).
const hasRotationSecret =
params.signingSecret !== undefined ||
params.botToken !== undefined ||
params.apiToken !== undefined ||
params.domain !== undefined
let rotatedSlackBotUserId: string | undefined
if (hasRotationSecret && access.credential.type === 'service_account') {
try {
const secret = await verifyAndBuildServiceAccountSecret(
access.credential.providerId ?? '',
{
signingSecret: params.signingSecret,
botToken: params.botToken,
apiToken: params.apiToken,
domain: params.domain,
}
)
updates.encryptedServiceAccountKey = secret.encryptedServiceAccountKey
rotatedSlackBotUserId = secret.botUserId
} catch (error) {
if (error instanceof ServiceAccountSecretError) {
return { success: false, error: error.message, errorCode: 'validation' }
}
if (error instanceof AtlassianValidationError) {
// Surface the provider code so the client maps it to the specific
// token/domain message (create returns it too).
return {
success: false,
error: error.code,
errorCode: 'validation',
providerErrorCode: error.code,
}
}
throw error
}
}
if (Object.keys(updates).length === 0) {
if (access.credential.type === 'oauth' || access.credential.type === 'service_account') {
return { success: false, error: 'No updatable fields provided.', errorCode: 'validation' }
}
return {
success: false,
error:
'Environment credentials cannot be updated via this endpoint. Use the environment value editor in credentials settings.',
errorCode: 'validation',
}
}
updates.updatedAt = new Date()
await db.update(credential).set(updates).where(eq(credential.id, params.credentialId))
// Reconnecting to a recreated Slack app changes the bot user id, but each
// deployed webhook cached the old one at deploy for reaction self-drop.
// Propagate the rotated id to the credential's live custom-bot webhooks so
// the bot's own reactions keep being dropped (a stale id lets them re-enter).
if (rotatedSlackBotUserId) {
await db
.update(webhook)
.set({
providerConfig: sql`jsonb_set((${webhook.providerConfig})::jsonb, '{bot_user_id}', to_jsonb(${rotatedSlackBotUserId}::text))::json`,
updatedAt: new Date(),
})
.where(and(eq(webhook.provider, 'slack'), eq(webhook.routingKey, params.credentialId)))
}
const updatedFields = Object.keys(updates).filter((key) => key !== 'updatedAt')
recordAudit({
workspaceId: access.credential.workspaceId,
actorId: params.userId,
actorName: params.actorName ?? undefined,
actorEmail: params.actorEmail ?? undefined,
action: AuditAction.CREDENTIAL_UPDATED,
resourceType: AuditResourceType.CREDENTIAL,
resourceId: params.credentialId,
resourceName: access.credential.displayName,
description: `Updated ${access.credential.type} credential "${access.credential.displayName}"`,
metadata: {
credentialType: access.credential.type,
updatedFields,
},
request: params.request,
})
return { success: true, workspaceId: access.credential.workspaceId, updatedFields }
} catch (error) {
if (error instanceof Error && error.message.includes('unique')) {
return {
success: false,
error: 'A service account credential with this name already exists in the workspace',
errorCode: 'conflict',
}
}
logger.error('Failed to update credential', { error })
return { success: false, error: 'Internal server error', errorCode: 'internal' }
}
}
export async function performDeleteCredential(
params: CredentialActorParams
): Promise<PerformCredentialResult> {
try {
const access = await getCredentialActorContext(params.credentialId, params.userId)
if (!access.credential) {
return { success: false, error: 'Credential not found', errorCode: 'not_found' }
}
if (!access.hasWorkspaceAccess || !access.isAdmin) {
return {
success: false,
error: 'Credential admin permission required',
errorCode: 'forbidden',
}
}
if (params.allowedTypes && !params.allowedTypes.includes(access.credential.type)) {
return {
success: false,
error: `Only ${params.allowedTypes.join(', ')} credentials can be managed with this tool.`,
errorCode: 'validation',
}
}
if (access.credential.type === 'env_personal' && access.credential.envKey) {
const ownerUserId = access.credential.envOwnerUserId
if (!ownerUserId) {
return { success: false, error: 'Invalid personal secret owner', errorCode: 'validation' }
}
const [personalRow] = await db
.select({ variables: environment.variables })
.from(environment)
.where(eq(environment.userId, ownerUserId))
.limit(1)
const current = ((personalRow?.variables as Record<string, string> | null) ?? {}) as Record<
string,
string
>
if (access.credential.envKey in current) delete current[access.credential.envKey]
await db
.insert(environment)
.values({ id: ownerUserId, userId: ownerUserId, variables: current, updatedAt: new Date() })
.onConflictDoUpdate({
target: [environment.userId],
set: { variables: current, updatedAt: new Date() },
})
await syncPersonalEnvCredentialsForUser({
userId: ownerUserId,
envKeys: Object.keys(current),
})
captureServerEvent(
params.userId,
'credential_deleted',
{
credential_type: 'env_personal',
provider_id: access.credential.envKey,
workspace_id: access.credential.workspaceId,
},
{ groups: { workspace: access.credential.workspaceId } }
)
recordAudit({
workspaceId: access.credential.workspaceId,
actorId: params.userId,
actorName: params.actorName ?? undefined,
actorEmail: params.actorEmail ?? undefined,
action: AuditAction.CREDENTIAL_DELETED,
resourceType: AuditResourceType.CREDENTIAL,
resourceId: params.credentialId,
resourceName: access.credential.displayName,
description: `Deleted personal env credential "${access.credential.envKey}"`,
metadata: { credentialType: 'env_personal', envKey: access.credential.envKey },
request: params.request,
})
return { success: true, workspaceId: access.credential.workspaceId }
}
if (access.credential.type === 'env_workspace' && access.credential.envKey) {
const [workspaceRow] = await db
.select({
id: workspaceEnvironment.id,
createdAt: workspaceEnvironment.createdAt,
variables: workspaceEnvironment.variables,
})
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, access.credential.workspaceId))
.limit(1)
const current = ((workspaceRow?.variables as Record<string, string> | null) ?? {}) as Record<
string,
string
>
if (access.credential.envKey in current) delete current[access.credential.envKey]
await db
.insert(workspaceEnvironment)
.values({
id: workspaceRow?.id || generateId(),
workspaceId: access.credential.workspaceId,
variables: current,
createdAt: workspaceRow?.createdAt || new Date(),
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [workspaceEnvironment.workspaceId],
set: { variables: current, updatedAt: new Date() },
})
await deleteWorkspaceEnvCredentials({
workspaceId: access.credential.workspaceId,
removedKeys: [access.credential.envKey],
})
captureServerEvent(
params.userId,
'credential_deleted',
{
credential_type: 'env_workspace',
provider_id: access.credential.envKey,
workspace_id: access.credential.workspaceId,
},
{ groups: { workspace: access.credential.workspaceId } }
)
recordAudit({
workspaceId: access.credential.workspaceId,
actorId: params.userId,
actorName: params.actorName ?? undefined,
actorEmail: params.actorEmail ?? undefined,
action: AuditAction.CREDENTIAL_DELETED,
resourceType: AuditResourceType.CREDENTIAL,
resourceId: params.credentialId,
resourceName: access.credential.displayName,
description: `Deleted workspace env credential "${access.credential.envKey}"`,
metadata: { credentialType: 'env_workspace', envKey: access.credential.envKey },
request: params.request,
})
return { success: true, workspaceId: access.credential.workspaceId }
}
await deleteCredential({
credentialId: params.credentialId,
actorId: params.userId,
actorName: params.actorName,
actorEmail: params.actorEmail,
reason: params.reason ?? 'user_delete',
request: params.request,
})
captureServerEvent(
params.userId,
'credential_deleted',
{
credential_type: access.credential.type as 'oauth' | 'service_account',
provider_id: access.credential.providerId ?? params.credentialId,
workspace_id: access.credential.workspaceId,
},
{ groups: { workspace: access.credential.workspaceId } }
)
return { success: true, workspaceId: access.credential.workspaceId }
} catch (error) {
logger.error('Failed to delete credential', { error })
return { success: false, error: 'Internal server error', errorCode: 'internal' }
}
}