Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 33 additions & 20 deletions apps/sim/app/api/auth/oauth/disconnect/route.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { account, credentialSet, credentialSetMember } from '@sim/db/schema'
import { account, credential, credentialSet, credentialSetMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, like, or } from 'drizzle-orm'
import { and, eq, inArray, like, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { disconnectOAuthContract } from '@/lib/api/contracts/oauth-connections'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { deleteCredential } from '@/lib/credentials/deletion'
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'

export const dynamic = 'force-dynamic'
Expand Down Expand Up @@ -52,27 +53,39 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
hasProviderId: !!providerId,
})

// If a specific account row ID is provided, delete that exact account
if (accountId) {
await db
.delete(account)
.where(and(eq(account.userId, session.user.id), eq(account.id, accountId)))
} else if (providerId) {
// If a specific providerId is provided, delete accounts for that provider ID
await db
.delete(account)
.where(and(eq(account.userId, session.user.id), eq(account.providerId, providerId)))
} else {
// Otherwise, delete all accounts for this provider
// Handle both exact matches (e.g., 'confluence') and prefixed matches (e.g., 'google-email')
await db
.delete(account)
.where(
and(
// Delete credentials before their accounts so deleteCredential can clear
// stored references first. Otherwise FK CASCADE would orphan them silently.
const accountFilter = accountId
? and(eq(account.userId, session.user.id), eq(account.id, accountId))
: providerId
? and(eq(account.userId, session.user.id), eq(account.providerId, providerId))
: and(
eq(account.userId, session.user.id),
or(eq(account.providerId, provider), like(account.providerId, `${provider}-%`))
)
)

const targetAccounts = await db.select({ id: account.id }).from(account).where(accountFilter)

const targetAccountIds = targetAccounts.map((a) => a.id)

if (targetAccountIds.length > 0) {
const credentialsToDelete = await db
.select({ id: credential.id })
.from(credential)
.where(inArray(credential.accountId, targetAccountIds))

for (const cred of credentialsToDelete) {
await deleteCredential({
credentialId: cred.id,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
reason: 'oauth_disconnect',
request,
})
}

await db.delete(account).where(inArray(account.id, targetAccountIds))
}

// Sync webhooks for all credential sets the user is a member of
Expand Down
27 changes: 9 additions & 18 deletions apps/sim/app/api/credentials/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { getSession } from '@/lib/auth'
import { encryptSecret } from '@/lib/core/security/encryption'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getCredentialActorContext } from '@/lib/credentials/access'
import { deleteCredential } from '@/lib/credentials/deletion'
import {
deleteWorkspaceEnvCredentials,
syncPersonalEnvCredentialsForUser,
Expand Down Expand Up @@ -333,7 +334,14 @@ export const DELETE = withRouteHandler(
return NextResponse.json({ success: true }, { status: 200 })
}

await db.delete(credential).where(eq(credential.id, id))
await deleteCredential({
credentialId: id,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
reason: 'user_delete',
request,
})

captureServerEvent(
session.user.id,
Expand All @@ -346,23 +354,6 @@ export const DELETE = withRouteHandler(
{ groups: { workspace: access.credential.workspaceId } }
)

recordAudit({
workspaceId: access.credential.workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CREDENTIAL_DELETED,
resourceType: AuditResourceType.CREDENTIAL,
resourceId: id,
resourceName: access.credential.displayName,
description: `Deleted ${access.credential.type} credential "${access.credential.displayName}"`,
metadata: {
credentialType: access.credential.type,
providerId: access.credential.providerId,
},
request,
})

return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
logger.error('Failed to delete credential', error)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { toError } from '@sim/utils/errors'
import { eq } from 'drizzle-orm'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
import { getCredentialActorContext } from '@/lib/credentials/access'
import { deleteCredential } from '@/lib/credentials/deletion'

export function executeManageCredential(
rawParams: Record<string, unknown>,
Expand Down Expand Up @@ -71,7 +72,11 @@ export function executeManageCredential(
failed.push(id)
continue
}
await db.delete(credential).where(eq(credential.id, id))
await deleteCredential({
credentialId: id,
actorId: context.userId,
reason: 'copilot_delete',
})
deleted.push(id)
}

Expand Down
164 changes: 164 additions & 0 deletions apps/sim/lib/credentials/__tests__/deletion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { clearCredentialInValue } from '@/lib/credentials/deletion'

const TARGET = 'cred_target123'
const OTHER = 'cred_other999'

describe('clearCredentialInValue', () => {
it('clears matching subBlock value with id="credential"', () => {
const input = { id: 'credential', type: 'oauth-input', value: TARGET }
const result = clearCredentialInValue(input, TARGET)
expect(result.changed).toBe(true)
expect(result.value).toEqual({ id: 'credential', type: 'oauth-input', value: '' })
})

it('clears matching subBlock value with id="manualCredential"', () => {
const input = { id: 'manualCredential', type: 'short-input', value: TARGET }
const result = clearCredentialInValue(input, TARGET)
expect(result.changed).toBe(true)
expect(result.value).toEqual({ id: 'manualCredential', type: 'short-input', value: '' })
})

it('clears matching subBlock value with id="triggerCredentials"', () => {
const input = { id: 'triggerCredentials', value: TARGET }
const result = clearCredentialInValue(input, TARGET)
expect(result.changed).toBe(true)
expect((result.value as { value: string }).value).toBe('')
})

it('leaves unrelated subBlock value untouched', () => {
const input = { id: 'someOtherField', value: TARGET }
const result = clearCredentialInValue(input, TARGET)
expect(result.changed).toBe(false)
expect(result.value).toBe(input)
})

it('leaves matching subBlock with non-matching value untouched', () => {
const input = { id: 'credential', value: OTHER }
const result = clearCredentialInValue(input, TARGET)
expect(result.changed).toBe(false)
expect(result.value).toBe(input)
})

it('clears nested tools[].params.credential', () => {
const input = {
id: 'tools',
value: [
{ type: 'gmail_send', params: { credential: TARGET, to: 'a@b.com' } },
{ type: 'slack_message', params: { credential: OTHER, channel: '#x' } },
],
}
const result = clearCredentialInValue(input, TARGET)
expect(result.changed).toBe(true)
const value = result.value as { value: Array<{ params: { credential: string } }> }
expect(value.value[0].params.credential).toBe('')
expect(value.value[1].params.credential).toBe(OTHER)
})

it('walks workflow_blocks-style keyed subBlocks structure', () => {
const input = {
credential: { id: 'credential', value: TARGET },
messages: { id: 'messages', value: 'hello' },
tools: {
id: 'tools',
value: [{ type: 'x', params: { credential: TARGET } }],
},
}
const result = clearCredentialInValue(input, TARGET)
expect(result.changed).toBe(true)
const value = result.value as typeof input
expect(value.credential.value).toBe('')
expect(value.messages.value).toBe('hello')
expect(value.tools.value[0].params.credential).toBe('')
})

it('walks deployment-style nested blocks structure', () => {
const input = {
blocks: {
block1: {
subBlocks: {
credential: { id: 'credential', value: TARGET },
},
},
block2: {
subBlocks: {
other: { id: 'other', value: 'unrelated' },
},
},
},
}
const result = clearCredentialInValue(input, TARGET)
expect(result.changed).toBe(true)
const value = result.value as typeof input
expect(value.blocks.block1.subBlocks.credential.value).toBe('')
expect(value.blocks.block2.subBlocks.other.value).toBe('unrelated')
})

it('returns same reference when no changes are made', () => {
const input = {
blocks: {
block1: {
subBlocks: { credential: { id: 'credential', value: OTHER } },
},
},
}
const result = clearCredentialInValue(input, TARGET)
expect(result.changed).toBe(false)
expect(result.value).toBe(input)
})

it('does not match outer "credential" key whose value is an object wrapper', () => {
const input = { credential: { id: 'credential', value: TARGET } }
const result = clearCredentialInValue(input, TARGET)
expect(result.changed).toBe(true)
const value = result.value as { credential: { id: string; value: string } }
expect(value.credential).toEqual({ id: 'credential', value: '' })
})

it('clears params.credential string directly even when not nested in tools', () => {
const input = { params: { credential: TARGET, channel: '#x' } }
const result = clearCredentialInValue(input, TARGET)
expect(result.changed).toBe(true)
expect((result.value as typeof input).params).toEqual({ credential: '', channel: '#x' })
})

it('does not match "credential" key when value is a different string', () => {
const input = { credential: 'cred_unrelated' }
const result = clearCredentialInValue(input, TARGET)
expect(result.changed).toBe(false)
expect(result.value).toBe(input)
})

it('handles primitives and null', () => {
expect(clearCredentialInValue(null, TARGET)).toEqual({ value: null, changed: false })
expect(clearCredentialInValue('string', TARGET)).toEqual({ value: 'string', changed: false })
expect(clearCredentialInValue(42, TARGET)).toEqual({ value: 42, changed: false })
})

it('clears multiple references in a single pass', () => {
const input = {
blocks: {
a: { subBlocks: { credential: { id: 'credential', value: TARGET } } },
b: { subBlocks: { triggerCredentials: { id: 'triggerCredentials', value: TARGET } } },
c: {
subBlocks: {
tools: {
id: 'tools',
value: [{ params: { credential: TARGET } }, { params: { credential: TARGET } }],
},
},
},
},
}
const result = clearCredentialInValue(input, TARGET)
expect(result.changed).toBe(true)
const value = result.value as typeof input
expect(value.blocks.a.subBlocks.credential.value).toBe('')
expect(value.blocks.b.subBlocks.triggerCredentials.value).toBe('')
expect(value.blocks.c.subBlocks.tools.value[0].params.credential).toBe('')
expect(value.blocks.c.subBlocks.tools.value[1].params.credential).toBe('')
})
})
Loading
Loading