mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-18 18:25:14 -05:00
* 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
266 lines
8.3 KiB
TypeScript
266 lines
8.3 KiB
TypeScript
/**
|
|
* @vitest-environment node
|
|
*/
|
|
import { databaseMock, drizzleOrmMock, loggerMock } from '@sim/testing'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const {
|
|
DEFAULT_PERMISSION_GROUP_CONFIG,
|
|
mockGetAllowedIntegrationsFromEnv,
|
|
mockIsOrganizationOnEnterprisePlan,
|
|
mockGetProviderFromModel,
|
|
} = vi.hoisted(() => ({
|
|
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,
|
|
},
|
|
mockGetAllowedIntegrationsFromEnv: vi.fn<() => string[] | null>(),
|
|
mockIsOrganizationOnEnterprisePlan: vi.fn<() => Promise<boolean>>(),
|
|
mockGetProviderFromModel: vi.fn<(model: string) => string>(),
|
|
}))
|
|
|
|
vi.mock('@sim/db', () => databaseMock)
|
|
vi.mock('@sim/db/schema', () => ({}))
|
|
vi.mock('@sim/logger', () => loggerMock)
|
|
vi.mock('drizzle-orm', () => drizzleOrmMock)
|
|
vi.mock('@/lib/billing', () => ({
|
|
isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan,
|
|
}))
|
|
vi.mock('@/lib/core/config/feature-flags', () => ({
|
|
getAllowedIntegrationsFromEnv: mockGetAllowedIntegrationsFromEnv,
|
|
isAccessControlEnabled: false,
|
|
isHosted: false,
|
|
}))
|
|
vi.mock('@/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.mock('@/providers/utils', () => ({
|
|
getProviderFromModel: mockGetProviderFromModel,
|
|
}))
|
|
|
|
import {
|
|
getUserPermissionConfig,
|
|
IntegrationNotAllowedError,
|
|
validateBlockType,
|
|
} from './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('env allowlist fallback when userId is absent', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('returns null allowlist when no userId and no env allowlist', async () => {
|
|
mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)
|
|
|
|
const userId: string | undefined = undefined
|
|
const permissionConfig = userId ? await getUserPermissionConfig(userId) : null
|
|
const allowedIntegrations =
|
|
permissionConfig?.allowedIntegrations ?? mockGetAllowedIntegrationsFromEnv()
|
|
|
|
expect(allowedIntegrations).toBeNull()
|
|
})
|
|
|
|
it('falls back to env allowlist when no userId is provided', async () => {
|
|
mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack', 'gmail'])
|
|
|
|
const userId: string | undefined = undefined
|
|
const permissionConfig = userId ? await getUserPermissionConfig(userId) : null
|
|
const allowedIntegrations =
|
|
permissionConfig?.allowedIntegrations ?? mockGetAllowedIntegrationsFromEnv()
|
|
|
|
expect(allowedIntegrations).toEqual(['slack', 'gmail'])
|
|
})
|
|
|
|
it('env allowlist filters block types when userId is absent', async () => {
|
|
mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack', 'gmail'])
|
|
|
|
const userId: string | undefined = undefined
|
|
const permissionConfig = userId ? await getUserPermissionConfig(userId) : null
|
|
const allowedIntegrations =
|
|
permissionConfig?.allowedIntegrations ?? mockGetAllowedIntegrationsFromEnv()
|
|
|
|
expect(allowedIntegrations).not.toBeNull()
|
|
expect(allowedIntegrations!.includes('slack')).toBe(true)
|
|
expect(allowedIntegrations!.includes('discord')).toBe(false)
|
|
})
|
|
|
|
it('uses permission config when userId is present, ignoring env fallback', async () => {
|
|
mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack', 'gmail'])
|
|
|
|
const config = await getUserPermissionConfig('user-123')
|
|
|
|
expect(config).not.toBeNull()
|
|
expect(config!.allowedIntegrations).toEqual(['slack', 'gmail'])
|
|
})
|
|
})
|
|
|
|
describe('validateBlockType', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('when no env allowlist is configured', () => {
|
|
beforeEach(() => {
|
|
mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)
|
|
})
|
|
|
|
it('allows any block type', async () => {
|
|
await validateBlockType(undefined, 'google_drive')
|
|
})
|
|
|
|
it('allows multi-word block types', async () => {
|
|
await validateBlockType(undefined, 'microsoft_excel')
|
|
})
|
|
|
|
it('always allows start_trigger', async () => {
|
|
await validateBlockType(undefined, 'start_trigger')
|
|
})
|
|
})
|
|
|
|
describe('when env allowlist is configured', () => {
|
|
beforeEach(() => {
|
|
mockGetAllowedIntegrationsFromEnv.mockReturnValue([
|
|
'slack',
|
|
'google_drive',
|
|
'microsoft_excel',
|
|
])
|
|
})
|
|
|
|
it('allows block types on the allowlist', async () => {
|
|
await validateBlockType(undefined, 'slack')
|
|
await validateBlockType(undefined, 'google_drive')
|
|
await validateBlockType(undefined, 'microsoft_excel')
|
|
})
|
|
|
|
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 validateBlockType(undefined, 'start_trigger')
|
|
})
|
|
|
|
it('matches case-insensitively', async () => {
|
|
await validateBlockType(undefined, 'Slack')
|
|
await validateBlockType(undefined, 'GOOGLE_DRIVE')
|
|
})
|
|
|
|
it('includes env reason in error when env allowlist is the source', async () => {
|
|
await expect(validateBlockType(undefined, 'discord')).rejects.toThrow(/ALLOWED_INTEGRATIONS/)
|
|
})
|
|
|
|
it('includes env reason even when userId is present if env is the source', async () => {
|
|
await expect(validateBlockType('user-123', 'discord')).rejects.toThrow(/ALLOWED_INTEGRATIONS/)
|
|
})
|
|
})
|
|
})
|
|
|
|
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)
|
|
}
|
|
})
|
|
})
|