Skip to content

Commit 7e0c77c

Browse files
authored
feat(slack): scope private channel visibility to installing user (#4779)
* feat(slack): scope private channel visibility to installing user * improvement(slack): warn on usr_ marker parse miss, drop dead bot prefix * chore(slack): trim verbose comment
1 parent c898e2e commit 7e0c77c

2 files changed

Lines changed: 179 additions & 35 deletions

File tree

apps/sim/app/api/tools/slack/channels/route.ts

Lines changed: 155 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import { db } from '@sim/db'
2+
import { account } from '@sim/db/schema'
13
import { createLogger } from '@sim/logger'
4+
import { eq } from 'drizzle-orm'
25
import { type NextRequest, NextResponse } from 'next/server'
36
import { slackChannelsSelectorContract } from '@/lib/api/contracts/selectors/slack'
47
import { parseRequest } from '@/lib/api/server'
@@ -20,6 +23,29 @@ interface SlackChannel {
2023
is_member: boolean
2124
}
2225

26+
/**
27+
* Extracts the installing user's Slack id from credentials connected after the
28+
* privacy fix, which `auth.ts` tags with a `usr_` marker
29+
* (`${teamId}-usr_${installerUserId}-${uuid}`). Legacy credentials encode the
30+
* bot id with no marker and return null, so the caller keeps the existing
31+
* `is_member` filter — no regression.
32+
*/
33+
const SCOPED_USER_ID_PATTERN =
34+
/-usr_([UW][A-Z0-9]+)-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
35+
36+
function parseScopedSlackUserId(accountId: string): string | null {
37+
const match = SCOPED_USER_ID_PATTERN.exec(accountId)
38+
if (match) return match[1]
39+
// Marker present but unparseable — surface it rather than silently falling
40+
// back to the bot `is_member` filter and bypassing the privacy scope.
41+
if (accountId.includes('-usr_')) {
42+
logger.warn('Slack accountId carries usr_ marker but did not parse; using is_member fallback', {
43+
accountId,
44+
})
45+
}
46+
return null
47+
}
48+
2349
export const POST = withRouteHandler(async (request: NextRequest) => {
2450
try {
2551
const requestId = generateRequestId()
@@ -32,6 +58,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
3258

3359
let accessToken: string
3460
let isBotToken = false
61+
let scopedUserId: string | null = null
3562

3663
if (credential.startsWith('xoxb-')) {
3764
accessToken = credential
@@ -65,11 +92,25 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
6592
}
6693
accessToken = resolvedToken
6794
logger.info('Using OAuth token for Slack API')
95+
96+
if (authz.resolvedCredentialId) {
97+
const [accountRow] = await db
98+
.select({ accountId: account.accountId })
99+
.from(account)
100+
.where(eq(account.id, authz.resolvedCredentialId))
101+
.limit(1)
102+
if (accountRow) {
103+
scopedUserId = parseScopedSlackUserId(accountRow.accountId)
104+
}
105+
}
68106
}
69107

70-
let data
108+
let data: SlackConversationsResult
71109
try {
72110
data = await fetchSlackChannels(accessToken, true)
111+
if (data.truncated) {
112+
logger.warn('conversations.list hit pagination cap; channel list may be incomplete')
113+
}
73114
logger.info('Successfully fetched channels including private channels')
74115
} catch (error) {
75116
if (isBotToken) {
@@ -96,17 +137,49 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
96137
}
97138
}
98139

140+
/**
141+
* Slack Marketplace privacy: a private channel may only be shown to a user
142+
* whose own Slack account is a member, even when the bot has been invited.
143+
* `users.conversations?user=` returns the channels the bot AND that user
144+
* share, giving us the allowed set. Public channels are never restricted.
145+
* Without a scoped user id (legacy credentials), fall back to bot membership.
146+
*/
147+
let allowedPrivateChannelIds: Set<string> | null = null
148+
if (scopedUserId) {
149+
try {
150+
const userPrivate = await fetchUserPrivateChannels(accessToken, scopedUserId)
151+
allowedPrivateChannelIds = new Set(userPrivate.channels.map((c) => c.id))
152+
if (userPrivate.truncated) {
153+
logger.warn(
154+
'users.conversations hit pagination cap; some private channels the user belongs to may be hidden',
155+
{ scopedUserId }
156+
)
157+
}
158+
logger.info('Scoped private channels to installing user membership', {
159+
scopedUserId,
160+
allowedCount: allowedPrivateChannelIds.size,
161+
})
162+
} catch (scopeError) {
163+
// Fail closed: if membership can't be verified, hide all private channels.
164+
logger.warn('Failed to scope private channels to user, hiding all private channels', {
165+
error: (scopeError as Error).message,
166+
})
167+
allowedPrivateChannelIds = new Set()
168+
}
169+
}
170+
99171
const channels = (data.channels || [])
100172
.filter((channel: SlackChannel) => {
101-
const canAccess = !channel.is_archived && (channel.is_member || !channel.is_private)
173+
if (channel.is_archived) return false
102174

103-
if (!canAccess) {
104-
logger.debug(
105-
`Filtering out channel: ${channel.name} (archived: ${channel.is_archived}, private: ${channel.is_private}, member: ${channel.is_member})`
106-
)
175+
if (channel.is_private) {
176+
if (allowedPrivateChannelIds) {
177+
return allowedPrivateChannelIds.has(channel.id)
178+
}
179+
return channel.is_member
107180
}
108181

109-
return canAccess
182+
return true
110183
})
111184
.filter((channel: SlackChannel) => {
112185
const validation = validateAlphanumericId(channel.id, 'channelId', 50)
@@ -141,6 +214,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
141214
private: channels.filter((c: { isPrivate: boolean }) => c.isPrivate).length,
142215
public: channels.filter((c: { isPrivate: boolean }) => !c.isPrivate).length,
143216
tokenType: isBotToken ? 'bot_token' : 'oauth',
217+
userScoped: !!scopedUserId,
144218
})
145219
return NextResponse.json({ channels })
146220
} catch (error) {
@@ -152,35 +226,86 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
152226
}
153227
})
154228

155-
async function fetchSlackChannels(accessToken: string, includePrivate = true) {
156-
const url = new URL('https://slack.com/api/conversations.list')
229+
const SLACK_PAGE_LIMIT = 200
230+
const SLACK_MAX_PAGES = 10
157231

158-
if (includePrivate) {
159-
url.searchParams.append('types', 'public_channel,private_channel')
160-
} else {
161-
url.searchParams.append('types', 'public_channel')
162-
}
232+
interface SlackConversationsResult {
233+
channels: SlackChannel[]
234+
truncated: boolean
235+
}
163236

164-
url.searchParams.append('exclude_archived', 'true')
165-
url.searchParams.append('limit', '200')
237+
/**
238+
* Lists Slack conversations, following `response_metadata.next_cursor` so the
239+
* full set is returned. Bounded by `SLACK_MAX_PAGES`; sets `truncated` rather
240+
* than silently dropping channels when the cap is hit.
241+
*/
242+
async function fetchAllConversations(
243+
method: 'conversations.list' | 'users.conversations',
244+
accessToken: string,
245+
params: Record<string, string>
246+
): Promise<SlackConversationsResult> {
247+
const channels: SlackChannel[] = []
248+
let cursor: string | undefined
249+
let truncated = false
166250

167-
const response = await fetch(url.toString(), {
168-
method: 'GET',
169-
headers: {
170-
Authorization: `Bearer ${accessToken}`,
171-
'Content-Type': 'application/json',
172-
},
173-
})
251+
for (let page = 0; page < SLACK_MAX_PAGES; page++) {
252+
const url = new URL(`https://slack.com/api/${method}`)
253+
for (const [key, value] of Object.entries(params)) {
254+
url.searchParams.append(key, value)
255+
}
256+
url.searchParams.append('limit', String(SLACK_PAGE_LIMIT))
257+
if (cursor) {
258+
url.searchParams.append('cursor', cursor)
259+
}
174260

175-
if (!response.ok) {
176-
throw new Error(`Slack API error: ${response.status} ${response.statusText}`)
177-
}
261+
const response = await fetch(url.toString(), {
262+
method: 'GET',
263+
headers: { Authorization: `Bearer ${accessToken}` },
264+
})
178265

179-
const data = await response.json()
266+
if (!response.ok) {
267+
throw new Error(`Slack API error: ${response.status} ${response.statusText}`)
268+
}
269+
270+
const data = await response.json()
180271

181-
if (!data.ok) {
182-
throw new Error(data.error || 'Failed to fetch channels')
272+
if (!data.ok) {
273+
throw new Error(data.error || `Failed to fetch ${method}`)
274+
}
275+
276+
if (Array.isArray(data.channels)) {
277+
channels.push(...data.channels)
278+
}
279+
280+
cursor = data.response_metadata?.next_cursor?.trim() || undefined
281+
if (!cursor) {
282+
return { channels, truncated }
283+
}
284+
if (page === SLACK_MAX_PAGES - 1) {
285+
truncated = true
286+
}
183287
}
184288

185-
return data
289+
return { channels, truncated }
290+
}
291+
292+
async function fetchSlackChannels(
293+
accessToken: string,
294+
includePrivate = true
295+
): Promise<SlackConversationsResult> {
296+
return fetchAllConversations('conversations.list', accessToken, {
297+
types: includePrivate ? 'public_channel,private_channel' : 'public_channel',
298+
exclude_archived: 'true',
299+
})
300+
}
301+
302+
async function fetchUserPrivateChannels(
303+
accessToken: string,
304+
userId: string
305+
): Promise<SlackConversationsResult> {
306+
return fetchAllConversations('users.conversations', accessToken, {
307+
user: userId,
308+
types: 'private_channel',
309+
exclude_archived: 'true',
310+
})
186311
}

apps/sim/lib/auth/auth.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2532,17 +2532,36 @@ export const auth = betterAuth({
25322532
}
25332533

25342534
const teamId = data.team_id || 'unknown'
2535-
const userId = data.user_id || data.bot_id || 'bot'
25362535
const teamName = data.team || 'Slack Workspace'
25372536

2538-
const uniqueId = `${teamId}-${userId}`
2539-
2540-
logger.info('Slack credential identifier', { teamId, userId, uniqueId, teamName })
2537+
/**
2538+
* Tag the accountId with the installing user's Slack id (from the OAuth
2539+
* v2 `authed_user.id`, preserved on `tokens.raw`) behind a `usr_` marker.
2540+
* The channels selector uses it to scope private-channel visibility to
2541+
* the installer's own Slack membership, per Slack Marketplace rules. The
2542+
* marker disambiguates it from a legacy bot id (same `U.../B...` shape);
2543+
* absent it, we keep the legacy format and today's behavior.
2544+
*/
2545+
const authedUser = tokens.raw?.authed_user as { id?: string } | undefined
2546+
const installerUserId = authedUser?.id
2547+
const userSegment = installerUserId
2548+
? `usr_${installerUserId}`
2549+
: data.user_id || data.bot_id || 'bot'
2550+
2551+
const uniqueId = `${teamId}-${userSegment}`
2552+
2553+
logger.info('Slack credential identifier', {
2554+
teamId,
2555+
userSegment,
2556+
uniqueId,
2557+
teamName,
2558+
hasInstallerId: !!installerUserId,
2559+
})
25412560

25422561
return {
25432562
id: `${uniqueId}-${generateId()}`,
25442563
name: teamName,
2545-
email: `${teamId}-${userId}@slack.bot`,
2564+
email: `${uniqueId}@slack.bot`,
25462565
emailVerified: false,
25472566
createdAt: new Date(),
25482567
updatedAt: new Date(),

0 commit comments

Comments
 (0)