Skip to content

Commit 57e2035

Browse files
committed
fix(slack): verify Search permissions before changing active grants
1 parent 90e3b91 commit 57e2035

9 files changed

Lines changed: 354 additions & 88 deletions

File tree

‎apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client'
22

33
import { Chip, toast } from '@sim/emcn'
4+
import { hasSlackSearchUserScopes } from '@/lib/credential-groups/slack-managed-user-scopes'
45
import { LIVE_SEARCH_SCOPE_FIELDS } from '@/lib/sim-search/live/policy-schema'
56
import { liveSearchProviderForCredential } from '@/lib/sim-search/live/provider-catalog'
67
import {
@@ -133,6 +134,8 @@ export function LiveMemberIntegrations({ organizationId, search }: LiveMemberInt
133134
Boolean(option || server) &&
134135
approved &&
135136
(!option || option.configurationStatus === 'ready') &&
137+
(provider !== 'slack' ||
138+
(option?.provider === 'slack' && hasSlackSearchUserScopes(option.requiredScopes))) &&
136139
((provider !== 'hubspot' && provider !== 'zoom') ||
137140
data.availableMcpConnectors.includes(provider))
138141
const scope =

‎apps/sim/app/o/[organizationId]/settings/components/integrations/live-search-settings.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ describe('live search administration', () => {
261261
it('opens Slack setup in Sources instead of its redirected service-account page', async () => {
262262
mocks.policies.mockReturnValue({ data: [{ connectorType: 'slack', approved: true }] })
263263
await render()
264-
await act(async () => button('Slack app')!.click())
264+
await act(async () => button('Verify permissions')!.click())
265265
expect(mockPush).not.toHaveBeenCalled()
266266
expect(container.querySelector('a[href*="providers/slack"]')).toBeNull()
267267
await act(async () =>

‎apps/sim/app/o/[organizationId]/settings/components/integrations/live-search-settings.tsx‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useRouter } from 'next/navigation'
77
import { useQueryState } from 'nuqs'
88
import { SettingsPanel } from '@/components/settings/settings-panel'
99
import type { SearchIntegrationApproval } from '@/lib/api/contracts/knowledge/search-integrations'
10+
import { hasSlackSearchUserScopes } from '@/lib/credential-groups/slack-managed-user-scopes'
1011
import { organizationRoutes } from '@/lib/navigation/paths'
1112
import {
1213
defaultLiveSearchPolicy,
@@ -163,6 +164,14 @@ export function LiveSearchSettings() {
163164
const memberProvider = liveSearchMemberAccountProvider(type)
164165
const mcpProvider = liveSearchMcpConnector(type)
165166
const group = accounts.data?.credentialGroup
167+
const slackOption = group?.options.find((option) => option.provider === 'slack')
168+
const needsSlackSetup =
169+
type === 'slack' &&
170+
accounts.data &&
171+
(group?.status !== 'active' ||
172+
slackOption?.status !== 'active' ||
173+
slackOption.configurationStatus !== 'ready' ||
174+
!hasSlackSearchUserScopes(slackOption.requiredScopes))
166175
const needsMemberSetup =
167176
integration.available !== false &&
168177
accounts.data &&
@@ -193,7 +202,13 @@ export function LiveSearchSettings() {
193202
iconVariant='custom'
194203
icon={<IntegrationTile blockType={type} icon={meta.icon} />}
195204
title={meta.name}
196-
description={integration.available === false ? 'Currently unavailable' : scope}
205+
description={
206+
integration.available === false
207+
? 'Currently unavailable'
208+
: needsSlackSetup
209+
? 'Member accounts · Verify Search permissions'
210+
: scope
211+
}
197212
trailing={
198213
<div className='flex gap-2'>
199214
{serviceAccount && (
@@ -206,7 +221,9 @@ export function LiveSearchSettings() {
206221
</ChipLink>
207222
)}
208223
{type === 'slack' && (
209-
<Chip onClick={() => void setConnectedAccounts('slack')}>Slack app</Chip>
224+
<Chip onClick={() => void setConnectedAccounts('slack')}>
225+
{needsSlackSetup ? 'Verify permissions' : 'Slack app'}
226+
</Chip>
210227
)}
211228
{needsMemberSetup && (
212229
<Chip

‎apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx‎

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type { WorkspaceCredential } from '@/lib/api/contracts'
2020
import type { OrganizationCredential } from '@/lib/api/contracts/organization-credentials'
2121
import { resourceScopeFields, resourceScopeFromOwner } from '@/lib/core/resource-scope'
2222
import {
23+
hasSlackSearchUserScopes,
2324
resolveSlackManagedUserScopes,
2425
SLACK_MANAGED_USER_SCOPES,
2526
SLACK_SEARCH_USER_SCOPES,
@@ -32,6 +33,7 @@ import {
3233
organizationAccountsKeys,
3334
useOrganizationAccounts,
3435
} from '@/hooks/queries/organization-accounts'
36+
import { useSearchIntegrations } from '@/hooks/queries/search-integrations'
3537
import { useSlackSearchInstallations } from '@/hooks/queries/slack-search'
3638
import { credentialGroupKeys } from '@/hooks/queries/utils/credential-group-queries'
3739

@@ -107,11 +109,15 @@ export function SlackManagedUsersModal({
107109
availableApps.find((app) => app.appId === appId) ??
108110
(availableApps.length === 1 && !appId ? availableApps[0] : undefined)
109111
const sharedAppInstalled = organizationSetup && selectedApp?.appKind === 'shared'
112+
const searchPolicies = useSearchIntegrations(organizationId ?? '', {
113+
enabled: open && sharedAppInstalled,
114+
})
115+
const searchApproved = searchPolicies.data?.some(
116+
(policy) => policy.connectorType === 'slack' && policy.approved
117+
)
110118
const accounts = useOrganizationAccounts(open && sharedAppInstalled ? organizationId : undefined)
111119
const memberGroup = accounts.data?.credentialGroup
112-
const memberOption = memberGroup?.options.find(
113-
(option) => option.provider === 'slack' && option.status === 'active'
114-
)
120+
const memberOption = memberGroup?.options.find((option) => option.provider === 'slack')
115121
const sharedAppCanAuthorize = Boolean(
116122
sharedAppInstalled &&
117123
apps.isSuccess &&
@@ -123,11 +129,22 @@ export function SlackManagedUsersModal({
123129
accounts.isSuccess &&
124130
!accounts.isFetching &&
125131
!accounts.error &&
126-
memberGroup?.id === credentialGroupId
132+
searchPolicies.isSuccess &&
133+
!searchPolicies.isFetching &&
134+
!searchPolicies.error &&
135+
memberGroup?.id === credentialGroupId &&
136+
memberOption?.status === 'active'
127137
)
128-
const sharedAppReady = sharedAppCanAuthorize && memberOption?.configurationStatus === 'ready'
138+
const searchPermissionsMissing =
139+
searchApproved && !hasSlackSearchUserScopes(memberOption?.requiredScopes)
140+
const sharedAppReady =
141+
sharedAppCanAuthorize &&
142+
memberOption?.configurationStatus === 'ready' &&
143+
!searchPermissionsMissing
129144
const sharedAppNeedsUpdate =
130-
sharedAppCanAuthorize && memberOption?.configurationStatus === 'needs_update'
145+
sharedAppCanAuthorize &&
146+
(memberOption?.configurationStatus === 'needs_update' ||
147+
(memberOption?.configurationStatus === 'ready' && searchPermissionsMissing))
131148
const [clientId, setClientId] = useState('')
132149
const [clientSecret, setClientSecret] = useState('')
133150
const [pending, setPending] = useState(false)
@@ -389,8 +406,19 @@ export function SlackManagedUsersModal({
389406
const needsApp = organizationSetup && apps.isSuccess && availableApps.length === 0
390407
const checkingSetup =
391408
apps.isPending ||
392-
(sharedAppInstalled && (apps.isFetching || accounts.isPending || accounts.isFetching))
393-
const failedSetup = apps.error ? apps : sharedAppInstalled && accounts.error ? accounts : null
409+
(sharedAppInstalled &&
410+
(apps.isFetching ||
411+
accounts.isPending ||
412+
accounts.isFetching ||
413+
searchPolicies.isPending ||
414+
searchPolicies.isFetching))
415+
const failedSetup = apps.error
416+
? apps
417+
: sharedAppInstalled && searchPolicies.error
418+
? searchPolicies
419+
: sharedAppInstalled && accounts.error
420+
? accounts
421+
: null
394422
const title = organizationSetup ? 'Set up Slack app' : 'Set up Slack'
395423
const primaryLabel = isLoading
396424
? 'Loading...'
@@ -405,7 +433,7 @@ export function SlackManagedUsersModal({
405433
(!organizationSetup && !selectedBot) ||
406434
pending ||
407435
(organizationSetup
408-
? apps.isPending || Boolean(apps.error) || !selectedApp || !requiredScopes.length
436+
? checkingSetup || Boolean(failedSetup) || !selectedApp || !requiredScopes.length
409437
: !clientId.trim() || !clientSecret.trim())
410438

411439
return (
@@ -472,9 +500,9 @@ export function SlackManagedUsersModal({
472500
<p className='text-[var(--text-secondary)] text-sm'>
473501
{sharedAppInstalled
474502
? sharedAppNeedsUpdate
475-
? 'Member access is outdated. Update it so members can reconnect their Slack accounts.'
503+
? 'Verify member permissions. Members will need to reconnect if their app or permissions change.'
476504
: 'The Sim Search installation needs attention. Manage the app to finish setup.'
477-
: 'Verify member authorization for the installed app. Members can then search the Slack conversations they can access.'}
505+
: 'Verify member permissions. Members will need to reconnect if their app or permissions change.'}
478506
</p>
479507
{selectedApp && (
480508
<Chip onClick={() => setAppSetupOpen(true)} disabled={pending}>

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ export const completeSlackCredentialGroupConfiguration: OperationUseCase<
169169
attempt.expectedAppId !== pending.expectedAppId ||
170170
attempt.expectedTeamId !== pending.expectedTeamId ||
171171
attempt.appRevision !== pending.appRevision ||
172+
attempt.searchApprovalUpdatedAt !== pending.searchApprovalUpdatedAt ||
172173
attempt.clientId !== pending.clientId ||
173174
attempt.redirectUri !== pending.redirectUri ||
174175
credentialGroupScopePolicyVersion(attempt.requiredScopes) !==

‎apps/sim/lib/credential-groups/service.ts‎

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ export async function ensureWorkspaceAccountsGroup(
401401
}
402402
}
403403

404-
/** Adds a provider or extends its required consent during an explicit administrator action. */
404+
/** Adds a provider without changing the verified consent policy of existing connections. */
405405
export async function addOrganizationAccountProvider(
406406
organizationId: string,
407407
userId: string,
@@ -430,31 +430,6 @@ export async function addOrganizationAccountProvider(
430430
'validation',
431431
`Enable ${option.label} in Connected accounts first`
432432
)
433-
if (option.requiredScopes) {
434-
const previousScopes =
435-
current.provider === 'slack'
436-
? resolveSlackManagedUserScopes(current.requiredScopes)
437-
: current.requiredScopes
438-
const requiredScopes = [...new Set([...previousScopes, ...option.requiredScopes])]
439-
const scopeVersion = credentialGroupScopePolicyVersion(requiredScopes)
440-
if (!scopesEqual(requiredScopes, previousScopes) || scopeVersion !== current.scopeVersion) {
441-
const [updated] = await executor
442-
.update(credentialGroup)
443-
.set({
444-
options: existing.options.map((entry) =>
445-
entry.id === current.id ? { ...entry, requiredScopes, scopeVersion } : entry
446-
),
447-
updatedAt: new Date(),
448-
})
449-
.where(
450-
and(eq(credentialGroup.id, group.id), resourceScopeCondition(credentialGroup, scope))
451-
)
452-
.returning({ id: credentialGroup.id })
453-
if (!updated) throw new Error('Connected accounts policy update returned no row')
454-
await invalidateOptionGrants(executor, group.id, [current.id])
455-
return { groupId: group.id, changed: true }
456-
}
457-
}
458433
return { groupId: group.id, changed: group.created }
459434
}
460435
if (

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ export const SLACK_SEARCH_USER_SCOPES = [
5959
...SLACK_RTS_USER_SCOPES,
6060
] as const
6161

62+
/** Search readiness is separate from a connection's existing workflow permissions. */
63+
export function hasSlackSearchUserScopes(scopes: readonly string[] | undefined): boolean {
64+
return SLACK_SEARCH_USER_SCOPES.every((scope) => scopes?.includes(scope))
65+
}
66+
6267
/** Existing workflow options retain their scope policy; every user grant must attest identity. */
6368
export function resolveSlackManagedUserScopes(requiredScopes?: readonly string[]): string[] {
6469
return [

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

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
credential,
55
credentialGroup,
66
credentialGroupEnrollment,
7+
organizationSearchIntegration,
78
slackApp,
89
slackSearchInstallation,
910
} from '@sim/db/schema'
@@ -38,7 +39,7 @@ import { getSharedSlackSearchAppConfiguration } from '@/lib/slack-search/shared-
3839

3940
const logger = createLogger('SlackManagedUsers')
4041
const SLACK_MANAGED_USERS_ATTEMPT_TTL_MS = 10 * 60 * 1000
41-
const SLACK_MANAGED_USERS_ATTEMPT_VERSION = 4 as const
42+
const SLACK_MANAGED_USERS_ATTEMPT_VERSION = 5 as const
4243
const MAX_SLACK_RESPONSE_BYTES = 64 * 1024
4344
const CONSUME_SCRIPT = `
4445
local value = redis.call('GET', KEYS[1])
@@ -61,6 +62,7 @@ interface SlackCustomBotSecret {
6162

6263
type StoredSlackManagedUsersAttempt = {
6364
appRevision?: string
65+
searchApprovalUpdatedAt?: number
6466
version: typeof SLACK_MANAGED_USERS_ATTEMPT_VERSION
6567
workspaceId?: string
6668
organizationId?: string
@@ -82,6 +84,7 @@ type StoredSlackManagedUsersAttempt = {
8284

8385
export interface SlackManagedUsersAttempt {
8486
appRevision?: string
87+
searchApprovalUpdatedAt?: number
8588
workspaceId?: string
8689
organizationId?: string
8790
userId: string
@@ -161,6 +164,10 @@ function isStoredAttempt(value: unknown): value is StoredSlackManagedUsersAttemp
161164
typeof candidate.credentialGroupId === 'string' &&
162165
typeof candidate.credentialGroupUpdatedAt === 'number' &&
163166
(candidate.appRevision === undefined || typeof candidate.appRevision === 'string') &&
167+
(candidate.searchApprovalUpdatedAt === undefined ||
168+
(candidate.organizationId !== undefined &&
169+
typeof candidate.searchApprovalUpdatedAt === 'number' &&
170+
Number.isFinite(candidate.searchApprovalUpdatedAt))) &&
164171
(candidate.organizationId !== undefined
165172
? candidate.slackBotCredentialId === undefined &&
166173
candidate.slackBotCredentialUpdatedAt === undefined
@@ -497,6 +504,7 @@ export async function createSlackManagedUsersAttempt(params: {
497504
let clientId = params.clientId
498505
let clientSecret = params.clientSecret
499506
let appRevision: string | undefined
507+
let searchApprovalUpdatedAt: number | undefined
500508
if (scope.kind === 'organization') {
501509
if (params.slackBotCredentialId || !params.appId || params.clientId || params.clientSecret)
502510
throw new SlackManagedUsersError(
@@ -548,6 +556,23 @@ export async function createSlackManagedUsersAttempt(params: {
548556
? SLACK_SEARCH_USER_SCOPES
549557
: existingOption.requiredScopes
550558
)
559+
const [searchApproval] = await db
560+
.select({
561+
approved: organizationSearchIntegration.approved,
562+
updatedAt: organizationSearchIntegration.updatedAt,
563+
})
564+
.from(organizationSearchIntegration)
565+
.where(
566+
and(
567+
eq(organizationSearchIntegration.organizationId, scope.organizationId),
568+
eq(organizationSearchIntegration.connectorType, 'slack')
569+
)
570+
)
571+
.limit(1)
572+
if (searchApproval?.approved) {
573+
requiredScopes = [...new Set([...requiredScopes, ...SLACK_SEARCH_USER_SCOPES])]
574+
searchApprovalUpdatedAt = searchApproval.updatedAt.getTime()
575+
}
551576
} else {
552577
if (!params.slackBotCredentialId)
553578
throw new SlackManagedUsersError('Select a custom Slack bot.', 'invalid_response')
@@ -565,6 +590,11 @@ export async function createSlackManagedUsersAttempt(params: {
565590
}
566591
if (!clientId || !clientSecret)
567592
throw new SlackManagedUsersError('Slack client credentials are required.', 'invalid_client')
593+
if (requiredScopes.length > 100)
594+
throw new SlackManagedUsersError(
595+
'The combined Slack request has too many permissions.',
596+
'invalid_response'
597+
)
568598
const redis = requireRedis()
569599
const state = generateId()
570600
const redirectUri = getSlackManagedUsersRedirectUri()
@@ -583,6 +613,7 @@ export async function createSlackManagedUsersAttempt(params: {
583613
expectedTeamId: identity.teamId,
584614
clientId,
585615
...(appRevision ? { appRevision } : {}),
616+
...(searchApprovalUpdatedAt !== undefined ? { searchApprovalUpdatedAt } : {}),
586617
...(sharedApp && scope.kind === 'organization'
587618
? { credentialSource: 'environment' as const, organizationId: scope.organizationId }
588619
: { encryptedClientSecret: (await encryptSecret(clientSecret)).encrypted }),
@@ -660,6 +691,9 @@ async function parseSlackManagedUsersAttempt(
660691
expectedTeamId: parsed.expectedTeamId,
661692
clientId: parsed.clientId,
662693
...(parsed.appRevision ? { appRevision: parsed.appRevision } : {}),
694+
...(parsed.searchApprovalUpdatedAt !== undefined
695+
? { searchApprovalUpdatedAt: parsed.searchApprovalUpdatedAt }
696+
: {}),
663697
clientSecret,
664698
redirectUri: parsed.redirectUri,
665699
requiredScopes: parsed.requiredScopes,
@@ -779,6 +813,30 @@ export async function exchangeAndConfigureSlackManagedUsers(params: {
779813
'invalid_state'
780814
)
781815
}
816+
if (params.attempt.organizationId && params.attempt.searchApprovalUpdatedAt !== undefined) {
817+
const [approval] = await tx
818+
.select({
819+
approved: organizationSearchIntegration.approved,
820+
updatedAt: organizationSearchIntegration.updatedAt,
821+
})
822+
.from(organizationSearchIntegration)
823+
.where(
824+
and(
825+
eq(organizationSearchIntegration.organizationId, params.attempt.organizationId),
826+
eq(organizationSearchIntegration.connectorType, 'slack')
827+
)
828+
)
829+
.limit(1)
830+
.for('share')
831+
if (
832+
!approval?.approved ||
833+
approval.updatedAt.getTime() !== params.attempt.searchApprovalUpdatedAt
834+
)
835+
throw new SlackManagedUsersError(
836+
'Search approval changed during authorization. Start again.',
837+
'invalid_state'
838+
)
839+
}
782840
if (params.attempt.workspaceId) {
783841
if (!params.attempt.slackBotCredentialId)
784842
throw new SlackManagedUsersError('Workspace Slack bot is missing.', 'invalid_state')

0 commit comments

Comments
 (0)