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
392 lines
14 KiB
TypeScript
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' }
|
|
}
|
|
}
|