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
1 change: 1 addition & 0 deletions apps/desktop/e2e/source-connect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ test('source authorization returns to its desktop screen and refreshes live', as
if (path === '/api/knowledge/slack') {
json({
sharedAppAvailable: true,
memberAppKind: installed ? 'shared' : null,
bots: [],
installations: installed
? [
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/api/knowledge/slack/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ export const GET = defineInternalJsonRoute({
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ query }) => query,
useCase: listSlackSearchInstallations,
present: ({ installations, bots, sharedAppAvailable }) => ({
present: ({ installations, bots, sharedAppAvailable, memberAppKind }) => ({
sharedAppAvailable,
memberAppKind,
bots,
installations: installations.map((row) => ({
...row,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
import { useSearchIntegrations } from '@/hooks/queries/search-integrations'
import { useSlackSearchInstallations } from '@/hooks/queries/slack-search'
import { credentialGroupKeys } from '@/hooks/queries/utils/credential-group-queries'
import { slackSearchKeys } from '@/hooks/queries/utils/slack-search-keys'

const CHANNEL_NAME = 'slack-managed-users'
const AUTHORIZATION_TIMEOUT_MS = 10 * 60 * 1000
Expand Down Expand Up @@ -71,6 +72,15 @@ export function getSlackManagedUsersFailureNotification(reason?: string): {
: { message: 'Slack app verification failed. Please try again.', variant: 'error' }
}

/** `optionActive` is undefined until the organization has a Slack sign-in option. */
function memberAccountsMessage(previousAppSetup: boolean, optionActive: boolean | undefined) {
if (previousAppSetup)
return 'Remove app setup on the Slack source to clear the previous Slack app first.'
if (optionActive === undefined) return 'Verify member permissions in your Slack workspace.'
if (!optionActive) return 'Enable Slack in Credential Groups to verify member permissions.'
return 'Verify member permissions. Members will need to reconnect if their app or permissions change.'
}

function isSlackManagedUsersMessage(value: unknown): value is SlackManagedUsersMessage {
if (!value || typeof value !== 'object') return false
const message = value as Record<string, unknown>
Expand Down Expand Up @@ -112,6 +122,7 @@ export function SlackManagedUsersModal({
const noInstalledApp = organizationSetup && apps.isSuccess && availableApps.length === 0
/** The shared app connects member accounts without a Search bot installation. */
const sharedAppWithoutBot = noInstalledApp && Boolean(apps.data?.sharedAppAvailable)
const previousAppSetup = sharedAppWithoutBot && apps.data?.memberAppKind === 'custom'
const sharedSetup = sharedAppInstalled || sharedAppWithoutBot
const searchPolicies = useSearchIntegrations(organizationId ?? '', {
enabled: open && sharedSetup,
Expand Down Expand Up @@ -143,6 +154,7 @@ export function SlackManagedUsersModal({
)
const memberOnlyCanAuthorize =
sharedAppWithoutBot &&
!previousAppSetup &&
!apps.isFetching &&
memberSetupSettled &&
(!memberOption || memberOption.status === 'active')
Expand Down Expand Up @@ -245,6 +257,7 @@ export function SlackManagedUsersModal({
void queryClient.invalidateQueries({
queryKey: organizationAccountsKeys.detail(scope.organizationId),
})
void queryClient.invalidateQueries({ queryKey: slackSearchKeys.list(scope.organizationId) })
} else {
void queryClient.invalidateQueries({
queryKey: credentialGroupKeys.workspace(scope.workspaceId),
Expand Down Expand Up @@ -346,9 +359,14 @@ export function SlackManagedUsersModal({
)
controller.signal.throwIfAborted()
if (scope.kind === 'organization')
await queryClient.invalidateQueries({
queryKey: organizationAccountsKeys.detail(scope.organizationId),
})
await Promise.all([
queryClient.invalidateQueries({
queryKey: organizationAccountsKeys.detail(scope.organizationId),
}),
queryClient.invalidateQueries({
queryKey: slackSearchKeys.list(scope.organizationId),
}),
])
else await queryClient.invalidateQueries({ queryKey: credentialGroupKeys.all })
controller.signal.throwIfAborted()
toast.success('Slack configured')
Expand Down Expand Up @@ -473,9 +491,10 @@ export function SlackManagedUsersModal({
) : sharedAppWithoutBot ? (
<ChipModalField type='custom' title='Member accounts'>
<p className='text-[var(--text-secondary)] text-sm'>
{memberOption
? 'Verify member permissions. Members will need to reconnect if their app or permissions change.'
: 'Verify member permissions in your Slack workspace.'}
{memberAccountsMessage(
previousAppSetup,
memberOption && memberOption.status === 'active'
)}
</p>
</ChipModalField>
) : needsApp ? (
Expand Down
1 change: 1 addition & 0 deletions apps/sim/hooks/queries/organization-accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ export function useUpdateOrganizationAccounts() {
queryClient.invalidateQueries({
queryKey: slackSearchKeys.organizationManifests(organizationId),
}),
queryClient.invalidateQueries({ queryKey: slackSearchKeys.list(organizationId) }),
]),
})
}
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/api/contracts/knowledge/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export const slackSearchInstallationSchema = z.object({
})
export const listSlackSearchResponseSchema = z.object({
sharedAppAvailable: z.boolean(),
/** The app the organization's Slack member sign-in uses, when configured. */
memberAppKind: z.enum(['custom', 'shared']).nullable(),
Comment thread
waleedlatif1 marked this conversation as resolved.
installations: z.array(slackSearchInstallationSchema).max(100),
bots: z
.array(z.object({ id: z.string().min(1).max(200), displayName: z.string().max(500) }))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ import {
import { readTestRedisUrl } from '@sim/db/testing/test-infrastructure'
import { sha256Hex } from '@sim/security/hash'
import { createSessionPrincipal } from '@sim/testing/factories/principal.factory'
import { createDeferred } from '@sim/testing/helpers/deferred'
import { generateId } from '@sim/utils/id'
import { eq, inArray } from 'drizzle-orm'
import { eq, inArray, sql } from 'drizzle-orm'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { env } from '@/lib/core/config/env'
import { closeRedisConnection } from '@/lib/core/config/redis'
Expand All @@ -31,13 +32,21 @@ import {
completeSlackCredentialGroupConfiguration,
startSlackCredentialGroupConfiguration,
} from '@/lib/credential-groups/application/slack-managed-users'
import { decryptCredentialGroupProviderConfiguration } from '@/lib/credential-groups/provider-configuration'
import { lockOrganizationSlackApp } from '@/lib/credential-groups/organization-slack-app'
import { credentialGroupScopePolicyVersion } from '@/lib/credential-groups/provider-adapter'
import {
decryptCredentialGroupProviderConfiguration,
emptyCredentialGroupProviderConfiguration,
encryptCredentialGroupProviderConfiguration,
} from '@/lib/credential-groups/provider-configuration'
import { getCredentialGroup } from '@/lib/credential-groups/service'
import { SLACK_SEARCH_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes'
import { slackCredentialGroupProviderAdapter } from '@/lib/credential-groups/slack-provider'
import { createOrganizationAccountsGroup } from '@/lib/credential-groups/workspace-accounts'
import { encryptManagedOAuthTokenSet } from '@/lib/credentials/managed-oauth'
import type { DbOrTx } from '@/lib/db/types'
import { approveSearchIntegration } from '@/lib/knowledge/application/search-integrations'
import { listSlackSearchInstallations } from '@/lib/knowledge/application/slack-search/installations'
import { revokeSlackSearchAccess } from '@/lib/knowledge/application/slack-search/lifecycle'
import { defaultLiveSearchPolicy } from '@/lib/sim-search/live/policy-schema'
import { getSharedSlackSearchAppConfiguration } from '@/lib/slack-search/shared-app-env'
Expand All @@ -48,6 +57,7 @@ vi.mock('@/lib/core/config/env-flags', async (importOriginal) => ({
}))

const SHARED_APP_ID = 'ASHAREDFIXTURE'
const CUSTOM_APP_ID = 'ACUSTOMFIXTURE'
const MEMBER_TEAM = 'TMEMBERS'
const OTHER_TEAM = 'TOTHER'

Expand Down Expand Up @@ -131,10 +141,10 @@ describe('Slack member accounts on the shared app without a Search bot', () => {
},
})

const startSetup = (credentialGroupId: string) =>
const startSetup = (credentialGroupId: string, app?: { appId: string; teamId: string }) =>
startSlackCredentialGroupConfiguration.execute({
principal: principal(),
input: { organizationId: ids.organization, credentialGroupId },
input: { organizationId: ids.organization, credentialGroupId, ...app },
})

const completeSetup = (state: string) =>
Expand All @@ -143,6 +153,48 @@ describe('Slack member accounts on the shared app without a Search bot', () => {
input: { state, code: 'fixture-code' },
})

async function installBot(appId: string, teamId: string, executor: DbOrTx = db) {
const secret = (await encryptSecret('fixture-bot')).encrypted
await executor
.insert(slackApp)
.values(
appId === SHARED_APP_ID
? { id: appId, kind: 'shared', organizationId: null, revision: generateId() }
: {
id: appId,
kind: 'custom',
organizationId: ids.organization,
clientId: 'fixture-custom-client',
encryptedClientSecret: secret,
encryptedSigningSecret: secret,
revision: generateId(),
}
)
.onConflictDoNothing()
const botId = generateId()
await executor.insert(credential).values({
id: botId,
organizationId: ids.organization,
type: 'service_account',
providerId: 'slack',
displayName: 'Fixture Slack bot',
createdBy: ids.owner,
encryptedServiceAccountKey: secret,
})
await executor.insert(slackSearchInstallation).values({
id: generateId(),
organizationId: ids.organization,
credentialId: botId,
appId,
slackAppId: appId,
teamId,
teamName: 'Fixture team',
botUserId: 'UBOT',
credentialVersion: generateId(),
revision: generateId(),
})
}

async function accountsGroup() {
const [group] = await db
.select()
Expand Down Expand Up @@ -230,6 +282,16 @@ describe('Slack member accounts on the shared app without a Search bot', () => {
})
).resolves.toMatchObject({ authorizationAppId: `slack:${SHARED_APP_ID}:${MEMBER_TEAM}` })
await expect(approveSlack()).resolves.toMatchObject({ memberAccounts: { changed: false } })
await expect(
listSlackSearchInstallations.execute({
principal: principal(),
input: { organizationId: ids.organization },
})
).resolves.toMatchObject({
sharedAppAvailable: true,
memberAppKind: 'shared',
installations: [],
})

const second = new URL((await startSetup(groupId)).authorizationUrl)
expect(second.searchParams.get('team')).toBe(MEMBER_TEAM)
Expand All @@ -243,34 +305,116 @@ describe('Slack member accounts on the shared app without a Search bot', () => {
it('refuses shared-app member setup when the organization already has a Search bot', async () => {
const groupId = (await approveSlack()).memberAccounts?.groupId
if (!groupId) throw new Error('Approval did not provision organization accounts')
await db
.insert(slackApp)
.values({ id: SHARED_APP_ID, kind: 'shared', organizationId: null, revision: generateId() })
const botId = generateId()
await db.insert(credential).values({
id: botId,
organizationId: ids.organization,
type: 'service_account',
providerId: 'slack',
displayName: 'Fixture Slack bot',
createdBy: ids.owner,
encryptedServiceAccountKey: (await encryptSecret('fixture-bot')).encrypted,
})
await db.insert(slackSearchInstallation).values({
id: generateId(),
organizationId: ids.organization,
credentialId: botId,
appId: SHARED_APP_ID,
slackAppId: SHARED_APP_ID,
teamId: MEMBER_TEAM,
teamName: 'Fixture team',
botUserId: 'UBOT',
credentialVersion: generateId(),
revision: generateId(),
})
await installBot(SHARED_APP_ID, MEMBER_TEAM)
await expect(startSetup(groupId)).rejects.toThrow('installed Slack app')
})

it('refuses to finish member setup when a custom bot is installed during authorization', async () => {
const groupId = (await approveSlack()).memberAccounts?.groupId
if (!groupId) throw new Error('Approval did not provision organization accounts')
const pending = new URL((await startSetup(groupId)).authorizationUrl)
const before = await accountsGroup()
await installBot(CUSTOM_APP_ID, OTHER_TEAM)
provideSlackConsent(MEMBER_TEAM)
await expect(completeSetup(pending.searchParams.get('state') ?? '')).rejects.toThrow(
'installations changed during authorization'
)
expect(await accountsGroup()).toEqual(before)
})

it('refuses member setup when another bot joins an installed one during authorization', async () => {
const groupId = (await approveSlack()).memberAccounts?.groupId
if (!groupId) throw new Error('Approval did not provision organization accounts')
await installBot(SHARED_APP_ID, MEMBER_TEAM)
const pending = new URL(
(await startSetup(groupId, { appId: SHARED_APP_ID, teamId: MEMBER_TEAM })).authorizationUrl
)
expect(pending.searchParams.get('team')).toBe(MEMBER_TEAM)
const before = await accountsGroup()
await installBot(CUSTOM_APP_ID, OTHER_TEAM)
provideSlackConsent(MEMBER_TEAM)
await expect(completeSetup(pending.searchParams.get('state') ?? '')).rejects.toThrow(
'installations changed during authorization'
)
expect(await accountsGroup()).toEqual(before)
})

it('waits for an in-flight bot install before finishing member setup', async () => {
const groupId = (await approveSlack()).memberAccounts?.groupId
if (!groupId) throw new Error('Approval did not provision organization accounts')
const pending = new URL((await startSetup(groupId)).authorizationUrl)
const before = await accountsGroup()
provideSlackConsent(MEMBER_TEAM)
const locked = createDeferred<number>()
const release = createDeferred<void>()
const install = db.transaction(async (tx) => {
await lockOrganizationSlackApp(tx, ids.organization)
await installBot(CUSTOM_APP_ID, OTHER_TEAM, tx)
const [connection] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`)
locked.resolve(connection.pid)
await release.promise
})
const installPid = await locked.promise
const completion = completeSetup(pending.searchParams.get('state') ?? '')
completion.catch(() => undefined)
try {
await vi.waitFor(
async () => {
const [waiting] = await db.execute<{ pid: number }>(
sql`SELECT pid FROM pg_stat_activity WHERE ${installPid} = ANY(pg_blocking_pids(pid))`
)
expect(waiting).toBeDefined()
},
{ timeout: 5_000 }
)
} finally {
release.resolve()
await install
}
await expect(completion).rejects.toThrow('installations changed during authorization')
expect(await accountsGroup()).toEqual(before)
})

it('reports a custom-app member setup and keeps it off the shared-app path', async () => {
const groupId = (await approveSlack()).memberAccounts?.groupId
if (!groupId) throw new Error('Approval did not provision organization accounts')
const scopes = [...SLACK_SEARCH_USER_SCOPES]
await db
.update(credentialGroup)
.set({
options: [
{
id: generateId(),
provider: 'slack',
label: 'Slack',
authorizationAppId: `slack:${CUSTOM_APP_ID}:${OTHER_TEAM}`,
requiredScopes: scopes,
scopeVersion: credentialGroupScopePolicyVersion(scopes),
required: false,
status: 'active',
},
],
encryptedProviderConfiguration: await encryptCredentialGroupProviderConfiguration({
...emptyCredentialGroupProviderConfiguration(),
slack: {
source: 'slack_app',
appId: CUSTOM_APP_ID,
teamId: OTHER_TEAM,
scopes,
verifiedAt: new Date().toISOString(),
},
}),
})
.where(eq(credentialGroup.id, groupId))
await expect(
listSlackSearchInstallations.execute({
principal: principal(),
input: { organizationId: ids.organization },
})
).resolves.toMatchObject({ memberAppKind: 'custom', installations: [] })
await expect(startSetup(groupId)).rejects.toThrow('Remove the existing Slack setup')
})

it('revokes shared-app member grants in every organization when a workspace has no bot', async () => {
const groupId = (await approveSlack()).memberAccounts?.groupId
if (!groupId) throw new Error('Approval did not provision organization accounts')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ export const completeSlackCredentialGroupConfiguration: OperationUseCase<
attempt.appRevision !== pending.appRevision ||
attempt.searchApproval?.approved !== pending.searchApproval?.approved ||
attempt.searchApproval?.updatedAt !== pending.searchApproval?.updatedAt ||
attempt.installationsRevision !== pending.installationsRevision ||
attempt.clientId !== pending.clientId ||
attempt.redirectUri !== pending.redirectUri ||
credentialGroupScopePolicyVersion(attempt.requiredScopes) !==
Expand Down
Loading
Loading