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
7 changes: 6 additions & 1 deletion apps/docs/content/docs/search/slack.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,11 @@ To switch apps, first remove Slack connections under **Settings → Sources →

## Permissions reference

New Search member connections request these read-only **User Token Scopes**. DM scopes are requested even when DM indexing is off; the source settings determine what is indexed. Bot scopes are separate and allow Sim to receive and answer questions in Slack.
New Search member connections request these read-only **User Token Scopes**. DM scopes are requested even when DM indexing is off; the source settings determine what is indexed. Bot scopes are separate and allow Sim to receive and answer questions in Slack and list channels during source setup.

The custom app's **Bot Token Scopes** include `channels:read` and `groups:read` for the channel picker. Private channels appear only when the bot has access. For an existing app, add these scopes under **OAuth & Permissions**, reinstall the app in Slack to approve the changes, then reconnect the bot in Sim.

Custom and official app manifests declare the same full bot and user scope sets, including permissions reserved for additional capabilities. The table below lists the scopes requested by member indexing; declaring additional user scopes in the manifest does not automatically grant them to each member connection.

| Purpose | User scopes |
|---|---|
Expand Down Expand Up @@ -146,5 +150,6 @@ See Slack's [app manifest reference](https://docs.slack.dev/reference/app-manife
| Redirect mismatch | Check all three redirect URLs above against your Sim origin. |
| App or workspace mismatch | Use the App ID and client credentials from the same app, and the ID of the workspace being authorized. |
| Missing scopes | Compare User Token Scopes with the table above, update the Slack app, reinstall as Slack requires, and reconnect. In workspace setup, select **Search documents** in both setup screens. |
| Channel picker says Options unavailable | Check that the selected custom bot has `channels:read` and `groups:read` under Bot Token Scopes. After adding them, reinstall the app in Slack and reconnect the bot in Sim. |
| Missing private-channel results | Confirm the member is in the channel and it is within the source filters. With an indexing account, confirm that account can read it too. |
| Slow initial indexing | Check sync status and Slack rate limits. A large history can take multiple background runs. |
11 changes: 11 additions & 0 deletions apps/sim/lib/internal/slack/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,17 @@ describe('Slack bot grant policy and cleanup', () => {
it('accepts the existing indexing bot scope policy', () => {
expect(() => validateSlackBotAuthorization(grant)).not.toThrow()
})
it.each(['channels:read', 'groups:read'] as const)(
'rejects a bot grant missing channel picker scope %s',
(missingScope) => {
expect(() =>
validateSlackBotAuthorization({
...grant,
scope: SLACK_SEARCH_SCOPES.filter((scope) => scope !== missingScope).join(','),
})
).toThrow(`Reinstall the app with these scopes: ${missingScope}`)
}
)
it('requires the additional command scope for shared installs', () => {
expect(() =>
validateSlackBotAuthorization(grant, [...SLACK_SEARCH_SCOPES, 'commands'])
Expand Down
15 changes: 15 additions & 0 deletions apps/sim/lib/internal/slack/search-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,21 @@ describe('Slack Search provider verification', () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify(auth)))
await expect(verifySlackSearchBot('token')).rejects.toThrow('Reinstall')
})
it.each(['channels:read', 'groups:read'] as const)(
'rejects an installed bot missing channel picker scope %s',
async (missingScope) => {
fetchMock.mockResolvedValue(
reply(
auth,
SLACK_SEARCH_SCOPES.filter((scope) => scope !== missingScope)
)
)
await expect(verifySlackSearchBot('token')).rejects.toThrow(
`Reinstall the Slack bot with these scopes: ${missingScope}`
)
expect(fetchMock).toHaveBeenCalledOnce()
}
)
it.each([
{ deleted: true },
{ is_bot: true },
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/slack-search/constants.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export const SLACK_SEARCH_SCOPES = [
'assistant:write',
'chat:write',
'channels:read',
'groups:read',
'im:history',
'im:write',
'app_mentions:read',
Expand Down
69 changes: 37 additions & 32 deletions apps/sim/lib/slack-search/manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ describe('Search app manifest', () => {
'app_mentions:read',
'im:write',
'im:history',
'channels:read',
'groups:read',
])
)
expect(manifest.oauth_config.scopes.bot).not.toContain('groups:history')
expect(manifest.oauth_config.scopes.user).toEqual(
expect.arrayContaining([
'users:read',
Expand Down Expand Up @@ -47,26 +48,21 @@ describe('Search app manifest', () => {
).toBe(true)
})
it('preserves existing member grants when updating a bot manifest', () => {
expect(
createSlackSearchManifest('Sim Search', 'Search', 'https://sim.test', ['files:read'])
.oauth_config.scopes.user
).toContain('files:read')
const manifest = createSlackSearchManifest('Sim Search', 'Search', 'https://sim.test', [
'files:read',
'files:write',
])
expect(manifest.oauth_config.scopes.user).toContain('files:write')
expect(manifest.oauth_config.scopes.user.filter((scope) => scope === 'files:read')).toEqual([
'files:read',
])
})
it('uses one origin for unified ingress and OAuth with only the required bot permissions', () => {
it('uses one origin for unified ingress and OAuth', () => {
const manifest = createSlackSearchManifest(
'Sim Search',
'Search with sources',
'https://search-test.ngrok.app'
)
expect(manifest.oauth_config.scopes.bot).toEqual([
'assistant:write',
'chat:write',
'im:history',
'im:write',
'app_mentions:read',
'users:read',
'users:read.email',
])
expect(manifest.settings.event_subscriptions.request_url).toBe(
'https://search-test.ngrok.app/api/webhooks/slack'
)
Expand All @@ -92,23 +88,26 @@ describe('Search app manifest', () => {
})
})

it('official app declares expanded permissions without subscribing to member message events', () => {
const manifest = createSharedSlackSearchManifest('https://www.sim.ai')
expect(manifest.oauth_config.scopes.user).toEqual([
it.each([
{
name: 'custom',
manifest: createSlackSearchManifest('Sim Search', 'Search', 'https://sim.test'),
},
{ name: 'shared', manifest: createSharedSlackSearchManifest('https://sim.test') },
])('$name app declares the complete bot and user scope sets without duplicates', ({ manifest }) => {
expect([...manifest.oauth_config.scopes.user].sort()).toEqual([
'canvases:read',
'canvases:write',
'channels:history',
'channels:read',
'chat:write',
'files:read',
'groups:history',
'groups:read',
'im:history',
'im:read',
'mpim:history',
'mpim:read',
'users:read',
'users:read.email',
'canvases:read',
'canvases:write',
'chat:write',
'files:read',
'search:read.files',
'search:read.im',
'search:read.mpim',
Expand All @@ -117,32 +116,38 @@ it('official app declares expanded permissions without subscribing to member mes
'search:read.users',
'team:read',
'usergroups:read',
])
expect(manifest.oauth_config.scopes.bot).toEqual([
'assistant:write',
'chat:write',
'im:history',
'im:write',
'app_mentions:read',
'users:read',
'users:read.email',
'commands',
])
expect([...manifest.oauth_config.scopes.bot].sort()).toEqual([
'app_mentions:read',
'assistant:write',
'channels:history',
'channels:manage',
'channels:read',
'channels:write.invites',
'chat:write',
'chat:write.public',
'commands',
'groups:history',
'groups:read',
'groups:write',
'groups:write.invites',
'im:history',
'im:write',
'links:read',
'links:write',
'mpim:history',
'mpim:read',
'mpim:write',
'reactions:write',
'users:read',
'users:read.email',
])
})

it('official app declares commands and lifecycle events without member message events', () => {
const manifest = createSharedSlackSearchManifest('https://www.sim.ai')
expect(manifest.features.slash_commands.map((command) => command.command)).toEqual([
'/query',
'/connect',
Expand Down
86 changes: 40 additions & 46 deletions apps/sim/lib/slack-search/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@ import {
SLACK_MANAGED_USER_ENROLLMENT_CALLBACK_PATH,
SLACK_SEARCH_USER_SCOPES,
} from '@/lib/credential-groups/slack-managed-user-scopes'
import { SLACK_SEARCH_SCOPES, SLACK_SHARED_SEARCH_BOT_SCOPES } from '@/lib/slack-search/constants'
import { SLACK_SHARED_SEARCH_BOT_SCOPES } from '@/lib/slack-search/constants'

export const SLACK_SEARCH_CALLBACK_PATH = '/api/knowledge/slack/oauth/callback'
export const SLACK_SEARCH_WEBHOOK_PATH = '/api/webhooks/slack'
export const SLACK_SEARCH_DEFAULT_NAME = 'Sim Search'
export const SLACK_SEARCH_DEFAULT_DESCRIPTION =
'Ask questions about your organization’s knowledge and get answers with sources.'

/** Bot conversations and member indexing share one manifest and app identity. */
/**
* Custom and shared apps declare the same permissions, including planned capabilities.
* Runtime OAuth validation requires only scopes used by implemented features.
*/
export function createSlackSearchManifest(
name: string,
description: string,
Expand All @@ -38,8 +41,40 @@ export function createSlackSearchManifest(
SLACK_MANAGED_USER_ENROLLMENT_CALLBACK_PATH,
].map((path) => new URL(path, url).href),
scopes: {
bot: [...SLACK_SEARCH_SCOPES],
user: [...new Set([...SLACK_SEARCH_USER_SCOPES, ...existingUserScopes])],
bot: [
...SLACK_SHARED_SEARCH_BOT_SCOPES,
'channels:history',
'channels:manage',
'channels:write.invites',
'chat:write.public',
'groups:history',
'groups:write',
'groups:write.invites',
'links:read',
'links:write',
'mpim:history',
'mpim:read',
'mpim:write',
'reactions:write',
],
user: [
...new Set([
...SLACK_SEARCH_USER_SCOPES,
'canvases:read',
'canvases:write',
'chat:write',
'files:read',
'search:read.files',
'search:read.im',
'search:read.mpim',
'search:read.private',
'search:read.public',
'search:read.users',
'team:read',
'usergroups:read',
...existingUserScopes,
]),
],
},
},
settings: {
Expand All @@ -55,10 +90,7 @@ export function createSlackSearchManifest(
}
}

/**
* Declares the company app's permissions, including planned capabilities.
* Runtime OAuth validation continues to require only scopes used by implemented features.
*/
/** Adds the official app's commands and lifecycle events to the common manifest. */
export function createSharedSlackSearchManifest(origin: string) {
const manifest = createSlackSearchManifest(
SLACK_SEARCH_DEFAULT_NAME,
Expand Down Expand Up @@ -87,44 +119,6 @@ export function createSharedSlackSearchManifest(origin: string) {
},
],
},
oauth_config: {
...manifest.oauth_config,
scopes: {
bot: [
...SLACK_SHARED_SEARCH_BOT_SCOPES,
'channels:history',
'channels:manage',
'channels:read',
'channels:write.invites',
'chat:write.public',
'groups:history',
'groups:read',
'groups:write',
'groups:write.invites',
'links:read',
'links:write',
'mpim:history',
'mpim:read',
'mpim:write',
'reactions:write',
],
user: [
...SLACK_SEARCH_USER_SCOPES,
'canvases:read',
'canvases:write',
'chat:write',
'files:read',
'search:read.files',
'search:read.im',
'search:read.mpim',
'search:read.private',
'search:read.public',
'search:read.users',
'team:read',
'usergroups:read',
],
},
},
settings: {
...manifest.settings,
event_subscriptions: {
Expand Down
Loading