Skip to content

Commit bbcef7c

Browse files
authored
feat(access-control): add ALLOWED_INTEGRATIONS env var for self-hosted block restrictions (#3238)
* feat(access-control): add ALLOWED_INTEGRATIONS env var for self-hosted block restrictions * fix(tests): add getAllowedIntegrationsFromEnv mock to agent-handler tests * fix(access-control): add auth to allowlist endpoint, fix loading state race, use accurate error message * fix(access-control): remove auth from allowed-integrations endpoint to match models endpoint pattern * fix(access-control): normalize blockType to lowercase before env allowlist check * fix(access-control): expose merged allowedIntegrations on config to prevent bypass via direct access * consolidate merging of allowed blocks so all callers have it by default * normalize to lower case * added tests * added tests, normalize to lower case * added safety incase userId is missing * fix failing tests
1 parent 0ee52df commit bbcef7c

File tree

16 files changed

+438
-50
lines changed

16 files changed

+438
-50
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { NextResponse } from 'next/server'
2+
import { getSession } from '@/lib/auth'
3+
import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags'
4+
5+
export async function GET() {
6+
const session = await getSession()
7+
if (!session?.user?.id) {
8+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
9+
}
10+
11+
return NextResponse.json({
12+
allowedIntegrations: getAllowedIntegrationsFromEnv(),
13+
})
14+
}

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/integrations/integrations.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,13 +223,11 @@ export function Integrations({ onOpenChange, registerCloseHandler }: Integration
223223
}
224224
}
225225

