Skip to content

Commit 52a3386

Browse files
committed
fix(slack): serialize Search bot removal with member setup, refuse a custom row on the shared app ID
1 parent 5bfd88d commit 52a3386

5 files changed

Lines changed: 112 additions & 44 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/changelog-empty-state.tsx‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,8 @@ interface Release {
2424

2525
const LATEST: Release = { top: 10, notes: [118, 92] }
2626

27-
const EARLIER: readonly Release[] = [
28-
{ top: 70, notes: [104, 126, 80] },
29-
{ top: 142, notes: [96, 112] },
30-
] as const
27+
/** Every release ends inside the 148px viewBox; the fade carries the timeline past it. */
28+
const EARLIER: readonly Release[] = [{ top: 70, notes: [104, 126, 80] }] as const
3129

3230
const SPINE_X = 24
3331
const NOTE_PITCH = 12

‎apps/sim/lib/credential-groups/__integration__/slack-shared-member-accounts.integration.ts‎

Lines changed: 84 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,12 @@ import { SLACK_SEARCH_USER_SCOPES } from '@/lib/credential-groups/slack-managed-
4444
import { slackCredentialGroupProviderAdapter } from '@/lib/credential-groups/slack-provider'
4545
import { createOrganizationAccountsGroup } from '@/lib/credential-groups/workspace-accounts'
4646
import { encryptManagedOAuthTokenSet } from '@/lib/credentials/managed-oauth'
47-
import type { DbOrTx } from '@/lib/db/types'
47+
import type { DbOrTx, DbTransaction } from '@/lib/db/types'
4848
import { approveSearchIntegration } from '@/lib/knowledge/application/search-integrations'
49-
import { listSlackSearchInstallations } from '@/lib/knowledge/application/slack-search/installations'
49+
import {
50+
listSlackSearchInstallations,
51+
removeSlackSearchInstallation,
52+
} from '@/lib/knowledge/application/slack-search/installations'
5053
import { revokeSlackSearchAccess } from '@/lib/knowledge/application/slack-search/lifecycle'
5154
import { defaultLiveSearchPolicy } from '@/lib/sim-search/live/policy-schema'
5255
import { getSharedSlackSearchAppConfiguration } from '@/lib/slack-search/shared-app-env'
@@ -181,8 +184,9 @@ describe('Slack member accounts on the shared app without a Search bot', () => {
181184
createdBy: ids.owner,
182185
encryptedServiceAccountKey: secret,
183186
})
187+
const installationId = generateId()
184188
await executor.insert(slackSearchInstallation).values({
185-
id: generateId(),
189+
id: installationId,
186190
organizationId: ids.organization,
187191
credentialId: botId,
188192
appId,
@@ -193,6 +197,7 @@ describe('Slack member accounts on the shared app without a Search bot', () => {
193197
credentialVersion: generateId(),
194198
revision: generateId(),
195199
})
200+
return installationId
196201
}
197202

198203
async function accountsGroup() {
@@ -203,6 +208,40 @@ describe('Slack member accounts on the shared app without a Search bot', () => {
203208
return group
204209
}
205210

211+
/** Holds the Slack organization lock until `contend` is observed waiting on it, then releases. */
212+
async function whileOrganizationSlackAppLocked<T>(
213+
contend: () => Promise<T>,
214+
holdWith: (tx: DbTransaction) => Promise<unknown> = async () => undefined
215+
): Promise<T> {
216+
const locked = createDeferred<number>()
217+
const release = createDeferred<void>()
218+
const holder = db.transaction(async (tx) => {
219+
await lockOrganizationSlackApp(tx, ids.organization)
220+
await holdWith(tx)
221+
const [connection] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`)
222+
locked.resolve(connection.pid)
223+
await release.promise
224+
})
225+
const holderPid = await locked.promise
226+
const contender = contend()
227+
contender.catch(() => undefined)
228+
try {
229+
await vi.waitFor(
230+
async () => {
231+
const [waiting] = await db.execute<{ pid: number }>(
232+
sql`SELECT pid FROM pg_stat_activity WHERE ${holderPid} = ANY(pg_blocking_pids(pid))`
233+
)
234+
expect(waiting).toBeDefined()
235+
},
236+
{ timeout: 5_000 }
237+
)
238+
} finally {
239+
release.resolve()
240+
await holder
241+
}
242+
return contender
243+
}
244+
206245
function provideSlackConsent(teamId: string) {
207246
restoreSlackHttp?.()
208247
const spy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
@@ -345,34 +384,50 @@ describe('Slack member accounts on the shared app without a Search bot', () => {
345384
const pending = new URL((await startSetup(groupId)).authorizationUrl)
346385
const before = await accountsGroup()
347386
provideSlackConsent(MEMBER_TEAM)
348-
const locked = createDeferred<number>()
349-
const release = createDeferred<void>()
350-
const install = db.transaction(async (tx) => {
351-
await lockOrganizationSlackApp(tx, ids.organization)
352-
await installBot(CUSTOM_APP_ID, OTHER_TEAM, tx)
353-
const [connection] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`)
354-
locked.resolve(connection.pid)
355-
await release.promise
356-
})
357-
const installPid = await locked.promise
358-
const completion = completeSetup(pending.searchParams.get('state') ?? '')
359-
completion.catch(() => undefined)
360-
try {
361-
await vi.waitFor(
362-
async () => {
363-
const [waiting] = await db.execute<{ pid: number }>(
364-
sql`SELECT pid FROM pg_stat_activity WHERE ${installPid} = ANY(pg_blocking_pids(pid))`
365-
)
366-
expect(waiting).toBeDefined()
367-
},
368-
{ timeout: 5_000 }
387+
await expect(
388+
whileOrganizationSlackAppLocked(
389+
() => completeSetup(pending.searchParams.get('state') ?? ''),
390+
(tx) => installBot(CUSTOM_APP_ID, OTHER_TEAM, tx)
369391
)
370-
} finally {
371-
release.resolve()
372-
await install
373-
}
374-
await expect(completion).rejects.toThrow('installations changed during authorization')
392+
).rejects.toThrow('installations changed during authorization')
393+
expect(await accountsGroup()).toEqual(before)
394+
})
395+
396+
it('waits on the Slack organization lock before removing a Search bot', async () => {
397+
await approveSlack()
398+
const installationId = await installBot(SHARED_APP_ID, MEMBER_TEAM)
399+
await expect(
400+
whileOrganizationSlackAppLocked(() =>
401+
removeSlackSearchInstallation.execute({
402+
principal: principal(),
403+
input: { organizationId: ids.organization, installationId },
404+
})
405+
)
406+
).resolves.toEqual({ id: installationId })
407+
})
408+
409+
it('explains a conflict when a custom app row holds the shared app ID', async () => {
410+
const groupId = (await approveSlack()).memberAccounts?.groupId
411+
if (!groupId) throw new Error('Approval did not provision organization accounts')
412+
const secret = (await encryptSecret('fixture-custom')).encrypted
413+
await db.insert(slackApp).values({
414+
id: SHARED_APP_ID,
415+
kind: 'custom',
416+
organizationId: ids.organization,
417+
clientId: 'fixture-custom-client',
418+
encryptedClientSecret: secret,
419+
encryptedSigningSecret: secret,
420+
revision: generateId(),
421+
})
422+
const pending = new URL((await startSetup(groupId)).authorizationUrl)
423+
const before = await accountsGroup()
424+
provideSlackConsent(MEMBER_TEAM)
425+
await expect(completeSetup(pending.searchParams.get('state') ?? '')).rejects.toThrow(
426+
'belongs to another installation owner'
427+
)
375428
expect(await accountsGroup()).toEqual(before)
429+
const [row] = await db.select().from(slackApp).where(eq(slackApp.id, SHARED_APP_ID))
430+
expect(row).toMatchObject({ kind: 'custom', organizationId: ids.organization })
376431
})
377432

378433
it('reports a custom-app member setup and keeps it off the shared-app path', async () => {

‎apps/sim/lib/credential-groups/organization-slack-app.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@ import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks'
1010
import type { DbOrTx, DbTransaction } from '@/lib/db/types'
1111

1212
/**
13-
* Serializes an organization's Slack member setup with its Search bot installs, so each sees the
14-
* other's committed app and workspace. Taken before any Slack app or workspace lock.
13+
* Serializes an organization's Slack member setup with Slack Search's own installation writes
14+
* (install, rebind, enable, disable, removal), so neither commits against an app or workspace the
15+
* other changed. Taken before any Slack app or workspace lock. Slack revocations, which must never
16+
* wait on setup, and the cascade from deleting a bot credential skip it: they only withdraw a bot,
17+
* and member setup stays valid without one.
1518
*/
1619
export async function lockOrganizationSlackApp(tx: DbTransaction, organizationId: string) {
1720
await acquireAdvisoryXactLock(tx, 'slack_org_app', `slack-org-app:${organizationId}`)

‎apps/sim/lib/credential-groups/slack-managed-users.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { isRecordLike } from '@sim/utils/object'
1616
import { compareStrings } from '@sim/utils/string'
1717
import { and, eq, inArray, isNull, or } from 'drizzle-orm'
1818
import { getRedisClient } from '@/lib/core/config/redis'
19+
import { OrchestrationError } from '@/lib/core/orchestration/types'
1920
import { resourceScopeFields, resourceScopeFromOwner } from '@/lib/core/resource-scope'
2021
import { resourceScopeCondition } from '@/lib/core/resource-scope.server'
2122
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
@@ -496,7 +497,8 @@ export function getSlackManagedUsersRedirectUri(): string {
496497

497498
/**
498499
* Names an organization's Search installation set. Installing, rebinding, enabling, disabling,
499-
* and removing a bot each rotate or drop a row's revision.
500+
* and removing a bot each rotate or drop a row's revision. Writes that can add a bot all hold the
501+
* Slack organization lock, so a match under that lock means none landed during authorization.
500502
*/
501503
async function organizationInstallationsRevision(executor: DbOrTx, organizationId: string) {
502504
const rows = await executor
@@ -899,6 +901,12 @@ export async function exchangeAndConfigureSlackManagedUsers(params: {
899901
)
900902
.limit(1)
901903
.for('update')
904+
/** A custom row holding the shared app's ID belongs to another owner; never adopt it. */
905+
if (sharedApp && app?.kind !== 'shared')
906+
throw new OrchestrationError(
907+
'conflict',
908+
'This Slack app belongs to another installation owner'
909+
)
902910
if (app?.kind === 'shared')
903911
await requireSlackSearchAppAvailable(app.id, params.attempt.organizationId)
904912
const resolved = app ? await resolveSlackAppCredentials(app) : null

‎apps/sim/lib/knowledge/application/slack-search/installations.ts‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -271,15 +271,19 @@ export const removeSlackSearchInstallation = defineAuthorizedKnowledgeUseCase({
271271
operation: knowledgeOperations.removeSlackInstallation,
272272
resolveContext: ({ input }: { input: RemoveInput }) => resolveKnowledgeOrganizationContext(input),
273273
async execute({ input, context }) {
274-
const [removed] = await db
275-
.delete(slackSearchInstallation)
276-
.where(
277-
and(
278-
eq(slackSearchInstallation.id, input.installationId),
279-
eq(slackSearchInstallation.organizationId, context.organizationId)
274+
const removed = await db.transaction(async (tx) => {
275+
await lockOrganizationSlackApp(tx, context.organizationId)
276+
const [row] = await tx
277+
.delete(slackSearchInstallation)
278+
.where(
279+
and(
280+
eq(slackSearchInstallation.id, input.installationId),
281+
eq(slackSearchInstallation.organizationId, context.organizationId)
282+
)
280283
)
281-
)
282-
.returning({ id: slackSearchInstallation.id })
284+
.returning({ id: slackSearchInstallation.id })
285+
return row
286+
})
283287
if (!removed) throw new OrchestrationError('not_found', 'Slack Search installation not found')
284288
return removed
285289
},

0 commit comments

Comments
 (0)