Skip to content

Commit d1d76bc

Browse files
committed
refactor(credentials): consolidate credential deletion onto one path
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.
1 parent 91c9da5 commit d1d76bc

10 files changed

Lines changed: 53 additions & 150 deletions

File tree

apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -147,21 +147,12 @@ export function ConnectedCredentialDetail({
147147
/**
148148
* Every credential type disconnects through the workspace-scoped credential
149149
* delete, which authorizes against credential admin — explicit members and
150-
* derived workspace admins alike. The personal OAuth disconnect is scoped to
151-
* the acting user's own `account` rows, so routing an admin through it
152-
* silently matched nothing when the connection belonged to a teammate.
150+
* derived workspace admins alike.
153151
*/
154152
const handleConfirmDelete = async () => {
155153
if (!credential) return
156154
try {
157155
await deleteCredential.mutateAsync(credential.id)
158-
if (credential.type === 'oauth' && credential.providerId) {
159-
window.dispatchEvent(
160-
new CustomEvent('oauth-credentials-updated', {
161-
detail: { providerId: credential.providerId, workspaceId },
162-
})
163-
)
164-
}
165156
setShowDeleteConfirmDialog(false)
166157
router.push(integrationsHref)
167158
} catch (error) {

apps/sim/components/permissions/role-lock.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,8 @@ interface RoleLockTooltipProps {
5555
* Wraps a disabled role control in a tooltip explaining why the role is fixed.
5656
* Renders children unchanged when there is no lock reason.
5757
*
58-
* The trigger is a `grid` so the wrapper stays layout-transparent: an unwrapped
59-
* control is a direct grid item of the member row and stretches to that row's
60-
* fixed role track, and a lone grid child stretches identically. An
61-
* `inline-flex` wrapper instead let the control shrink to its label, so locked
62-
* and editable rows of the same list rendered at two different widths.
58+
* The trigger is a `grid` so the wrapper stays layout-transparent; a
59+
* shrink-to-content wrapper would size locked and unlocked rows differently.
6360
*/
6461
export function RoleLockTooltip({ reason, children }: RoleLockTooltipProps) {
6562
if (!reason) return <>{children}</>

apps/sim/hooks/queries/credentials.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
type WorkspaceCredentialType,
2121
} from '@/lib/api/contracts'
2222
import { environmentKeys } from '@/hooks/queries/environment'
23+
import { oauthConnectionsKeys } from '@/hooks/queries/oauth/oauth-connections'
2324
import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys'
2425
import {
2526
fetchWorkspaceCredentialList,
@@ -211,6 +212,7 @@ export function useDeleteWorkspaceCredential() {
211212
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.lists() })
212213
queryClient.invalidateQueries({ queryKey: OAUTH_CREDENTIALS_KEY })
213214
queryClient.invalidateQueries({ queryKey: environmentKeys.all })
215+
queryClient.invalidateQueries({ queryKey: oauthConnectionsKeys.connections() })
214216
},
215217
})
216218
}

apps/sim/hooks/queries/oauth/oauth-connections.ts

Lines changed: 0 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
33
import { requestJson } from '@/lib/api/client/request'
44
import {
55
type ConnectedAccount,
6-
disconnectOAuthContract,
76
listOAuthConnectionsContract,
87
type OAuthAccountSummary,
98
type OAuthConnection,
@@ -199,68 +198,5 @@ export function useConnectOAuthService() {
199198
})
200199
}
201200

