Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
14 changes: 14 additions & 0 deletions apps/sim/app/api/settings/allowed-integrations/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags'

export async function GET() {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

return NextResponse.json({
allowedIntegrations: getAllowedIntegrationsFromEnv(),
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -223,13 +223,11 @@ export function Integrations({ onOpenChange, registerCloseHandler }: Integration
}
}

// Group services by provider, filtering by permission config
const groupedServices = services.reduce(
(acc, service) => {
// Filter based on allowedIntegrations
if (
permissionConfig.allowedIntegrations !== null &&
!permissionConfig.allowedIntegrations.includes(service.id)
!permissionConfig.allowedIntegrations.includes(service.id.replace(/-/g, '_'))
) {
return acc
}
Expand Down
209 changes: 209 additions & 0 deletions apps/sim/ee/access-control/utils/permission-check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/**
* @vitest-environment node
*/
import { databaseMock, drizzleOrmMock, loggerMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const DEFAULT_PERMISSION_GROUP_CONFIG = {
allowedIntegrations: null,
allowedModelProviders: null,
hideTraceSpans: false,
hideKnowledgeBaseTab: false,
hideCopilot: false,
hideApiKeysTab: false,
hideEnvironmentTab: false,
hideFilesTab: false,
disableMcpTools: false,
disableCustomTools: false,
disableSkills: false,
hideTemplates: false,
disableInvitations: false,
hideDeployApi: false,
hideDeployMcp: false,
hideDeployA2a: false,
hideDeployChatbot: false,
hideDeployTemplate: false,
}

const mockGetAllowedIntegrationsFromEnv = vi.fn<() => string[] | null>()
const mockIsOrganizationOnEnterprisePlan = vi.fn<() => Promise<boolean>>()
const mockGetProviderFromModel = vi.fn<(model: string) => string>()

vi.doMock('@sim/db', () => databaseMock)
vi.doMock('@sim/db/schema', () => ({}))
vi.doMock('@sim/logger', () => loggerMock)
vi.doMock('drizzle-orm', () => drizzleOrmMock)
vi.doMock('@/lib/billing', () => ({
isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan,
}))
vi.doMock('@/lib/core/config/feature-flags', () => ({
getAllowedIntegrationsFromEnv: mockGetAllowedIntegrationsFromEnv,
isAccessControlEnabled: false,
isHosted: false,
}))
vi.doMock('@/lib/permission-groups/types', () => ({
DEFAULT_PERMISSION_GROUP_CONFIG,
parsePermissionGroupConfig: (config: unknown) => {
if (!config || typeof config !== 'object') return DEFAULT_PERMISSION_GROUP_CONFIG
return { ...DEFAULT_PERMISSION_GROUP_CONFIG, ...config }
},
}))
vi.doMock('@/providers/utils', () => ({
getProviderFromModel: mockGetProviderFromModel,
}))

const { IntegrationNotAllowedError, getUserPermissionConfig, validateBlockType } = await import(
'./permission-check'
)

describe('IntegrationNotAllowedError', () => {
it.concurrent('creates error with correct name and message', () => {
const error = new IntegrationNotAllowedError('discord')

expect(error).toBeInstanceOf(Error)
expect(error.name).toBe('IntegrationNotAllowedError')
expect(error.message).toContain('discord')
})

it.concurrent('includes custom reason when provided', () => {
const error = new IntegrationNotAllowedError('discord', 'blocked by server policy')

expect(error.message).toContain('blocked by server policy')
})
})

describe('getUserPermissionConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('returns null when no env allowlist is configured', async () => {
mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)

const config = await getUserPermissionConfig('user-123')

expect(config).toBeNull()
})

it('returns config with env allowlist when configured', async () => {
mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack', 'gmail'])

const config = await getUserPermissionConfig('user-123')

expect(config).not.toBeNull()
expect(config!.allowedIntegrations).toEqual(['slack', 'gmail'])
})

it('preserves default values for non-allowlist fields', async () => {
mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack'])

const config = await getUserPermissionConfig('user-123')

expect(config!.disableMcpTools).toBe(false)
expect(config!.allowedModelProviders).toBeNull()
})
})

describe('validateBlockType', () => {
beforeEach(() => {
vi.clearAllMocks()
})

describe('when no env allowlist is configured', () => {
beforeEach(() => {
mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)
})

it('allows any block type', async () => {
await expect(validateBlockType(undefined, 'google_drive')).resolves.not.toThrow()
})

it('allows multi-word block types', async () => {
await expect(validateBlockType(undefined, 'microsoft_excel')).resolves.not.toThrow()
})

it('always allows start_trigger', async () => {
await expect(validateBlockType(undefined, 'start_trigger')).resolves.not.toThrow()
})
})

describe('when env allowlist is configured', () => {
beforeEach(() => {
mockGetAllowedIntegrationsFromEnv.mockReturnValue([
'slack',
'google_drive',
'microsoft_excel',
])
})

it('allows block types on the allowlist', async () => {
await expect(validateBlockType(undefined, 'slack')).resolves.not.toThrow()
await expect(validateBlockType(undefined, 'google_drive')).resolves.not.toThrow()
await expect(validateBlockType(undefined, 'microsoft_excel')).resolves.not.toThrow()
})

it('rejects block types not on the allowlist', async () => {
await expect(validateBlockType(undefined, 'discord')).rejects.toThrow(
IntegrationNotAllowedError
)
})

it('always allows start_trigger regardless of allowlist', async () => {
await expect(validateBlockType(undefined, 'start_trigger')).resolves.not.toThrow()
})

it('matches case-insensitively', async () => {
await expect(validateBlockType(undefined, 'Slack')).resolves.not.toThrow()
await expect(validateBlockType(undefined, 'GOOGLE_DRIVE')).resolves.not.toThrow()
})

it('includes reason in error for env-only enforcement', async () => {
await expect(validateBlockType(undefined, 'discord')).rejects.toThrow(/ALLOWED_INTEGRATIONS/)
})

it('does not include env reason when userId is provided', async () => {
await expect(validateBlockType('user-123', 'discord')).rejects.toThrow(
/permission group settings/
)
})
})
})