226-
// Group services by provider, filtering by permission config
227226
const groupedServices = services.reduce(
228227
(acc, service) => {
229-
// Filter based on allowedIntegrations
230228
if (
231229
permissionConfig.allowedIntegrations !== null &&
232-
!permissionConfig.allowedIntegrations.includes(service.id)
230+
!permissionConfig.allowedIntegrations.includes(service.id.replace(/-/g, '_'))
233231
) {
234232
return acc
235233
}
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { databaseMock, drizzleOrmMock, loggerMock } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const {
8+
DEFAULT_PERMISSION_GROUP_CONFIG,
9+
mockGetAllowedIntegrationsFromEnv,
10+
mockIsOrganizationOnEnterprisePlan,
11+
mockGetProviderFromModel,
12+
} = vi.hoisted(() => ({
13+
DEFAULT_PERMISSION_GROUP_CONFIG: {
14+
allowedIntegrations: null,
15+
allowedModelProviders: null,
16+
hideTraceSpans: false,
17+
hideKnowledgeBaseTab: false,
18+
hideCopilot: false,
19+
hideApiKeysTab: false,
20+
hideEnvironmentTab: false,
21+
hideFilesTab: false,
22+
disableMcpTools: false,
23+
disableCustomTools: false,
24+
disableSkills: false,
25+
hideTemplates: false,
26+
disableInvitations: false,
27+
hideDeployApi: false,
28+
hideDeployMcp: false,
29+
hideDeployA2a: false,
30+
hideDeployChatbot: false,
31+
hideDeployTemplate: false,
32+
},
33+
mockGetAllowedIntegrationsFromEnv: vi.fn<() => string[] | null>(),
34+
mockIsOrganizationOnEnterprisePlan: vi.fn<() => Promise<boolean>>(),
35+
mockGetProviderFromModel: vi.fn<(model: string) => string>(),
36+
}))
37+
38+
vi.mock('@sim/db', () => databaseMock)
39+
vi.mock('@sim/db/schema', () => ({}))
40+
vi.mock('@sim/logger', () => loggerMock)
41+
vi.mock('drizzle-orm', () => drizzleOrmMock)
42+
vi.mock('@/lib/billing', () => ({
43+
isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan,
44+
}))
45+
vi.mock('@/lib/core/config/feature-flags', () => ({
46+
getAllowedIntegrationsFromEnv: mockGetAllowedIntegrationsFromEnv,
47+
isAccessControlEnabled: false,
48+
isHosted: false,
49+
}))
50+
vi.mock('@/lib/permission-groups/types', () => ({
51+
DEFAULT_PERMISSION_GROUP_CONFIG,
52+
parsePermissionGroupConfig: (config: unknown) => {
53+
if (!config || typeof config !== 'object') return DEFAULT_PERMISSION_GROUP_CONFIG
54+
return { ...DEFAULT_PERMISSION_GROUP_CONFIG, ...config }
55+
},
56+
}))
57+
vi.mock('@/providers/utils', () => ({
58+
getProviderFromModel: mockGetProviderFromModel,
59+
}))
60+
61+
import {
62+
getUserPermissionConfig,
63+
IntegrationNotAllowedError,
64+
validateBlockType,
65+
} from './permission-check'
66+
67+
describe('IntegrationNotAllowedError', () => {
68+
it.concurrent('creates error with correct name and message', () => {
69+
const error = new IntegrationNotAllowedError('discord')
70+
71+
expect(error).toBeInstanceOf(Error)
72+
expect(error.name).toBe('IntegrationNotAllowedError')
73+
expect(error.message).toContain('discord')
74+
})
75+
76+
it.concurrent('includes custom reason when provided', () => {
77+
const error = new IntegrationNotAllowedError('discord', 'blocked by server policy')
78+
79+
expect(error.message).toContain('blocked by server policy')
80+
})
81+
})
82+
83+
describe('getUserPermissionConfig', () => {
84+
beforeEach(() => {
85+
vi.clearAllMocks()
86+
})
87+
88+
it('returns null when no env allowlist is configured', async () => {
89+
mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)
90+
91+
const config = await getUserPermissionConfig('user-123')
92+
93+
expect(config).toBeNull()
94+
})
95+
96+
it('returns config with env allowlist when configured', async () => {
97+
mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack', 'gmail'])
98+
99+
const config = await getUserPermissionConfig('user-123')
100+
101+
expect(config).not.toBeNull()
102+
expect(config!.allowedIntegrations).toEqual(['slack', 'gmail'])
103+
})
104+
105+
it('preserves default values for non-allowlist fields', async () => {
106+
mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack'])
107+
108+
const config = await getUserPermissionConfig('user-123')
109+
110+
expect(config!.disableMcpTools).toBe(false)
111+
expect(config!.allowedModelProviders).toBeNull()
112+
})
113+
})
114+
115+
describe('env allowlist fallback when userId is absent', () => {
116+
beforeEach(() => {
117+
vi.clearAllMocks()
118+
})
119+
120+
it('returns null allowlist when no userId and no env allowlist', async () => {
121+
mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)
122+
123+
const userId: string | undefined = undefined
124+
const permissionConfig = userId ? await getUserPermissionConfig(userId) : null
125+
const allowedIntegrations =
126+
permissionConfig?.allowedIntegrations ?? mockGetAllowedIntegrationsFromEnv()
127+
128+
expect(allowedIntegrations).toBeNull()
129+
})
130+
131+
it('falls back to env allowlist when no userId is provided', async () => {
132+
mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack', 'gmail'])
133+
134+
const userId: string | undefined = undefined
135+
const permissionConfig = userId ? await getUserPermissionConfig(userId) : null
136+
const allowedIntegrations =
137+
permissionConfig?.allowedIntegrations ?? mockGetAllowedIntegrationsFromEnv()
138+
139+
expect(allowedIntegrations).toEqual(['slack', 'gmail'])
140+
})
141+
142+
it('env allowlist filters block types when userId is absent', async () => {
143+
mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack', 'gmail'])
144+
145+
const userId: string | undefined = undefined
146+
const permissionConfig = userId ? await getUserPermissionConfig(userId) : null
147+
const allowedIntegrations =
148+
permissionConfig?.allowedIntegrations ?? mockGetAllowedIntegrationsFromEnv()
149+
150+
expect(allowedIntegrations).not.toBeNull()
151+
expect(allowedIntegrations!.includes('slack')).toBe(true)
152+
expect(allowedIntegrations!.includes('discord')).toBe(false)
153+
})
154+
155+
it('uses permission config when userId is present, ignoring env fallback', async () => {
156+
mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack', 'gmail'])
157+
158+
const config = await getUserPermissionConfig('user-123')
159+
160+
expect(config).not.toBeNull()
161+
expect(config!.allowedIntegrations).toEqual(['slack', 'gmail'])
162+
})
163+
})
164+
165+
describe('validateBlockType', () => {
166+
beforeEach(() => {
167+
vi.clearAllMocks()
168+
})
169+
170+
describe('when no env allowlist is configured', () => {
171+
beforeEach(() => {
172+
mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)
173+
})
174+
175+
it('allows any block type', async () => {
176+
await validateBlockType(undefined, 'google_drive')
177+
})
178+
179+
it('allows multi-word block types', async () => {
180+
await validateBlockType(undefined, 'microsoft_excel')
181+
})
182+
183+
it('always allows start_trigger', async () => {
184+
await validateBlockType(undefined, 'start_trigger')
185+
})
186+
})
187+
188+
describe('when env allowlist is configured', () => {
189+
beforeEach(() => {
190+
mockGetAllowedIntegrationsFromEnv.mockReturnValue([
191+
'slack',
192+
'google_drive',
193+
'microsoft_excel',
194+
])
195+
})
196+
197+
it('allows block types on the allowlist', async () => {
198+
await validateBlockType(undefined, 'slack')
199+
await validateBlockType(undefined, 'google_drive')
200+
await validateBlockType(undefined, 'microsoft_excel')
201+
})
202+
203+
it('rejects block types not on the allowlist', async () => {
204+
await expect(validateBlockType(undefined, 'discord')).rejects.toThrow(
205+
IntegrationNotAllowedError
206+
)
207+
})
208+
209+
it('always allows start_trigger regardless of allowlist', async () => {
210+
await validateBlockType(undefined, 'start_trigger')
211+
})
212+
213+
it('matches case-insensitively', async () => {
214+
await validateBlockType(undefined, 'Slack')
215+
await validateBlockType(undefined, 'GOOGLE_DRIVE')
216+
})
217+
218+
it('includes env reason in error when env allowlist is the source', async () => {
219+
await expect(validateBlockType(undefined, 'discord')).rejects.toThrow(/ALLOWED_INTEGRATIONS/)
220+
})
221+
222+
it('includes env reason even when userId is present if env is the source', async () => {
223+
await expect(validateBlockType('user-123', 'discord')).rejects.toThrow(/ALLOWED_INTEGRATIONS/)
224+
})
225+
})
226+
})
227+
228+
describe('service ID to block type normalization', () => {
229+
it.concurrent('hyphenated service IDs match underscore block types after normalization', () => {
230+
const allowedBlockTypes = [
231+
'google_drive',
232+
'microsoft_excel',
233+
'microsoft_teams',
234+
'google_sheets',
235+
'google_docs',
236+
'google_calendar',
237+
'google_forms',
238+
'microsoft_planner',
239+
]
240+
const serviceIds = [
241+
'google-drive',
242+
'microsoft-excel',
243+
'microsoft-teams',
244+
'google-sheets',
245+
'google-docs',
246+
'google-calendar',
247+
'google-forms',
248+
'microsoft-planner',
249+
]
250+
251+
for (const serviceId of serviceIds) {
252+
const normalized = serviceId.replace(/-/g, '_')
253+
expect(allowedBlockTypes).toContain(normalized)
254+
}
255+
})
256+
257+
it.concurrent('single-word service IDs are unaffected by normalization', () => {
258+
const serviceIds = ['slack', 'gmail', 'notion', 'discord', 'jira', 'trello']
259+
260+
for (const serviceId of serviceIds) {
261+
const normalized = serviceId.replace(/-/g, '_')
262+
expect(normalized).toBe(serviceId)
263+
}
264+
})
265+
})

0 commit comments

Comments
 (0)