From 91c9da5d3f4c96b2e071963e5683763964b3e303 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 15 Aug 2026 11:54:24 -0700 Subject: [PATCH 1/2] fix(credentials): let workspace admins disconnect a teammate's OAuth credential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disconnecting an OAuth credential routed through POST /api/auth/oauth/disconnect, a credential *user* operation scoped to the acting user's own `account` rows. A workspace or org admin acting on a teammate's connection matched no accounts, so the call returned `{ credentials: [] }` and the route still answered 200 — the UI navigated away and the credential was still there. Not a denial, a silent no-op. Route every type through the workspace-scoped credential delete instead, which authorizes against credential admin and already resolves workspace and org admins as derived credential admins for shared credential types. That path only deleted the `credential` row, so send `oauth` through `deleteCredentialRecord` — the manager that also tears down a credential's secret source — and teach it to revoke the backing `account` grant once no credential references it. Scoped by account id, not owner: the caller is already authorized against the credential, and the grant belongs to the teammate. Also make RoleLockTooltip layout-transparent. It wrapped locked controls in an `inline-flex` div, which let the chip shrink to its label while unwrapped controls stretched to the member row's fixed role track — so a credential's own members list rendered Admin at two different widths. A `grid` wrapper stretches like the unwrapped control, aligning the credential, secrets, and skills member lists that share the row. --- .../connected-credential-detail.tsx | 30 ++++------ apps/sim/components/permissions/role-lock.tsx | 8 ++- .../application/service-account.test.ts | 28 ++++++++++ .../application/service-account.ts | 6 +- apps/sim/lib/credentials/deletion.ts | 29 ++++++++++ .../credentials/orchestration/index.test.ts | 56 +++++++++++++++++++ .../lib/credentials/orchestration/index.ts | 19 ++++++- 7 files changed, 153 insertions(+), 23 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx index 2b1ae99f392..79981c1645b 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx @@ -46,7 +46,6 @@ import { } from '@/hooks/queries/credentials' import { useConnectOAuthService, - useDisconnectOAuthService, useOAuthConnections, } from '@/hooks/queries/oauth/oauth-connections' import { useOAuthReturnRouter } from '@/hooks/use-oauth-return' @@ -74,7 +73,6 @@ export function ConnectedCredentialDetail({ const { data: oauthConnections = [] } = useOAuthConnections() const connectOAuthService = useConnectOAuthService() - const disconnectOAuthService = useDisconnectOAuthService() const createDraft = useCreateCredentialDraft() const deleteCredential = useDeleteWorkspaceCredential() @@ -146,24 +144,18 @@ export function ConnectedCredentialDetail({ } } + /** + * Every credential type disconnects through the workspace-scoped credential + * delete, which authorizes against credential admin — explicit members and + * derived workspace admins alike. The personal OAuth disconnect is scoped to + * the acting user's own `account` rows, so routing an admin through it + * silently matched nothing when the connection belonged to a teammate. + */ const handleConfirmDelete = async () => { if (!credential) return try { - if (credential.type === 'service_account') { - await deleteCredential.mutateAsync(credential.id) - } else { - if (!credential.accountId || !credential.providerId) { - toast.error("Can't disconnect", { - description: 'Missing account information. Try reconnecting this credential first.', - }) - return - } - await disconnectOAuthService.mutateAsync({ - provider: credential.providerId.split('-')[0] || credential.providerId, - providerId: credential.providerId, - serviceId: credential.providerId, - accountId: credential.accountId, - }) + await deleteCredential.mutateAsync(credential.id) + if (credential.type === 'oauth' && credential.providerId) { window.dispatchEvent( new CustomEvent('oauth-credentials-updated', { detail: { providerId: credential.providerId, workspaceId }, @@ -207,7 +199,7 @@ export function ConnectedCredentialDetail({ setShowDeleteConfirmDialog(true)} - disabled={disconnectOAuthService.isPending || deleteCredential.isPending} + disabled={deleteCredential.isPending} > Disconnect @@ -303,7 +295,7 @@ export function ConnectedCredentialDetail({ confirm={{ label: 'Disconnect', onClick: handleConfirmDelete, - pending: disconnectOAuthService.isPending || deleteCredential.isPending, + pending: deleteCredential.isPending, pendingLabel: 'Disconnecting...', }} /> diff --git a/apps/sim/components/permissions/role-lock.tsx b/apps/sim/components/permissions/role-lock.tsx index a3f6af078c1..eedf7edbac7 100644 --- a/apps/sim/components/permissions/role-lock.tsx +++ b/apps/sim/components/permissions/role-lock.tsx @@ -54,6 +54,12 @@ interface RoleLockTooltipProps { /** * Wraps a disabled role control in a tooltip explaining why the role is fixed. * Renders children unchanged when there is no lock reason. + * + * The trigger is a `grid` so the wrapper stays layout-transparent: an unwrapped + * control is a direct grid item of the member row and stretches to that row's + * fixed role track, and a lone grid child stretches identically. An + * `inline-flex` wrapper instead let the control shrink to its label, so locked + * and editable rows of the same list rendered at two different widths. */ export function RoleLockTooltip({ reason, children }: RoleLockTooltipProps) { if (!reason) return <>{children} @@ -61,7 +67,7 @@ export function RoleLockTooltip({ reason, children }: RoleLockTooltipProps) { return ( -
{children}
+
{children}
{reason}
diff --git a/apps/sim/lib/credentials/application/service-account.test.ts b/apps/sim/lib/credentials/application/service-account.test.ts index 6dc2ece8096..22ae04ccab7 100644 --- a/apps/sim/lib/credentials/application/service-account.test.ts +++ b/apps/sim/lib/credentials/application/service-account.test.ts @@ -270,6 +270,34 @@ describe('credential service-account application operations', () => { }) }) + it('disconnects an oauth credential through the record manager so its grant is revoked', async () => { + const oauthCredential = { + ...credential, + type: 'oauth', + providerId: 'google-email', + accountId: 'acct-1', + } + mocks.getCredential.mockResolvedValue(oauthCredential) + mocks.getActor.mockResolvedValue({ + credential: oauthCredential, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + + const result = await deleteCredentialUseCase.execute({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: oauthCredential.id }, + }) + + expect(result).toEqual({ credential: oauthCredential, deleted: true }) + expect(mocks.deleteRecord).toHaveBeenCalledWith({ + credential: oauthCredential, + reason: 'user_delete', + }) + expect(mocks.delete).not.toHaveBeenCalled() + }) + it('treats a concurrent disconnect as an idempotent success', async () => { mocks.delete.mockResolvedValue(false) diff --git a/apps/sim/lib/credentials/application/service-account.ts b/apps/sim/lib/credentials/application/service-account.ts index 078cb50f04d..8f1cf8f696b 100644 --- a/apps/sim/lib/credentials/application/service-account.ts +++ b/apps/sim/lib/credentials/application/service-account.ts @@ -159,8 +159,12 @@ export const deleteCredentialUseCase = defineAuthorizedCredentialUseCase({ ) } const reason = principal.kind === 'delegated' ? 'copilot_delete' : 'user_delete' + // Every type but `service_account` goes through the record manager so it also + // tears down the credential's secret source — for `oauth` that is the backing + // `account` row, which an admin deleting a teammate's connection must revoke + // too, not just the credential that pointed at it. const deleted = - context.credential.type === 'oauth' || context.credential.type === 'service_account' + context.credential.type === 'service_account' ? await deleteConnectionCredential({ credentialId: context.credential.id, workspaceId: context.workspaceId, diff --git a/apps/sim/lib/credentials/deletion.ts b/apps/sim/lib/credentials/deletion.ts index f16902ddf04..50b70c315f6 100644 --- a/apps/sim/lib/credentials/deletion.ts +++ b/apps/sim/lib/credentials/deletion.ts @@ -101,6 +101,35 @@ export async function deleteConnectionCredential( return deleted.length === 1 } +/** + * Deletes the OAuth grant backing a credential once no credential references it + * any more, and reports whether it went. The `account` row is an OAuth + * credential's secret source, so leaving it behind keeps the provider grant + * alive after the credential it backed is gone. + * + * Scoped by `accountId` rather than by owner: the caller has already been + * authorized against the credential, and the grant may belong to a different + * user than the one deleting it (a workspace admin removing a teammate's + * connection). Returns false when another credential still uses the grant. + */ +export async function deleteOrphanedOAuthAccount(accountId: string): Promise { + const [stillReferenced] = await db + .select({ id: schema.credential.id }) + .from(schema.credential) + .where(eq(schema.credential.accountId, accountId)) + .limit(1) + if (stillReferenced) return false + + const deleted = await db + .delete(schema.account) + .where(eq(schema.account.id, accountId)) + .returning({ id: schema.account.id }) + if (deleted.length > 0) { + logger.info('Deleted orphaned OAuth account', { accountId }) + } + return deleted.length > 0 +} + /** * Clears stored references to a credential across mutable workspace state * (editor blocks, copilot checkpoints, knowledge connectors) and frozen diff --git a/apps/sim/lib/credentials/orchestration/index.test.ts b/apps/sim/lib/credentials/orchestration/index.test.ts index 8ea6c8954b8..392ff005f54 100644 --- a/apps/sim/lib/credentials/orchestration/index.test.ts +++ b/apps/sim/lib/credentials/orchestration/index.test.ts @@ -18,6 +18,7 @@ const { mockIsClientCredentialAccountProviderId, mockGetClientCredentialAccountDescriptor, mockDeleteConnectionCredential, + mockDeleteOrphanedOAuthAccount, } = vi.hoisted(() => ({ mockRecordAudit: vi.fn(), mockGetCredentialActorContext: vi.fn(), @@ -28,6 +29,7 @@ const { // providers must not trigger the stored-blob read for authMethod/username. mockGetClientCredentialAccountDescriptor: vi.fn(() => undefined), mockDeleteConnectionCredential: vi.fn(), + mockDeleteOrphanedOAuthAccount: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -51,6 +53,7 @@ vi.mock('@/lib/credentials/client-credential-accounts/descriptors', () => ({ })) vi.mock('@/lib/credentials/deletion', () => ({ deleteConnectionCredential: mockDeleteConnectionCredential, + deleteOrphanedOAuthAccount: mockDeleteOrphanedOAuthAccount, })) vi.mock('@/lib/credentials/environment', () => ({ deleteWorkspaceEnvCredentials: vi.fn(), @@ -550,4 +553,57 @@ describe('deleteCredentialRecord', () => { }) expect(mockDeleteConnectionCredential).not.toHaveBeenCalled() }) + + it('revokes the backing OAuth grant of a deleted oauth credential', async () => { + mockDeleteConnectionCredential.mockResolvedValueOnce(true) + + const deleted = await deleteCredentialRecord({ + credential: { + id: 'cred-1', + workspaceId: 'ws-1', + type: 'oauth', + providerId: 'google-email', + accountId: 'acct-1', + } as never, + reason: 'user_delete', + }) + + expect(deleted).toBe(true) + expect(mockDeleteOrphanedOAuthAccount).toHaveBeenCalledWith('acct-1') + }) + + it('leaves the OAuth grant alone when the credential row was already gone', async () => { + mockDeleteConnectionCredential.mockResolvedValueOnce(false) + + const deleted = await deleteCredentialRecord({ + credential: { + id: 'cred-1', + workspaceId: 'ws-1', + type: 'oauth', + providerId: 'google-email', + accountId: 'acct-1', + } as never, + reason: 'user_delete', + }) + + expect(deleted).toBe(false) + expect(mockDeleteOrphanedOAuthAccount).not.toHaveBeenCalled() + }) + + it('does not touch OAuth grants for a service-account credential', async () => { + mockDeleteConnectionCredential.mockResolvedValueOnce(true) + + await deleteCredentialRecord({ + credential: { + id: 'cred-1', + workspaceId: 'ws-1', + type: 'service_account', + providerId: 'google-service-account', + accountId: null, + } as never, + reason: 'user_delete', + }) + + expect(mockDeleteOrphanedOAuthAccount).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index 84fd54888fb..a0fc7c94132 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -24,7 +24,11 @@ import { getClientCredentialAccountDescriptor, isClientCredentialAccountProviderId, } from '@/lib/credentials/client-credential-accounts/descriptors' -import { type CredentialDeleteReason, deleteConnectionCredential } from '@/lib/credentials/deletion' +import { + type CredentialDeleteReason, + deleteConnectionCredential, + deleteOrphanedOAuthAccount, +} from '@/lib/credentials/deletion' import { slackCustomBotDisplayName } from '@/lib/credentials/display-name' import { deleteWorkspaceEnvCredentials, @@ -554,11 +558,22 @@ export async function deleteCredentialRecord( return true } - return deleteConnectionCredential({ + const deleted = await deleteConnectionCredential({ credentialId: credentialRow.id, workspaceId: credentialRow.workspaceId, reason: params.reason, }) + + // An OAuth credential's secret source is its `account` row, so deleting the + // credential alone would leave the provider grant behind. Revoke it here + // rather than only on the personal disconnect path, so an admin removing a + // teammate's connection tears down the same state the owner's own disconnect + // would. Idempotent for the disconnect path, which sweeps accounts after. + if (deleted && credentialRow.type === 'oauth' && credentialRow.accountId) { + await deleteOrphanedOAuthAccount(credentialRow.accountId) + } + + return deleted } /** Preserves the legacy callers while application adapters migrate to the manager above. */ From d1d76bca1519d61ece5e0cdd7d2542fb75c21a98 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 15 Aug 2026 14:00:58 -0700 Subject: [PATCH 2/2] refactor(credentials): consolidate credential deletion onto one path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups from review of the previous commit. - Collapse the delete use case's remaining `service_account` carve-out. Both ternary arms reached the same `deleteConnectionCredential` tail, but the carve-out skipped `deleteCredentialRecord`'s Slack custom-bot guard — and custom bots are exactly the type it guards, so the single-delete surface could orphan a credential group that the batch path refuses to. - Make `deleteOrphanedOAuthAccount` one conditional statement instead of a read then a write. `credential.accountId` is ON DELETE CASCADE, so a credential racing the gap would have been reaped by Postgres without `clearCredentialRefs` running, stranding its id in workflow state. - Give oauth its own branch in `deleteCredentialRecord`, matching the shape the env types already use, rather than a conditional tail after the return. - Point `handleReconnectCredential` at the shared helper; it carried its own copy of the same orphan-grant rule. - Invalidate the OAuth connections query on credential delete. The removed disconnect hook owned that invalidation, and the detail page reads it. This also retires the hand-dispatched `oauth-credentials-updated` event from the delete path; the connect/reconnect path still dispatches it for its listener. - Delete `useDisconnectOAuthService`, now callerless. - Trim comments to the behavior rather than the bug that motivated it. --- .../connected-credential-detail.tsx | 11 +--- apps/sim/components/permissions/role-lock.tsx | 7 +- apps/sim/hooks/queries/credentials.ts | 2 + .../hooks/queries/oauth/oauth-connections.ts | 64 ------------------- .../application/service-account.test.ts | 24 +++---- .../application/service-account.ts | 14 +--- apps/sim/lib/credentials/deletion.ts | 42 ++++++------ apps/sim/lib/credentials/draft-hooks.ts | 12 +--- .../credentials/orchestration/index.test.ts | 2 +- .../lib/credentials/orchestration/index.ts | 25 ++++---- 10 files changed, 53 insertions(+), 150 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx index 79981c1645b..0a0962db7e6 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx @@ -147,21 +147,12 @@ export function ConnectedCredentialDetail({ /** * Every credential type disconnects through the workspace-scoped credential * delete, which authorizes against credential admin — explicit members and - * derived workspace admins alike. The personal OAuth disconnect is scoped to - * the acting user's own `account` rows, so routing an admin through it - * silently matched nothing when the connection belonged to a teammate. + * derived workspace admins alike. */ const handleConfirmDelete = async () => { if (!credential) return try { await deleteCredential.mutateAsync(credential.id) - if (credential.type === 'oauth' && credential.providerId) { - window.dispatchEvent( - new CustomEvent('oauth-credentials-updated', { - detail: { providerId: credential.providerId, workspaceId }, - }) - ) - } setShowDeleteConfirmDialog(false) router.push(integrationsHref) } catch (error) { diff --git a/apps/sim/components/permissions/role-lock.tsx b/apps/sim/components/permissions/role-lock.tsx index eedf7edbac7..4b6fed19f04 100644 --- a/apps/sim/components/permissions/role-lock.tsx +++ b/apps/sim/components/permissions/role-lock.tsx @@ -55,11 +55,8 @@ interface RoleLockTooltipProps { * Wraps a disabled role control in a tooltip explaining why the role is fixed. * Renders children unchanged when there is no lock reason. * - * The trigger is a `grid` so the wrapper stays layout-transparent: an unwrapped - * control is a direct grid item of the member row and stretches to that row's - * fixed role track, and a lone grid child stretches identically. An - * `inline-flex` wrapper instead let the control shrink to its label, so locked - * and editable rows of the same list rendered at two different widths. + * The trigger is a `grid` so the wrapper stays layout-transparent; a + * shrink-to-content wrapper would size locked and unlocked rows differently. */ export function RoleLockTooltip({ reason, children }: RoleLockTooltipProps) { if (!reason) return <>{children} diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index 8d931e4e20e..3f0dceb37a3 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -20,6 +20,7 @@ import { type WorkspaceCredentialType, } from '@/lib/api/contracts' import { environmentKeys } from '@/hooks/queries/environment' +import { oauthConnectionsKeys } from '@/hooks/queries/oauth/oauth-connections' import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' import { fetchWorkspaceCredentialList, @@ -211,6 +212,7 @@ export function useDeleteWorkspaceCredential() { queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.lists() }) queryClient.invalidateQueries({ queryKey: OAUTH_CREDENTIALS_KEY }) queryClient.invalidateQueries({ queryKey: environmentKeys.all }) + queryClient.invalidateQueries({ queryKey: oauthConnectionsKeys.connections() }) }, }) } diff --git a/apps/sim/hooks/queries/oauth/oauth-connections.ts b/apps/sim/hooks/queries/oauth/oauth-connections.ts index e3a5becbc0b..a1fa1e29813 100644 --- a/apps/sim/hooks/queries/oauth/oauth-connections.ts +++ b/apps/sim/hooks/queries/oauth/oauth-connections.ts @@ -3,7 +3,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { type ConnectedAccount, - disconnectOAuthContract, listOAuthConnectionsContract, type OAuthAccountSummary, type OAuthConnection, @@ -199,68 +198,5 @@ export function useConnectOAuthService() { }) } -interface DisconnectServiceParams { - provider: string - providerId?: string - serviceId: string - accountId?: string -} - -/** - * Disconnects an OAuth service account. - * Performs optimistic update and rolls back on failure. - */ -export function useDisconnectOAuthService() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: async ({ provider, providerId, accountId }: DisconnectServiceParams) => { - return requestJson(disconnectOAuthContract, { - body: { - provider, - providerId, - accountId, - }, - }) - }, - onMutate: async ({ serviceId, accountId }) => { - await queryClient.cancelQueries({ queryKey: oauthConnectionsKeys.connections() }) - - const previousServices = queryClient.getQueryData( - oauthConnectionsKeys.connections() - ) - - if (previousServices) { - queryClient.setQueryData( - oauthConnectionsKeys.connections(), - previousServices.map((svc) => { - if (svc.id === serviceId) { - const updatedAccounts = - accountId && svc.accounts ? svc.accounts.filter((acc) => acc.id !== accountId) : [] - return { - ...svc, - accounts: updatedAccounts, - isConnected: updatedAccounts.length > 0, - } - } - return svc - }) - ) - } - - return { previousServices } - }, - onError: (_err, _variables, context) => { - if (context?.previousServices) { - queryClient.setQueryData(oauthConnectionsKeys.connections(), context.previousServices) - } - logger.error('Failed to disconnect service') - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: oauthConnectionsKeys.connections() }) - }, - }) -} - /** Connected OAuth account for a specific provider. */ export type { ConnectedAccount } diff --git a/apps/sim/lib/credentials/application/service-account.test.ts b/apps/sim/lib/credentials/application/service-account.test.ts index 22ae04ccab7..dbdf310231e 100644 --- a/apps/sim/lib/credentials/application/service-account.test.ts +++ b/apps/sim/lib/credentials/application/service-account.test.ts @@ -13,7 +13,6 @@ const mocks = vi.hoisted(() => ({ requireProvider: vi.fn(), getCredential: vi.fn(), getActor: vi.fn(), - delete: vi.fn(), deleteRecord: vi.fn(), capture: vi.fn(), })) @@ -29,7 +28,6 @@ vi.mock('@sim/platform-authz/workspace', () => ({ })) vi.mock('@/lib/credentials/orchestration', () => ({ createServiceAccountCredential: mocks.create, - deleteConnectionCredential: mocks.delete, deleteCredentialRecord: mocks.deleteRecord, })) vi.mock('@/lib/credentials/application/provider-catalog', () => ({ @@ -96,7 +94,6 @@ describe('credential service-account application operations', () => { auditMetadata: { tenantId: 'tenant-1' }, }) mocks.listCatalog.mockResolvedValue([{ providerId: 'zoom-service-account' }]) - mocks.delete.mockResolvedValue(true) mocks.deleteRecord.mockResolvedValue(true) mocks.requireProvider.mockReturnValue({ type: 'service_account', @@ -196,7 +193,7 @@ describe('credential service-account application operations', () => { code: 'forbidden', detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', }) - expect(mocks.delete).not.toHaveBeenCalled() + expect(mocks.deleteRecord).not.toHaveBeenCalled() }) it('rejects workspace keys before canonical loading on disconnect', async () => { @@ -215,7 +212,7 @@ describe('credential service-account application operations', () => { }) expect(mocks.loadWorkspace).not.toHaveBeenCalled() expect(mocks.getActor).not.toHaveBeenCalled() - expect(mocks.delete).not.toHaveBeenCalled() + expect(mocks.deleteRecord).not.toHaveBeenCalled() }) it('allows an explicit credential admin with workspace read access to disconnect', async () => { @@ -227,7 +224,7 @@ describe('credential service-account application operations', () => { input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, }) ).resolves.toEqual({ credential, deleted: true }) - expect(mocks.delete).toHaveBeenCalledOnce() + expect(mocks.deleteRecord).toHaveBeenCalledOnce() }) it('applies credential admin policy during authorization-only checks', async () => { @@ -237,7 +234,7 @@ describe('credential service-account application operations', () => { }) expect(mocks.getActor).toHaveBeenCalledWith(credential.id, principal.userId) - expect(mocks.delete).not.toHaveBeenCalled() + expect(mocks.deleteRecord).not.toHaveBeenCalled() }) it('enforces personal-key workspace policy before credential authorization', async () => { @@ -253,7 +250,7 @@ describe('credential service-account application operations', () => { detailCode: 'PERSONAL_API_KEYS_DISABLED', }) expect(mocks.getActor).not.toHaveBeenCalled() - expect(mocks.delete).not.toHaveBeenCalled() + expect(mocks.deleteRecord).not.toHaveBeenCalled() }) it('disconnects an administered credential', async () => { @@ -263,14 +260,10 @@ describe('credential service-account application operations', () => { }) expect(result).toEqual({ credential, deleted: true }) - expect(mocks.delete).toHaveBeenCalledWith({ - credentialId: credential.id, - workspaceId: WORKSPACE_ID, - reason: 'user_delete', - }) + expect(mocks.deleteRecord).toHaveBeenCalledWith({ credential, reason: 'user_delete' }) }) - it('disconnects an oauth credential through the record manager so its grant is revoked', async () => { + it('deletes every type through the record manager so secret sources are torn down', async () => { const oauthCredential = { ...credential, type: 'oauth', @@ -295,11 +288,10 @@ describe('credential service-account application operations', () => { credential: oauthCredential, reason: 'user_delete', }) - expect(mocks.delete).not.toHaveBeenCalled() }) it('treats a concurrent disconnect as an idempotent success', async () => { - mocks.delete.mockResolvedValue(false) + mocks.deleteRecord.mockResolvedValue(false) const result = await deleteCredentialUseCase.execute({ principal, diff --git a/apps/sim/lib/credentials/application/service-account.ts b/apps/sim/lib/credentials/application/service-account.ts index 8f1cf8f696b..fe3c058ba77 100644 --- a/apps/sim/lib/credentials/application/service-account.ts +++ b/apps/sim/lib/credentials/application/service-account.ts @@ -15,7 +15,6 @@ import { import { type CreateServiceAccountCredentialParams, createServiceAccountCredential, - deleteConnectionCredential, deleteCredentialRecord, } from '@/lib/credentials/orchestration' import type { CredentialRow } from '@/lib/credentials/queries' @@ -159,18 +158,7 @@ export const deleteCredentialUseCase = defineAuthorizedCredentialUseCase({ ) } const reason = principal.kind === 'delegated' ? 'copilot_delete' : 'user_delete' - // Every type but `service_account` goes through the record manager so it also - // tears down the credential's secret source — for `oauth` that is the backing - // `account` row, which an admin deleting a teammate's connection must revoke - // too, not just the credential that pointed at it. - const deleted = - context.credential.type === 'service_account' - ? await deleteConnectionCredential({ - credentialId: context.credential.id, - workspaceId: context.workspaceId, - reason, - }) - : await deleteCredentialRecord({ credential: context.credential, reason }) + const deleted = await deleteCredentialRecord({ credential: context.credential, reason }) return { credential: context.credential, deleted } }, projectAudit: ({ principal, result }) => { diff --git a/apps/sim/lib/credentials/deletion.ts b/apps/sim/lib/credentials/deletion.ts index 50b70c315f6..f2b8d866c29 100644 --- a/apps/sim/lib/credentials/deletion.ts +++ b/apps/sim/lib/credentials/deletion.ts @@ -2,7 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import * as schema from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, or, sql } from 'drizzle-orm' +import { and, eq, notExists, or, sql } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { CREDENTIAL_SUBBLOCK_IDS } from '@/lib/workflows/persistence/utils' @@ -102,32 +102,36 @@ export async function deleteConnectionCredential( } /** - * Deletes the OAuth grant backing a credential once no credential references it - * any more, and reports whether it went. The `account` row is an OAuth - * credential's secret source, so leaving it behind keeps the provider grant - * alive after the credential it backed is gone. + * Deletes the stored OAuth grant behind a credential once nothing references + * it. The `account` row is an OAuth credential's secret source, so leaving it + * behind keeps a usable grant; nothing is revoked provider-side. * - * Scoped by `accountId` rather than by owner: the caller has already been - * authorized against the credential, and the grant may belong to a different - * user than the one deleting it (a workspace admin removing a teammate's - * connection). Returns false when another credential still uses the grant. + * Scoped by `accountId`, not by owner — the caller is already authorized + * against the credential, which may belong to another user. + * + * The reference check is a predicate on the delete: `credential.accountId` is + * `ON DELETE CASCADE`, so a credential racing a separate check would be reaped + * by Postgres without {@link clearCredentialRefs} ever running. */ -export async function deleteOrphanedOAuthAccount(accountId: string): Promise { - const [stillReferenced] = await db - .select({ id: schema.credential.id }) - .from(schema.credential) - .where(eq(schema.credential.accountId, accountId)) - .limit(1) - if (stillReferenced) return false - +export async function deleteOrphanedOAuthAccount(accountId: string): Promise { const deleted = await db .delete(schema.account) - .where(eq(schema.account.id, accountId)) + .where( + and( + eq(schema.account.id, accountId), + notExists( + db + .select({ referenced: sql`1` }) + .from(schema.credential) + .where(eq(schema.credential.accountId, accountId)) + ) + ) + ) .returning({ id: schema.account.id }) + if (deleted.length > 0) { logger.info('Deleted orphaned OAuth account', { accountId }) } - return deleted.length > 0 } /** diff --git a/apps/sim/lib/credentials/draft-hooks.ts b/apps/sim/lib/credentials/draft-hooks.ts index 704a22c25fb..cec4f1e607a 100644 --- a/apps/sim/lib/credentials/draft-hooks.ts +++ b/apps/sim/lib/credentials/draft-hooks.ts @@ -4,6 +4,7 @@ import * as schema from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, sql } from 'drizzle-orm' +import { deleteOrphanedOAuthAccount } from '@/lib/credentials/deletion' import { clearDeadFlag } from '@/lib/oauth/terminal-errors' import { captureServerEvent } from '@/lib/posthog/server' @@ -184,15 +185,6 @@ export async function handleReconnectCredential(params: { }) if (oldAccountId) { - const [stillReferenced] = await db - .select({ id: schema.credential.id }) - .from(schema.credential) - .where(eq(schema.credential.accountId, oldAccountId)) - .limit(1) - - if (!stillReferenced) { - await db.delete(schema.account).where(eq(schema.account.id, oldAccountId)) - logger.info('Deleted orphaned account after reconnect', { accountId: oldAccountId }) - } + await deleteOrphanedOAuthAccount(oldAccountId) } } diff --git a/apps/sim/lib/credentials/orchestration/index.test.ts b/apps/sim/lib/credentials/orchestration/index.test.ts index 392ff005f54..47064858db5 100644 --- a/apps/sim/lib/credentials/orchestration/index.test.ts +++ b/apps/sim/lib/credentials/orchestration/index.test.ts @@ -599,7 +599,7 @@ describe('deleteCredentialRecord', () => { workspaceId: 'ws-1', type: 'service_account', providerId: 'google-service-account', - accountId: null, + accountId: 'acct-1', } as never, reason: 'user_delete', }) diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index a0fc7c94132..2ca86cb8b07 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -558,22 +558,23 @@ export async function deleteCredentialRecord( return true } - const deleted = await deleteConnectionCredential({ + if (credentialRow.type === 'oauth') { + const deleted = await deleteConnectionCredential({ + credentialId: credentialRow.id, + workspaceId: credentialRow.workspaceId, + reason: params.reason, + }) + if (deleted && credentialRow.accountId) { + await deleteOrphanedOAuthAccount(credentialRow.accountId) + } + return deleted + } + + return deleteConnectionCredential({ credentialId: credentialRow.id, workspaceId: credentialRow.workspaceId, reason: params.reason, }) - - // An OAuth credential's secret source is its `account` row, so deleting the - // credential alone would leave the provider grant behind. Revoke it here - // rather than only on the personal disconnect path, so an admin removing a - // teammate's connection tears down the same state the owner's own disconnect - // would. Idempotent for the disconnect path, which sweeps accounts after. - if (deleted && credentialRow.type === 'oauth' && credentialRow.accountId) { - await deleteOrphanedOAuthAccount(credentialRow.accountId) - } - - return deleted } /** Preserves the legacy callers while application adapters migrate to the manager above. */