describe('service ID to block type normalization', () => {
it.concurrent('hyphenated service IDs match underscore block types after normalization', () => {
const allowedBlockTypes = [
'google_drive',
'microsoft_excel',
'microsoft_teams',
'google_sheets',
'google_docs',
'google_calendar',
'google_forms',
'microsoft_planner',
]
const serviceIds = [
'google-drive',
'microsoft-excel',
'microsoft-teams',
'google-sheets',
'google-docs',
'google-calendar',
'google-forms',
'microsoft-planner',
]

for (const serviceId of serviceIds) {
const normalized = serviceId.replace(/-/g, '_')
expect(allowedBlockTypes).toContain(normalized)
}
})

it.concurrent('single-word service IDs are unaffected by normalization', () => {
const serviceIds = ['slack', 'gmail', 'notion', 'discord', 'jira', 'trello']

for (const serviceId of serviceIds) {
const normalized = serviceId.replace(/-/g, '_')
expect(normalized).toBe(serviceId)
}
})
})
73 changes: 57 additions & 16 deletions apps/sim/ee/access-control/utils/permission-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@ import { member, permissionGroup, permissionGroupMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
import { isAccessControlEnabled, isHosted } from '@/lib/core/config/feature-flags'
import {
getAllowedIntegrationsFromEnv,
isAccessControlEnabled,
isHosted,
} from '@/lib/core/config/feature-flags'
import {
DEFAULT_PERMISSION_GROUP_CONFIG,
type PermissionGroupConfig,
parsePermissionGroupConfig,
} from '@/lib/permission-groups/types'
Expand All @@ -23,8 +28,12 @@ export class ProviderNotAllowedError extends Error {
}

export class IntegrationNotAllowedError extends Error {
constructor(blockType: string) {
super(`Integration "${blockType}" is not allowed based on your permission group settings`)
constructor(blockType: string, reason?: string) {
super(
reason
? `Integration "${blockType}" is not allowed: ${reason}`
: `Integration "${blockType}" is not allowed based on your permission group settings`
)
this.name = 'IntegrationNotAllowedError'
}
}
Expand Down Expand Up @@ -57,11 +66,38 @@ export class InvitationsNotAllowedError extends Error {
}
}

/**
* Merges the env allowlist into a permission config.
* If `config` is null and no env allowlist is set, returns null.
* If `config` is null but env allowlist is set, returns a default config with only allowedIntegrations set.
* If both are set, intersects the two allowlists.
*/
function mergeEnvAllowlist(config: PermissionGroupConfig | null): PermissionGroupConfig | null {
const envAllowlist = getAllowedIntegrationsFromEnv()

if (envAllowlist === null) {
return config
}

if (config === null) {
return { ...DEFAULT_PERMISSION_GROUP_CONFIG, allowedIntegrations: envAllowlist }
}

const merged =
config.allowedIntegrations === null
? envAllowlist
: config.allowedIntegrations
.map((i) => i.toLowerCase())
.filter((i) => envAllowlist.includes(i))

return { ...config, allowedIntegrations: merged }
}

export async function getUserPermissionConfig(
userId: string
): Promise<PermissionGroupConfig | null> {
if (!isHosted && !isAccessControlEnabled) {
return null
return mergeEnvAllowlist(null)
}

const [membership] = await db
Expand All @@ -71,12 +107,12 @@ export async function getUserPermissionConfig(
.limit(1)

if (!membership) {
return null
return mergeEnvAllowlist(null)
}

const isEnterprise = await isOrganizationOnEnterprisePlan(membership.organizationId)
if (!isEnterprise) {
return null
return mergeEnvAllowlist(null)
}

const [groupMembership] = await db
Expand All @@ -92,10 +128,10 @@ export async function getUserPermissionConfig(
.limit(1)

if (!groupMembership) {
return null
return mergeEnvAllowlist(null)
}

return parsePermissionGroupConfig(groupMembership.config)
return mergeEnvAllowlist(parsePermissionGroupConfig(groupMembership.config))
}

export async function getPermissionConfig(
Expand Down Expand Up @@ -152,19 +188,24 @@ export async function validateBlockType(
return
}

if (!userId) {
return
}

const config = await getPermissionConfig(userId, ctx)
const config = userId ? await getPermissionConfig(userId, ctx) : mergeEnvAllowlist(null)

if (!config || config.allowedIntegrations === null) {
return
}

if (!config.allowedIntegrations.includes(blockType)) {
logger.warn('Integration blocked by permission group', { userId, blockType })
throw new IntegrationNotAllowedError(blockType)
if (!config.allowedIntegrations.includes(blockType.toLowerCase())) {
const isEnvOnly = !userId
logger.warn(
isEnvOnly
? 'Integration blocked by env allowlist'
: 'Integration blocked by permission config',
{ userId, blockType }
)
throw new IntegrationNotAllowedError(
blockType,
isEnvOnly ? 'blocked by server ALLOWED_INTEGRATIONS policy' : undefined
)
}
}

Expand Down
1 change: 1 addition & 0 deletions apps/sim/executor/handlers/agent/agent-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ vi.mock('@/lib/core/config/feature-flags', () => ({
isDev: true,
isTest: false,
getCostMultiplier: vi.fn().mockReturnValue(1),
getAllowedIntegrationsFromEnv: vi.fn().mockReturnValue(null),
isEmailVerificationEnabled: false,
isBillingEnabled: false,
isOrganizationsEnabled: false,
Expand Down
Loading