Skip to content

Commit 2f72a24

Browse files
committed
feat(access-control): add ALLOWED_INTEGRATIONS env var for self-hosted block restrictions
1 parent a0afb5d commit 2f72a24

File tree

6 files changed

+74
-8
lines changed

6 files changed

+74
-8
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { NextResponse } from 'next/server'
2+
import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags'
3+
4+
export async function GET() {
5+
return NextResponse.json({
6+
allowedIntegrations: getAllowedIntegrationsFromEnv(),
7+
})
8+
}

apps/sim/ee/access-control/utils/permission-check.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { member, permissionGroup, permissionGroupMember } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { and, eq } from 'drizzle-orm'
55
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
6-
import { isAccessControlEnabled, isHosted } from '@/lib/core/config/feature-flags'
6+
import {
7+
getAllowedIntegrationsFromEnv,
8+
isAccessControlEnabled,
9+
isHosted,
10+
} from '@/lib/core/config/feature-flags'
711
import {
812
type PermissionGroupConfig,
913
parsePermissionGroupConfig,
@@ -152,6 +156,12 @@ export async function validateBlockType(
152156
return
153157
}
154158

159+
const envAllowlist = getAllowedIntegrationsFromEnv()
160+
if (envAllowlist !== null && !envAllowlist.includes(blockType)) {
161+
logger.warn('Integration blocked by env allowlist', { blockType })
162+
throw new IntegrationNotAllowedError(blockType)
163+
}
164+
155165
if (!userId) {
156166
return
157167
}

apps/sim/hooks/use-permission-config.ts

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client'
22

33
import { useMemo } from 'react'
4+
import { useQuery } from '@tanstack/react-query'
45
import { getEnv, isTruthy } from '@/lib/core/config/env'
56
import { isAccessControlEnabled, isHosted } from '@/lib/core/config/feature-flags'
67
import {
@@ -21,12 +22,39 @@ export interface PermissionConfigResult {
2122
isInvitationsDisabled: boolean
2223
}
2324

25+
interface AllowedIntegrationsResponse {
26+
allowedIntegrations: string[] | null
27+
}
28+
29+
function useAllowedIntegrationsFromEnv() {
30+
return useQuery<AllowedIntegrationsResponse>({
31+
queryKey: ['allowedIntegrations', 'env'],
32+
queryFn: async () => {
33+
const response = await fetch('/api/settings/allowed-integrations')
34+
if (!response.ok) return { allowedIntegrations: null }
35+
return response.json()
36+
},
37+
staleTime: 5 * 60 * 1000,
38+
})
39+
}
40+
41+
/**
42+
* Intersects two allowlists. If either is null (unrestricted), returns the other.
43+
* If both are set, returns only items present in both.
44+
*/
45+
function intersectAllowlists(a: string[] | null, b: string[] | null): string[] | null {
46+
if (a === null) return b
47+
if (b === null) return a
48+
return a.filter((i) => b.includes(i))
49+
}
50+
2451
export function usePermissionConfig(): PermissionConfigResult {
2552
const accessControlDisabled = !isHosted && !isAccessControlEnabled
2653
const { data: organizationsData } = useOrganizations()
2754
const activeOrganization = organizationsData?.activeOrganization
2855

2956
const { data: permissionData, isLoading } = useUserPermissionConfig(activeOrganization?.id)
57+
const { data: envAllowlistData } = useAllowedIntegrationsFromEnv()
3058

3159
const config = useMemo(() => {
3260
if (accessControlDisabled) {
@@ -40,13 +68,18 @@ export function usePermissionConfig(): PermissionConfigResult {
4068

4169
const isInPermissionGroup = !accessControlDisabled && !!permissionData?.permissionGroupId
4270

71+
const mergedAllowedIntegrations = useMemo(() => {
72+
const envAllowlist = envAllowlistData?.allowedIntegrations ?? null
73+
return intersectAllowlists(config.allowedIntegrations, envAllowlist)
74+
}, [config.allowedIntegrations, envAllowlistData])
75+
4376
const isBlockAllowed = useMemo(() => {
4477
return (blockType: string) => {
4578
if (blockType === 'start_trigger') return true
46-
if (config.allowedIntegrations === null) return true
47-
return config.allowedIntegrations.includes(blockType)
79+
if (mergedAllowedIntegrations === null) return true
80+
return mergedAllowedIntegrations.includes(blockType)
4881
}
49-
}, [config.allowedIntegrations])
82+
}, [mergedAllowedIntegrations])
5083

5184
const isProviderAllowed = useMemo(() => {
5285
return (providerId: string) => {
@@ -57,13 +90,12 @@ export function usePermissionConfig(): PermissionConfigResult {
5790

5891
const filterBlocks = useMemo(() => {
5992
return <T extends { type: string }>(blocks: T[]): T[] => {
60-
if (config.allowedIntegrations === null) return blocks
93+
if (mergedAllowedIntegrations === null) return blocks
6194
return blocks.filter(
62-
(block) =>
63-
block.type === 'start_trigger' || config.allowedIntegrations!.includes(block.type)
95+
(block) => block.type === 'start_trigger' || mergedAllowedIntegrations.includes(block.type)
6496
)
6597
}
66-
}, [config.allowedIntegrations])
98+
}, [mergedAllowedIntegrations])
6799

68100
const filterProviders = useMemo(() => {
69101
return (providerIds: string[]): string[] => {

apps/sim/lib/core/config/env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export const env = createEnv({
9393
EXA_API_KEY: z.string().min(1).optional(), // Exa AI API key for enhanced online search
9494
BLACKLISTED_PROVIDERS: z.string().optional(), // Comma-separated provider IDs to hide (e.g., "openai,anthropic")
9595
BLACKLISTED_MODELS: z.string().optional(), // Comma-separated model names/prefixes to hide (e.g., "gpt-4,claude-*")
96+
ALLOWED_INTEGRATIONS: z.string().optional(), // Comma-separated block types to allow (e.g., "slack,github,agent"). Empty = all allowed.
9697

9798
// Azure Configuration - Shared credentials with feature-specific models
9899
AZURE_OPENAI_ENDPOINT: z.string().url().optional(), // Shared Azure OpenAI service endpoint

apps/sim/lib/core/config/feature-flags.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,18 @@ export const isReactGrabEnabled = isDev && isTruthy(env.REACT_GRAB_ENABLED)
123123
*/
124124
export const isReactScanEnabled = isDev && isTruthy(env.REACT_SCAN_ENABLED)
125125

126+
/**
127+
* Returns the parsed allowlist of integration block types from the environment variable.
128+
* If not set or empty, returns null (meaning all integrations are allowed).
129+
*/
130+
export function getAllowedIntegrationsFromEnv(): string[] | null {
131+
if (!env.ALLOWED_INTEGRATIONS) return null
132+
const parsed = env.ALLOWED_INTEGRATIONS.split(',')
133+
.map((i) => i.trim().toLowerCase())
134+
.filter(Boolean)
135+
return parsed.length > 0 ? parsed : null
136+
}
137+
126138
/**
127139
* Get cost multiplier based on environment
128140
*/

helm/sim/values.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,9 @@ app:
194194
BLACKLISTED_PROVIDERS: "" # Comma-separated provider IDs to hide from UI (e.g., "openai,anthropic,google")
195195
BLACKLISTED_MODELS: "" # Comma-separated model names/prefixes to hide (e.g., "gpt-4,claude-*")
196196

197+
# Integration/Block Restrictions (leave empty if not restricting)
198+
ALLOWED_INTEGRATIONS: "" # Comma-separated block types to allow (e.g., "slack,github,agent"). Empty = all allowed.
199+
197200
# Invitation Control
198201
DISABLE_INVITATIONS: "" # Set to "true" to disable workspace invitations globally
199202
NEXT_PUBLIC_DISABLE_INVITATIONS: "" # Set to "true" to hide invitation UI elements

0 commit comments

Comments
 (0)