202-
interface DisconnectServiceParams {
203-
provider: string
204-
providerId?: string
205-
serviceId: string
206-
accountId?: string
207-
}
208-
209-
/**
210-
* Disconnects an OAuth service account.
211-
* Performs optimistic update and rolls back on failure.
212-
*/
213-
export function useDisconnectOAuthService() {
214-
const queryClient = useQueryClient()
215-
216-
return useMutation({
217-
mutationFn: async ({ provider, providerId, accountId }: DisconnectServiceParams) => {
218-
return requestJson(disconnectOAuthContract, {
219-
body: {
220-
provider,
221-
providerId,
222-
accountId,
223-
},
224-
})
225-
},
226-
onMutate: async ({ serviceId, accountId }) => {
227-
await queryClient.cancelQueries({ queryKey: oauthConnectionsKeys.connections() })
228-
229-
const previousServices = queryClient.getQueryData<ServiceInfo[]>(
230-
oauthConnectionsKeys.connections()
231-
)
232-
233-
if (previousServices) {
234-
queryClient.setQueryData<ServiceInfo[]>(
235-
oauthConnectionsKeys.connections(),
236-
previousServices.map((svc) => {
237-
if (svc.id === serviceId) {
238-
const updatedAccounts =
239-
accountId && svc.accounts ? svc.accounts.filter((acc) => acc.id !== accountId) : []
240-
return {
241-
...svc,
242-
accounts: updatedAccounts,
243-
isConnected: updatedAccounts.length > 0,
244-
}
245-
}
246-
return svc
247-
})
248-
)
249-
}
250-
251-
return { previousServices }
252-
},
253-
onError: (_err, _variables, context) => {
254-
if (context?.previousServices) {
255-
queryClient.setQueryData(oauthConnectionsKeys.connections(), context.previousServices)
256-
}
257-
logger.error('Failed to disconnect service')
258-
},
259-
onSettled: () => {
260-
queryClient.invalidateQueries({ queryKey: oauthConnectionsKeys.connections() })
261-
},
262-
})
263-
}
264-
265201
/** Connected OAuth account for a specific provider. */
266202
export type { ConnectedAccount }

