From bbcef7ce5cfd8621d4e270f7a958ca5fd9957a5d Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 17 Feb 2026 18:46:24 -0800 Subject: [PATCH] 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 --- .../settings/allowed-integrations/route.ts | 14 + .../components/integrations/integrations.tsx | 4 +- .../utils/permission-check.test.ts | 265 ++++++++++++++++++ .../access-control/utils/permission-check.ts | 74 +++-- .../handlers/agent/agent-handler.test.ts | 1 + apps/sim/hooks/use-permission-config.ts | 62 +++- apps/sim/lib/copilot/process-contents.ts | 17 +- .../tools/server/blocks/get-block-config.ts | 6 +- .../tools/server/blocks/get-block-options.ts | 6 +- .../server/blocks/get-blocks-and-tools.ts | 7 +- .../server/blocks/get-blocks-metadata-tool.ts | 7 +- .../tools/server/blocks/get-trigger-blocks.ts | 7 +- .../workflow/edit-workflow/validation.ts | 2 +- apps/sim/lib/core/config/env.ts | 1 + apps/sim/lib/core/config/feature-flags.ts | 12 + helm/sim/values.yaml | 3 + 16 files changed, 438 insertions(+), 50 deletions(-) create mode 100644 apps/sim/app/api/settings/allowed-integrations/route.ts create mode 100644 apps/sim/ee/access-control/utils/permission-check.test.ts diff --git a/apps/sim/app/api/settings/allowed-integrations/route.ts b/apps/sim/app/api/settings/allowed-integrations/route.ts new file mode 100644 index 000000000..d05887641 --- /dev/null +++ b/apps/sim/app/api/settings/allowed-integrations/route.ts @@ -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(), + }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/integrations/integrations.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/integrations/integrations.tsx index dabdfc03f..6f7fb5397 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/integrations/integrations.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/integrations/integrations.tsx @@ -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 } diff --git a/apps/sim/ee/access-control/utils/permission-check.test.ts b/apps/sim/ee/access-control/utils/permission-check.test.ts new file mode 100644 index 000000000..995af13cf --- /dev/null +++ b/apps/sim/ee/access-control/utils/permission-check.test.ts @@ -0,0 +1,265 @@ +/** + * @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>(), + 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) + } + }) +}) diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index c3a5e05e8..4e74b1af8 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -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' @@ -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' } } @@ -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 { if (!isHosted && !isAccessControlEnabled) { - return null + return mergeEnvAllowlist(null) } const [membership] = await db @@ -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 @@ -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( @@ -152,19 +188,25 @@ 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 envAllowlist = getAllowedIntegrationsFromEnv() + const blockedByEnv = envAllowlist !== null && !envAllowlist.includes(blockType.toLowerCase()) + logger.warn( + blockedByEnv + ? 'Integration blocked by env allowlist' + : 'Integration blocked by permission group', + { userId, blockType } + ) + throw new IntegrationNotAllowedError( + blockType, + blockedByEnv ? 'blocked by server ALLOWED_INTEGRATIONS policy' : undefined + ) } } diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index 217971b9e..75c22e8be 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -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, diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index 3c536caf5..32c16e227 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -1,6 +1,7 @@ 'use client' import { useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' import { getEnv, isTruthy } from '@/lib/core/config/env' import { isAccessControlEnabled, isHosted } from '@/lib/core/config/feature-flags' import { @@ -21,12 +22,44 @@ export interface PermissionConfigResult { isInvitationsDisabled: boolean } +interface AllowedIntegrationsResponse { + allowedIntegrations: string[] | null +} + +function useAllowedIntegrationsFromEnv() { + return useQuery({ + queryKey: ['allowedIntegrations', 'env'], + queryFn: async () => { + const response = await fetch('/api/settings/allowed-integrations') + if (!response.ok) return { allowedIntegrations: null } + return response.json() + }, + staleTime: 5 * 60 * 1000, + }) +} + +/** + * Intersects two allowlists. If either is null (unrestricted), returns the other. + * If both are set, returns only items present in both. + */ +function intersectAllowlists(a: string[] | null, b: string[] | null): string[] | null { + if (a === null) return b + if (b === null) return a + return a.map((i) => i.toLowerCase()).filter((i) => b.includes(i)) +} + export function usePermissionConfig(): PermissionConfigResult { const accessControlDisabled = !isHosted && !isAccessControlEnabled const { data: organizationsData } = useOrganizations() const activeOrganization = organizationsData?.activeOrganization - const { data: permissionData, isLoading } = useUserPermissionConfig(activeOrganization?.id) + const { data: permissionData, isLoading: isPermissionLoading } = useUserPermissionConfig( + activeOrganization?.id + ) + const { data: envAllowlistData, isLoading: isEnvAllowlistLoading } = + useAllowedIntegrationsFromEnv() + + const isLoading = isPermissionLoading || isEnvAllowlistLoading const config = useMemo(() => { if (accessControlDisabled) { @@ -40,13 +73,18 @@ export function usePermissionConfig(): PermissionConfigResult { const isInPermissionGroup = !accessControlDisabled && !!permissionData?.permissionGroupId + const mergedAllowedIntegrations = useMemo(() => { + const envAllowlist = envAllowlistData?.allowedIntegrations ?? null + return intersectAllowlists(config.allowedIntegrations, envAllowlist) + }, [config.allowedIntegrations, envAllowlistData]) + const isBlockAllowed = useMemo(() => { return (blockType: string) => { if (blockType === 'start_trigger') return true - if (config.allowedIntegrations === null) return true - return config.allowedIntegrations.includes(blockType) + if (mergedAllowedIntegrations === null) return true + return mergedAllowedIntegrations.includes(blockType.toLowerCase()) } - }, [config.allowedIntegrations]) + }, [mergedAllowedIntegrations]) const isProviderAllowed = useMemo(() => { return (providerId: string) => { @@ -57,13 +95,14 @@ export function usePermissionConfig(): PermissionConfigResult { const filterBlocks = useMemo(() => { return (blocks: T[]): T[] => { - if (config.allowedIntegrations === null) return blocks + if (mergedAllowedIntegrations === null) return blocks return blocks.filter( (block) => - block.type === 'start_trigger' || config.allowedIntegrations!.includes(block.type) + block.type === 'start_trigger' || + mergedAllowedIntegrations.includes(block.type.toLowerCase()) ) } - }, [config.allowedIntegrations]) + }, [mergedAllowedIntegrations]) const filterProviders = useMemo(() => { return (providerIds: string[]): string[] => { @@ -77,9 +116,14 @@ export function usePermissionConfig(): PermissionConfigResult { return featureFlagDisabled || config.disableInvitations }, [config.disableInvitations]) + const mergedConfig = useMemo( + () => ({ ...config, allowedIntegrations: mergedAllowedIntegrations }), + [config, mergedAllowedIntegrations] + ) + return useMemo( () => ({ - config, + config: mergedConfig, isLoading, isInPermissionGroup, filterBlocks, @@ -89,7 +133,7 @@ export function usePermissionConfig(): PermissionConfigResult { isInvitationsDisabled, }), [ - config, + mergedConfig, isLoading, isInPermissionGroup, filterBlocks, diff --git a/apps/sim/lib/copilot/process-contents.ts b/apps/sim/lib/copilot/process-contents.ts index 9e1eeb079..46709ef44 100644 --- a/apps/sim/lib/copilot/process-contents.ts +++ b/apps/sim/lib/copilot/process-contents.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { copilotChats, document, knowledgeBase, templates } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' +import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import { isHiddenFromDisplay } from '@/blocks/types' @@ -349,16 +350,14 @@ async function processBlockMetadata( userId?: string ): Promise { try { - if (userId) { - const permissionConfig = await getUserPermissionConfig(userId) - const allowedIntegrations = permissionConfig?.allowedIntegrations - if (allowedIntegrations != null && !allowedIntegrations.includes(blockId)) { - logger.debug('Block not allowed by permission group', { blockId, userId }) - return null - } + const permissionConfig = userId ? await getUserPermissionConfig(userId) : null + const allowedIntegrations = + permissionConfig?.allowedIntegrations ?? getAllowedIntegrationsFromEnv() + if (allowedIntegrations != null && !allowedIntegrations.includes(blockId.toLowerCase())) { + logger.debug('Block not allowed by integration allowlist', { blockId, userId }) + return null } - // Reuse registry to match get_blocks_metadata tool result const { registry: blockRegistry } = await import('@/blocks/registry') const { tools: toolsRegistry } = await import('@/tools/registry') const SPECIAL_BLOCKS_METADATA: Record = {} @@ -466,7 +465,6 @@ async function processWorkflowBlockFromDb( if (!block) return null const tag = label ? `@${label} in Workflow` : `@${block.name || blockId} in Workflow` - // Build content: isolate the block and include its subBlocks fully const contentObj = { workflowId, block: block, @@ -518,7 +516,6 @@ async function processExecutionLogFromDb( endedAt: log.endedAt?.toISOString?.() || (log.endedAt ? String(log.endedAt) : null), totalDurationMs: log.totalDurationMs ?? null, workflowName: log.workflowName || '', - // Include trace spans and any available details without being huge executionData: log.executionData ? { traceSpans: (log.executionData as any).traceSpans || undefined, diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-block-config.ts b/apps/sim/lib/copilot/tools/server/blocks/get-block-config.ts index 64021e07c..b9df7fa2c 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-block-config.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-block-config.ts @@ -6,6 +6,7 @@ import { GetBlockConfigResult, type GetBlockConfigResultType, } from '@/lib/copilot/tools/shared/schemas' +import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags' import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry' import { isHiddenFromDisplay, type SubBlockConfig } from '@/blocks/types' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' @@ -439,9 +440,10 @@ export const getBlockConfigServerTool: BaseServerTool< } const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null - const allowedIntegrations = permissionConfig?.allowedIntegrations + const allowedIntegrations = + permissionConfig?.allowedIntegrations ?? getAllowedIntegrationsFromEnv() - if (allowedIntegrations != null && !allowedIntegrations.includes(blockType)) { + if (allowedIntegrations != null && !allowedIntegrations.includes(blockType.toLowerCase())) { throw new Error(`Block "${blockType}" is not available`) } diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-block-options.ts b/apps/sim/lib/copilot/tools/server/blocks/get-block-options.ts index 49c0648b2..1e7772748 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-block-options.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-block-options.ts @@ -6,6 +6,7 @@ import { GetBlockOptionsResult, type GetBlockOptionsResultType, } from '@/lib/copilot/tools/shared/schemas' +import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags' import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { tools as toolsRegistry } from '@/tools/registry' @@ -59,9 +60,10 @@ export const getBlockOptionsServerTool: BaseServerTool< } const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null - const allowedIntegrations = permissionConfig?.allowedIntegrations + const allowedIntegrations = + permissionConfig?.allowedIntegrations ?? getAllowedIntegrationsFromEnv() - if (allowedIntegrations != null && !allowedIntegrations.includes(blockId)) { + if (allowedIntegrations != null && !allowedIntegrations.includes(blockId.toLowerCase())) { throw new Error(`Block "${blockId}" is not available`) } diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-and-tools.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-and-tools.ts index e695f270e..bdd6ee256 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-and-tools.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-and-tools.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import { GetBlocksAndToolsInput, GetBlocksAndToolsResult } from '@/lib/copilot/tools/shared/schemas' +import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags' import { registry as blockRegistry } from '@/blocks/registry' import type { BlockConfig } from '@/blocks/types' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' @@ -17,7 +18,8 @@ export const getBlocksAndToolsServerTool: BaseServerTool< logger.debug('Executing get_blocks_and_tools') const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null - const allowedIntegrations = permissionConfig?.allowedIntegrations + const allowedIntegrations = + permissionConfig?.allowedIntegrations ?? getAllowedIntegrationsFromEnv() type BlockListItem = { type: string @@ -30,7 +32,8 @@ export const getBlocksAndToolsServerTool: BaseServerTool< Object.entries(blockRegistry) .filter(([blockType, blockConfig]: [string, BlockConfig]) => { if (blockConfig.hideFromToolbar) return false - if (allowedIntegrations != null && !allowedIntegrations.includes(blockType)) return false + if (allowedIntegrations != null && !allowedIntegrations.includes(blockType.toLowerCase())) + return false return true }) .forEach(([blockType, blockConfig]: [string, BlockConfig]) => { diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts index ed6b60acb..4e6e7f6fe 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts @@ -3,6 +3,7 @@ import { join } from 'path' import { createLogger } from '@sim/logger' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import { GetBlocksMetadataInput, GetBlocksMetadataResult } from '@/lib/copilot/tools/shared/schemas' +import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags' import { registry as blockRegistry } from '@/blocks/registry' import { AuthMode, type BlockConfig, isHiddenFromDisplay } from '@/blocks/types' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' @@ -112,11 +113,12 @@ export const getBlocksMetadataServerTool: BaseServerTool< logger.debug('Executing get_blocks_metadata', { count: blockIds?.length }) const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null - const allowedIntegrations = permissionConfig?.allowedIntegrations + const allowedIntegrations = + permissionConfig?.allowedIntegrations ?? getAllowedIntegrationsFromEnv() const result: Record = {} for (const blockId of blockIds || []) { - if (allowedIntegrations != null && !allowedIntegrations.includes(blockId)) { + if (allowedIntegrations != null && !allowedIntegrations.includes(blockId.toLowerCase())) { logger.debug('Block not allowed by permission group', { blockId }) continue } @@ -420,7 +422,6 @@ function extractInputs(metadata: CopilotBlockMetadata): { } if (schema.options && schema.options.length > 0) { - // Always return the id (actual value to use), not the display label input.options = schema.options.map((opt) => opt.id || opt.label) } diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts index 367c61475..df77ad6f7 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { z } from 'zod' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' +import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags' import { registry as blockRegistry } from '@/blocks/registry' import type { BlockConfig } from '@/blocks/types' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' @@ -22,13 +23,15 @@ export const getTriggerBlocksServerTool: BaseServerTool< logger.debug('Executing get_trigger_blocks') const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null - const allowedIntegrations = permissionConfig?.allowedIntegrations + const allowedIntegrations = + permissionConfig?.allowedIntegrations ?? getAllowedIntegrationsFromEnv() const triggerBlockIds: string[] = [] Object.entries(blockRegistry).forEach(([blockType, blockConfig]: [string, BlockConfig]) => { if (blockConfig.hideFromToolbar) return - if (allowedIntegrations != null && !allowedIntegrations.includes(blockType)) return + if (allowedIntegrations != null && !allowedIntegrations.includes(blockType.toLowerCase())) + return if (blockConfig.category === 'triggers') { triggerBlockIds.push(blockType) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts index 424be9d25..18b787ba9 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts @@ -657,7 +657,7 @@ export function isBlockTypeAllowed( if (!permissionConfig || permissionConfig.allowedIntegrations === null) { return true } - return permissionConfig.allowedIntegrations.includes(blockType) + return permissionConfig.allowedIntegrations.includes(blockType.toLowerCase()) } /** diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 8d06f938c..7d6a414ba 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -94,6 +94,7 @@ export const env = createEnv({ BLACKLISTED_PROVIDERS: z.string().optional(), // Comma-separated provider IDs to hide (e.g., "openai,anthropic") BLACKLISTED_MODELS: z.string().optional(), // Comma-separated model names/prefixes to hide (e.g., "gpt-4,claude-*") ALLOWED_MCP_DOMAINS: z.string().optional(), // Comma-separated domains for MCP servers (e.g., "internal.company.com,mcp.example.org"). Empty = all allowed. + ALLOWED_INTEGRATIONS: z.string().optional(), // Comma-separated block types to allow (e.g., "slack,github,agent"). Empty = all allowed. // Azure Configuration - Shared credentials with feature-specific models AZURE_OPENAI_ENDPOINT: z.string().url().optional(), // Shared Azure OpenAI service endpoint diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 3ff517fb8..75dc1a177 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -123,6 +123,18 @@ export const isReactGrabEnabled = isDev && isTruthy(env.REACT_GRAB_ENABLED) */ export const isReactScanEnabled = isDev && isTruthy(env.REACT_SCAN_ENABLED) +/** + * Returns the parsed allowlist of integration block types from the environment variable. + * If not set or empty, returns null (meaning all integrations are allowed). + */ +export function getAllowedIntegrationsFromEnv(): string[] | null { + if (!env.ALLOWED_INTEGRATIONS) return null + const parsed = env.ALLOWED_INTEGRATIONS.split(',') + .map((i) => i.trim().toLowerCase()) + .filter(Boolean) + return parsed.length > 0 ? parsed : null +} + /** * Normalizes a domain entry from the ALLOWED_MCP_DOMAINS env var. * Accepts bare hostnames (e.g., "mcp.company.com") or full URLs (e.g., "https://mcp.company.com"). diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index dc7b2081a..61001c6fb 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -195,6 +195,9 @@ app: BLACKLISTED_MODELS: "" # Comma-separated model names/prefixes to hide (e.g., "gpt-4,claude-*") ALLOWED_MCP_DOMAINS: "" # Comma-separated domains for MCP servers (e.g., "internal.company.com,mcp.example.org"). Empty = all allowed. + # Integration/Block Restrictions (leave empty if not restricting) + ALLOWED_INTEGRATIONS: "" # Comma-separated block types to allow (e.g., "slack,github,agent"). Empty = all allowed. + # Invitation Control DISABLE_INVITATIONS: "" # Set to "true" to disable workspace invitations globally NEXT_PUBLIC_DISABLE_INVITATIONS: "" # Set to "true" to hide invitation UI elements