apps/sim/lib/credentials/application/service-account.test.ts

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ const mocks = vi.hoisted(() => ({
1313
requireProvider: vi.fn(),
1414
getCredential: vi.fn(),
1515
getActor: vi.fn(),
16-
delete: vi.fn(),
1716
deleteRecord: vi.fn(),
1817
capture: vi.fn(),
1918
}))
@@ -29,7 +28,6 @@ vi.mock('@sim/platform-authz/workspace', () => ({
2928
}))
3029
vi.mock('@/lib/credentials/orchestration', () => ({
3130
createServiceAccountCredential: mocks.create,
32-
deleteConnectionCredential: mocks.delete,
3331
deleteCredentialRecord: mocks.deleteRecord,
3432
}))
3533
vi.mock('@/lib/credentials/application/provider-catalog', () => ({
@@ -96,7 +94,6 @@ describe('credential service-account application operations', () => {
9694
auditMetadata: { tenantId: 'tenant-1' },
9795
})
9896
mocks.listCatalog.mockResolvedValue([{ providerId: 'zoom-service-account' }])
99-
mocks.delete.mockResolvedValue(true)
10097
mocks.deleteRecord.mockResolvedValue(true)
10198
mocks.requireProvider.mockReturnValue({
10299
type: 'service_account',
@@ -196,7 +193,7 @@ describe('credential service-account application operations', () => {
196193
code: 'forbidden',
197194
detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED',
198195
})
199-
expect(mocks.delete).not.toHaveBeenCalled()
196+
expect(mocks.deleteRecord).not.toHaveBeenCalled()
200197
})
201198

202199
it('rejects workspace keys before canonical loading on disconnect', async () => {
@@ -215,7 +212,7 @@ describe('credential service-account application operations', () => {
215212
})
216213
expect(mocks.loadWorkspace).not.toHaveBeenCalled()
217214
expect(mocks.getActor).not.toHaveBeenCalled()
218-
expect(mocks.delete).not.toHaveBeenCalled()
215+
expect(mocks.deleteRecord).not.toHaveBeenCalled()
219216
})
220217

221218
it('allows an explicit credential admin with workspace read access to disconnect', async () => {
@@ -227,7 +224,7 @@ describe('credential service-account application operations', () => {
227224
input: { workspaceId: WORKSPACE_ID, credentialId: credential.id },
228225
})
229226
).resolves.toEqual({ credential, deleted: true })
230-
expect(mocks.delete).toHaveBeenCalledOnce()
227+
expect(mocks.deleteRecord).toHaveBeenCalledOnce()
231228
})
232229

233230
it('applies credential admin policy during authorization-only checks', async () => {
@@ -237,7 +234,7 @@ describe('credential service-account application operations', () => {
237234
})
238235

239236
expect(mocks.getActor).toHaveBeenCalledWith(credential.id, principal.userId)
240-
expect(mocks.delete).not.toHaveBeenCalled()
237+
expect(mocks.deleteRecord).not.toHaveBeenCalled()
241238
})
242239

243240
it('enforces personal-key workspace policy before credential authorization', async () => {
@@ -253,7 +250,7 @@ describe('credential service-account application operations', () => {
253250
detailCode: 'PERSONAL_API_KEYS_DISABLED',
254251
})
255252
expect(mocks.getActor).not.toHaveBeenCalled()
256-
expect(mocks.delete).not.toHaveBeenCalled()
253+
expect(mocks.deleteRecord).not.toHaveBeenCalled()
257254
})
258255

259256
it('disconnects an administered credential', async () => {
@@ -263,14 +260,10 @@ describe('credential service-account application operations', () => {
263260
})
264261

265262
expect(result).toEqual({ credential, deleted: true })
266-
expect(mocks.delete).toHaveBeenCalledWith({
267-
credentialId: credential.id,
268-
workspaceId: WORKSPACE_ID,
269-
reason: 'user_delete',
270-
})
263+
expect(mocks.deleteRecord).toHaveBeenCalledWith({ credential, reason: 'user_delete' })
271264
})
272265

273-
it('disconnects an oauth credential through the record manager so its grant is revoked', async () => {
266+
it('deletes every type through the record manager so secret sources are torn down', async () => {
274267
const oauthCredential = {
275268
...credential,
276269
type: 'oauth',
@@ -295,11 +288,10 @@ describe('credential service-account application operations', () => {
295288
credential: oauthCredential,
296289
reason: 'user_delete',
297290
})
298-
expect(mocks.delete).not.toHaveBeenCalled()
299291
})
300292

301293
it('treats a concurrent disconnect as an idempotent success', async () => {
302-
mocks.delete.mockResolvedValue(false)
294+
mocks.deleteRecord.mockResolvedValue(false)
303295

304296
const result = await deleteCredentialUseCase.execute({
305297
principal,

apps/sim/lib/credentials/application/service-account.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import {
1515
import {
1616
type CreateServiceAccountCredentialParams,
1717
createServiceAccountCredential,
18-
deleteConnectionCredential,
1918
deleteCredentialRecord,
2019
} from '@/lib/credentials/orchestration'
2120
import type { CredentialRow } from '@/lib/credentials/queries'
@@ -159,18 +158,7 @@ export const deleteCredentialUseCase = defineAuthorizedCredentialUseCase({
159158
)
160159
}
161160
const reason = principal.kind === 'delegated' ? 'copilot_delete' : 'user_delete'
162-
// Every type but `service_account` goes through the record manager so it also
163-
// tears down the credential's secret source — for `oauth` that is the backing
164-
// `account` row, which an admin deleting a teammate's connection must revoke
165-
// too, not just the credential that pointed at it.
166-
const deleted =
167-
context.credential.type === 'service_account'
168-
? await deleteConnectionCredential({
169-
credentialId: context.credential.id,
170-
workspaceId: context.workspaceId,
171-
reason,
172-
})
173-
: await deleteCredentialRecord({ credential: context.credential, reason })
161+
const deleted = await deleteCredentialRecord({ credential: context.credential, reason })
174162
return { credential: context.credential, deleted }
175163
},
176164
projectAudit: ({ principal, result }) => {

apps/sim/lib/credentials/deletion.ts

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { db } from '@sim/db'
33
import * as schema from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
5-
import { and, eq, or, sql } from 'drizzle-orm'
5+
import { and, eq, notExists, or, sql } from 'drizzle-orm'
66
import type { NextRequest } from 'next/server'
77
import { CREDENTIAL_SUBBLOCK_IDS } from '@/lib/workflows/persistence/utils'
88

@@ -102,32 +102,36 @@ export async function deleteConnectionCredential(
102102
}
103103

104104
/**
105-
* Deletes the OAuth grant backing a credential once no credential references it
106-
* any more, and reports whether it went. The `account` row is an OAuth
107-
* credential's secret source, so leaving it behind keeps the provider grant
108-
* alive after the credential it backed is gone.
105+
* Deletes the stored OAuth grant behind a credential once nothing references
106+
* it. The `account` row is an OAuth credential's secret source, so leaving it
107+
* behind keeps a usable grant; nothing is revoked provider-side.
109108
*
110-
* Scoped by `accountId` rather than by owner: the caller has already been
111-
* authorized against the credential, and the grant may belong to a different
112-
* user than the one deleting it (a workspace admin removing a teammate's
113-
* connection). Returns false when another credential still uses the grant.
109+
* Scoped by `accountId`, not by owner — the caller is already authorized
110+
* against the credential, which may belong to another user.
111+
*
112+
* The reference check is a predicate on the delete: `credential.accountId` is
113+
* `ON DELETE CASCADE`, so a credential racing a separate check would be reaped
114+
* by Postgres without {@link clearCredentialRefs} ever running.
114115
*/
115-
export async function deleteOrphanedOAuthAccount(accountId: string): Promise<boolean> {
116-
const [stillReferenced] = await db
117-
.select({ id: schema.credential.id })
118-
.from(schema.credential)
119-
.where(eq(schema.credential.accountId, accountId))
120-
.limit(1)
121-
if (stillReferenced) return false
122-
116+
export async function deleteOrphanedOAuthAccount(accountId: string): Promise<void> {
123117
const deleted = await db
124118
.delete(schema.account)
125-
.where(eq(schema.account.id, accountId))
119+
.where(
120+
and(
121+
eq(schema.account.id, accountId),
122+
notExists(
123+
db
124+
.select({ referenced: sql`1` })
125+
.from(schema.credential)
126+
.where(eq(schema.credential.accountId, accountId))
127+
)
128+
)
129+
)
126130
.returning({ id: schema.account.id })
131+
127132
if (deleted.length > 0) {
128133
logger.info('Deleted orphaned OAuth account', { accountId })
129134
}
130-
return deleted.length > 0
131135
}
132136

133137
/**

apps/sim/lib/credentials/draft-hooks.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as schema from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
55
import { generateId } from '@sim/utils/id'
66
import { and, eq, sql } from 'drizzle-orm'
7+
import { deleteOrphanedOAuthAccount } from '@/lib/credentials/deletion'
78
import { clearDeadFlag } from '@/lib/oauth/terminal-errors'
89
import { captureServerEvent } from '@/lib/posthog/server'
910

@@ -184,15 +185,6 @@ export async function handleReconnectCredential(params: {
184185
})
185186

186187
if (oldAccountId) {
187-
const [stillReferenced] = await db
188-
.select({ id: schema.credential.id })
189-
.from(schema.credential)
190-
.where(eq(schema.credential.accountId, oldAccountId))
191-
.limit(1)
192-
193-
if (!stillReferenced) {
194-
await db.delete(schema.account).where(eq(schema.account.id, oldAccountId))
195-
logger.info('Deleted orphaned account after reconnect', { accountId: oldAccountId })
196-
}
188+
await deleteOrphanedOAuthAccount(oldAccountId)
197189
}
198190
}

apps/sim/lib/credentials/orchestration/index.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -599,7 +599,7 @@ describe('deleteCredentialRecord', () => {
599599
workspaceId: 'ws-1',
600600
type: 'service_account',
601601
providerId: 'google-service-account',
602-
accountId: null,
602+
accountId: 'acct-1',
603603
} as never,
604604
reason: 'user_delete',
605605
})

apps/sim/lib/credentials/orchestration/index.ts

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -558,22 +558,23 @@ export async function deleteCredentialRecord(
558558
return true
559559
}
560560

561-
const deleted = await deleteConnectionCredential({
561+
if (credentialRow.type === 'oauth') {
562+
const deleted = await deleteConnectionCredential({
563+
credentialId: credentialRow.id,
564+
workspaceId: credentialRow.workspaceId,
565+
reason: params.reason,
566+
})
567+
if (deleted && credentialRow.accountId) {
568+
await deleteOrphanedOAuthAccount(credentialRow.accountId)
569+
}
570+
return deleted
571+
}
572+
573+
return deleteConnectionCredential({
562574
credentialId: credentialRow.id,
563575
workspaceId: credentialRow.workspaceId,
564576
reason: params.reason,
565577
})
566-
567-
// An OAuth credential's secret source is its `account` row, so deleting the
568-
// credential alone would leave the provider grant behind. Revoke it here
569-
// rather than only on the personal disconnect path, so an admin removing a
570-
// teammate's connection tears down the same state the owner's own disconnect
571-
// would. Idempotent for the disconnect path, which sweeps accounts after.
572-
if (deleted && credentialRow.type === 'oauth' && credentialRow.accountId) {
573-
await deleteOrphanedOAuthAccount(credentialRow.accountId)
574-
}
575-
576-
return deleted
577578
}
578579

579580
/** Preserves the legacy callers while application adapters migrate to the manager above. */

0 commit comments

Comments
 (0)