From 8511b323768b0d264e6a2d398d1dbc664ed1180d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 29 May 2026 12:31:56 -0700 Subject: [PATCH 1/3] feat(access-control): add per-model denylist to permission groups --- apps/sim/app/api/guardrails/validate/route.ts | 3 +- apps/sim/app/api/providers/route.ts | 7 +- .../components/combobox/combobox.tsx | 17 +- .../components/access-control.tsx | 301 ++++++++++++++++-- .../utils/permission-check.test.ts | 51 +++ .../access-control/utils/permission-check.ts | 73 +++-- apps/sim/hooks/use-permission-config.ts | 11 + .../lib/api/contracts/permission-groups.ts | 1 + apps/sim/lib/permission-groups/types.ts | 14 + 9 files changed, 423 insertions(+), 55 deletions(-) diff --git a/apps/sim/app/api/guardrails/validate/route.ts b/apps/sim/app/api/guardrails/validate/route.ts index e9d19853c04..03bf505a4df 100644 --- a/apps/sim/app/api/guardrails/validate/route.ts +++ b/apps/sim/app/api/guardrails/validate/route.ts @@ -12,6 +12,7 @@ import { validatePII } from '@/lib/guardrails/validate_pii' import { validateRegex } from '@/lib/guardrails/validate_regex' import { assertPermissionsAllowed, + ModelNotAllowedError, ProviderNotAllowedError, } from '@/ee/access-control/utils/permission-check' @@ -161,7 +162,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { model, }) } catch (err) { - if (err instanceof ProviderNotAllowedError) { + if (err instanceof ProviderNotAllowedError || err instanceof ModelNotAllowedError) { return NextResponse.json({ success: true, output: { diff --git a/apps/sim/app/api/providers/route.ts b/apps/sim/app/api/providers/route.ts index f0bfc2b4a45..bc5e344772b 100644 --- a/apps/sim/app/api/providers/route.ts +++ b/apps/sim/app/api/providers/route.ts @@ -19,6 +19,7 @@ import { import { assertPermissionsAllowed, IntegrationNotAllowedError, + ModelNotAllowedError, ProviderNotAllowedError, } from '@/ee/access-control/utils/permission-check' import type { StreamingExecution } from '@/executor/types' @@ -132,7 +133,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { model, }) } catch (err) { - if (err instanceof ProviderNotAllowedError || err instanceof IntegrationNotAllowedError) { + if ( + err instanceof ProviderNotAllowedError || + err instanceof ModelNotAllowedError || + err instanceof IntegrationNotAllowedError + ) { return NextResponse.json({ error: err.message }, { status: 403 }) } throw err diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index e802667aa4b..d5437cf025b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -151,7 +151,11 @@ export const ComboBox = memo(function ComboBox({ const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue // Permission-based filtering for model dropdowns - const { isProviderAllowed, isLoading: isPermissionLoading } = usePermissionConfig() + const { + isProviderAllowed, + isModelAllowed, + isLoading: isPermissionLoading, + } = usePermissionConfig() // Evaluate static options if provided as a function const staticOptions = useMemo(() => { @@ -160,9 +164,9 @@ export const ComboBox = memo(function ComboBox({ if (subBlockId === 'model') { return opts.filter((opt) => { const modelId = typeof opt === 'string' ? opt : opt.id + if (!isModelAllowed(modelId)) return false try { - const providerId = getProviderFromModel(modelId) - return isProviderAllowed(providerId) + return isProviderAllowed(getProviderFromModel(modelId)) } catch { return true } @@ -170,7 +174,7 @@ export const ComboBox = memo(function ComboBox({ } return opts - }, [options, subBlockId, isProviderAllowed]) + }, [options, subBlockId, isProviderAllowed, isModelAllowed]) // Normalize fetched options to match ComboBoxOption format const normalizedFetchedOptions = useMemo((): ComboBoxOption[] => { @@ -185,9 +189,9 @@ export const ComboBox = memo(function ComboBox({ if (subBlockId === 'model' && fetchOptions && normalizedFetchedOptions.length > 0) { opts = opts.filter((opt) => { const modelId = typeof opt === 'string' ? opt : opt.id + if (!isModelAllowed(modelId)) return false try { - const providerId = getProviderFromModel(modelId) - return isProviderAllowed(providerId) + return isProviderAllowed(getProviderFromModel(modelId)) } catch { return true } @@ -212,6 +216,7 @@ export const ComboBox = memo(function ComboBox({ hydratedOption, subBlockId, isProviderAllowed, + isModelAllowed, ]) // Convert options to Combobox format diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx index 930c9da176b..3adc57f4837 100644 --- a/apps/sim/ee/access-control/components/access-control.tsx +++ b/apps/sim/ee/access-control/components/access-control.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo, useState } from 'react' import { createLogger } from '@sim/logger' -import { Plus, Search } from 'lucide-react' +import { ChevronDown, Plus, Search } from 'lucide-react' import { useParams } from 'next/navigation' import { Avatar, @@ -27,6 +27,7 @@ import { } from '@/components/emcn' import { Input as BaseInput } from '@/components/ui' import { getEnv, isTruthy } from '@/lib/core/config/env' +import { cn } from '@/lib/core/utils/cn' import type { PermissionGroupConfig } from '@/lib/permission-groups/types' import { getUserColor } from '@/lib/workspaces/colors' import { getAllBlocks } from '@/blocks' @@ -42,9 +43,12 @@ import { useUserPermissionConfig, } from '@/ee/access-control/hooks/permission-groups' import { useBlacklistedProviders } from '@/hooks/queries/allowed-providers' +import { useProviderModels } from '@/hooks/queries/providers' import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace' import { PROVIDER_DEFINITIONS } from '@/providers/models' -import { getAllProviderIds } from '@/providers/utils' +import type { ProviderId } from '@/providers/types' +import { getAllProviderIds, getProviderFromModel, getProviderModels } from '@/providers/utils' +import type { ProviderName } from '@/stores/providers' const logger = createLogger('AccessControl') @@ -252,6 +256,201 @@ function AccessControlSkeleton() { ) } +/** + * Providers whose model catalog is discovered at runtime from a user-configured + * endpoint rather than the static {@link PROVIDER_DEFINITIONS} list. Their models + * are fetched lazily via {@link useProviderModels} when a row is expanded. + */ +const DYNAMIC_MODEL_PROVIDERS = new Set([ + 'ollama', + 'vllm', + 'litellm', + 'openrouter', + 'fireworks', +]) + +interface ModelDenylistControls { + isModelAllowed: (model: string) => boolean + onToggleModel: (model: string) => void + onSetModelsDenied: (models: string[], denied: boolean) => void +} + +interface ModelCheckboxGridProps extends ModelDenylistControls { + models: string[] + isLoading: boolean +} + +function ModelCheckboxGrid({ + models, + isLoading, + isModelAllowed, + onToggleModel, + onSetModelsDenied, +}: ModelCheckboxGridProps) { + const [search, setSearch] = useState('') + + const sortedModels = useMemo(() => [...models].sort((a, b) => a.localeCompare(b)), [models]) + + const filteredModels = useMemo(() => { + if (!search.trim()) return sortedModels + const query = search.toLowerCase() + return sortedModels.filter((model) => model.toLowerCase().includes(query)) + }, [sortedModels, search]) + + if (isLoading) { + return
Loading models…
+ } + + if (models.length === 0) { + return ( +
+ No models available for this provider. +
+ ) + } + + const allFilteredAllowed = filteredModels.every((model) => isModelAllowed(model)) + + return ( +
+
+
+ + setSearch(e.target.value)} + className='h-auto flex-1 border-0 bg-transparent p-0 font-base text-sm leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0' + /> +
+ +
+
+ {filteredModels.map((model) => { + const checkboxId = `model-${model}` + return ( + + ) + })} +
+
+ ) +} + +interface DynamicProviderModelsProps extends ModelDenylistControls { + provider: ProviderName + workspaceId?: string +} + +function DynamicProviderModels({ provider, workspaceId, ...controls }: DynamicProviderModelsProps) { + const { data, isPending } = useProviderModels(provider, workspaceId) + return +} + +interface StaticProviderModelsProps extends ModelDenylistControls { + providerId: ProviderId +} + +function StaticProviderModels({ providerId, ...controls }: StaticProviderModelsProps) { + const models = useMemo(() => getProviderModels(providerId), [providerId]) + return +} + +interface ProviderRowProps extends ModelDenylistControls { + providerId: ProviderId + isProviderAllowed: boolean + onToggleProvider: () => void + deniedCount: number + workspaceId?: string +} + +function ProviderRow({ + providerId, + isProviderAllowed, + onToggleProvider, + deniedCount, + workspaceId, + ...controls +}: ProviderRowProps) { + const [expanded, setExpanded] = useState(false) + + const ProviderIcon = PROVIDER_DEFINITIONS[providerId]?.icon + const providerName = + PROVIDER_DEFINITIONS[providerId]?.name || + providerId.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) + const isDynamic = DYNAMIC_MODEL_PROVIDERS.has(providerId as ProviderName) + const checkboxId = `provider-${providerId}` + + return ( +
+
+ onToggleProvider()} + /> +
+ {ProviderIcon && } +
+ +
+ {expanded && isProviderAllowed && ( +
+ {isDynamic ? ( + + ) : ( + + )} +
+ )} +
+ ) +} + export function AccessControl() { const params = useParams() const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : undefined @@ -748,6 +947,65 @@ export function AccessControl() { [editingConfig] ) + const isModelAllowed = useCallback( + (model: string) => { + if (!editingConfig) return true + const normalized = model.toLowerCase() + return !editingConfig.deniedModels.some((denied) => denied.toLowerCase() === normalized) + }, + [editingConfig] + ) + + const toggleModel = useCallback( + (model: string) => { + if (!editingConfig) return + const normalized = model.toLowerCase() + const isDenied = editingConfig.deniedModels.some( + (denied) => denied.toLowerCase() === normalized + ) + const deniedModels = isDenied + ? editingConfig.deniedModels.filter((denied) => denied.toLowerCase() !== normalized) + : [...editingConfig.deniedModels, model] + setEditingConfig({ ...editingConfig, deniedModels }) + }, + [editingConfig] + ) + + const setModelsDenied = useCallback( + (models: string[], denied: boolean) => { + if (!editingConfig) return + if (denied) { + const existing = new Set(editingConfig.deniedModels.map((m) => m.toLowerCase())) + const additions = models.filter((m) => !existing.has(m.toLowerCase())) + if (additions.length === 0) return + setEditingConfig({ + ...editingConfig, + deniedModels: [...editingConfig.deniedModels, ...additions], + }) + } else { + const toRemove = new Set(models.map((m) => m.toLowerCase())) + setEditingConfig({ + ...editingConfig, + deniedModels: editingConfig.deniedModels.filter((m) => !toRemove.has(m.toLowerCase())), + }) + } + }, + [editingConfig] + ) + + const deniedCountByProvider = useMemo(() => { + const counts: Record = {} + for (const model of editingConfig?.deniedModels ?? []) { + try { + const providerId = getProviderFromModel(model) + counts[providerId] = (counts[providerId] ?? 0) + 1 + } catch { + // Model maps to an unavailable provider (e.g. server-blacklisted); skip its badge. + } + } + return counts + }, [editingConfig?.deniedModels]) + const availableMembersToAdd = useMemo(() => { const existingMemberUserIds = new Set(members.map((m) => m.userId)) return workspaceMembers.filter((m) => !existingMemberUserIds.has(m.userId)) @@ -945,31 +1203,20 @@ export function AccessControl() { : 'Select All'} -
- {filteredProviders.map((providerId) => { - const ProviderIcon = PROVIDER_DEFINITIONS[providerId]?.icon - const providerName = - PROVIDER_DEFINITIONS[providerId]?.name || - providerId.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) - const checkboxId = `provider-${providerId}` - return ( - - ) - })} +
+ {filteredProviders.map((providerId) => ( + toggleProvider(providerId)} + deniedCount={deniedCountByProvider[providerId] ?? 0} + workspaceId={workspaceId} + isModelAllowed={isModelAllowed} + onToggleModel={toggleModel} + onSetModelsDenied={setModelsDenied} + /> + ))}
diff --git a/apps/sim/ee/access-control/utils/permission-check.test.ts b/apps/sim/ee/access-control/utils/permission-check.test.ts index 0c0a3934399..5a2cf46bced 100644 --- a/apps/sim/ee/access-control/utils/permission-check.test.ts +++ b/apps/sim/ee/access-control/utils/permission-check.test.ts @@ -13,6 +13,7 @@ const { DEFAULT_PERMISSION_GROUP_CONFIG: { allowedIntegrations: null, allowedModelProviders: null, + deniedModels: [], hideTraceSpans: false, hideKnowledgeBaseTab: false, hideTablesTab: false, @@ -94,6 +95,7 @@ import { getUserPermissionConfig, IntegrationNotAllowedError, McpToolsNotAllowedError, + ModelNotAllowedError, ProviderNotAllowedError, SkillsNotAllowedError, validateBlockType, @@ -237,6 +239,42 @@ describe('validateModelProvider', () => { await validateModelProvider('user-123', 'workspace-1', 'gpt-4') }) + + it('throws ModelNotAllowedError when the model is on the denylist', async () => { + mockDbGroupMembership.value = [{ config: { deniedModels: ['gpt-4'] } }] + mockGetProviderFromModel.mockReturnValue('openai') + + await expect(validateModelProvider('user-123', 'workspace-1', 'gpt-4')).rejects.toBeInstanceOf( + ModelNotAllowedError + ) + }) + + it('denylist match is case-insensitive', async () => { + mockDbGroupMembership.value = [{ config: { deniedModels: ['Ollama/Llama3'] } }] + mockGetProviderFromModel.mockReturnValue('ollama') + + await expect( + validateModelProvider('user-123', 'workspace-1', 'ollama/llama3') + ).rejects.toBeInstanceOf(ModelNotAllowedError) + }) + + it('enforces the denylist even when no provider allowlist is set', async () => { + mockDbGroupMembership.value = [ + { config: { allowedModelProviders: null, deniedModels: ['gpt-4'] } }, + ] + mockGetProviderFromModel.mockReturnValue('openai') + + await expect(validateModelProvider('user-123', 'workspace-1', 'gpt-4')).rejects.toBeInstanceOf( + ModelNotAllowedError + ) + }) + + it('allows a model that is not on the denylist', async () => { + mockDbGroupMembership.value = [{ config: { deniedModels: ['gpt-4'] } }] + mockGetProviderFromModel.mockReturnValue('openai') + + await validateModelProvider('user-123', 'workspace-1', 'gpt-4o') + }) }) describe('validateMcpToolsAllowed', () => { @@ -281,6 +319,19 @@ describe('assertPermissionsAllowed', () => { ).rejects.toBeInstanceOf(ProviderNotAllowedError) }) + it('throws ModelNotAllowedError when the model is on the denylist', async () => { + mockDbGroupMembership.value = [{ config: { deniedModels: ['gpt-4'] } }] + mockGetProviderFromModel.mockReturnValue('openai') + + await expect( + assertPermissionsAllowed({ + userId: 'user-123', + workspaceId: 'workspace-1', + model: 'gpt-4', + }) + ).rejects.toBeInstanceOf(ModelNotAllowedError) + }) + it('throws IntegrationNotAllowedError when block type is blocked', async () => { mockDbGroupMembership.value = [{ config: { allowedIntegrations: ['slack'] } }] diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index e871cf45740..400682953fe 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -29,6 +29,13 @@ export class ProviderNotAllowedError extends Error { } } +export class ModelNotAllowedError extends Error { + constructor(model: string) { + super(`Model "${model}" is not allowed based on your permission group settings`) + this.name = 'ModelNotAllowedError' + } +} + export class IntegrationNotAllowedError extends Error { constructor(blockType: string, reason?: string) { super( @@ -168,6 +175,18 @@ async function getPermissionConfig( return getUserPermissionConfig(userId, workspaceId) } +/** + * Returns true when `model` appears in the group's model denylist. Comparison is + * case-insensitive to match the normalization applied by `getProviderFromModel`. + */ +function isModelDenied(config: PermissionGroupConfig, model: string): boolean { + if (!config.deniedModels || config.deniedModels.length === 0) { + return false + } + const normalized = model.toLowerCase() + return config.deniedModels.some((denied) => denied.toLowerCase() === normalized) +} + export async function validateModelProvider( userId: string | undefined, workspaceId: string | undefined, @@ -180,20 +199,27 @@ export async function validateModelProvider( const config = await getPermissionConfig(userId, workspaceId, ctx) - if (!config || config.allowedModelProviders === null) { + if (!config) { return } - const providerId = getProviderFromModel(model) + if (config.allowedModelProviders !== null) { + const providerId = getProviderFromModel(model) - if (!config.allowedModelProviders.includes(providerId)) { - logger.warn('Model provider blocked by permission group', { - userId, - workspaceId, - model, - providerId, - }) - throw new ProviderNotAllowedError(providerId, model) + if (!config.allowedModelProviders.includes(providerId)) { + logger.warn('Model provider blocked by permission group', { + userId, + workspaceId, + model, + providerId, + }) + throw new ProviderNotAllowedError(providerId, model) + } + } + + if (isModelDenied(config, model)) { + logger.warn('Model blocked by permission group', { userId, workspaceId, model }) + throw new ModelNotAllowedError(model) } } @@ -421,16 +447,23 @@ export async function assertPermissionsAllowed(req: PermissionAssertion): Promis ? await getPermissionConfig(userId, workspaceId, ctx) : mergeEnvAllowlist(null) - if (model && config && config.allowedModelProviders !== null) { - const providerId = getProviderFromModel(model) - if (!config.allowedModelProviders.includes(providerId)) { - logger.warn('Model provider blocked by permission group', { - userId, - workspaceId, - model, - providerId, - }) - throw new ProviderNotAllowedError(providerId, model) + if (model && config) { + if (config.allowedModelProviders !== null) { + const providerId = getProviderFromModel(model) + if (!config.allowedModelProviders.includes(providerId)) { + logger.warn('Model provider blocked by permission group', { + userId, + workspaceId, + model, + providerId, + }) + throw new ProviderNotAllowedError(providerId, model) + } + } + + if (isModelDenied(config, model)) { + logger.warn('Model blocked by permission group', { userId, workspaceId, model }) + throw new ModelNotAllowedError(model) } } diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index cc52003b49f..88ef67f7229 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -21,6 +21,7 @@ export interface PermissionConfigResult { filterProviders: (providerIds: string[]) => string[] isBlockAllowed: (blockType: string) => boolean isProviderAllowed: (providerId: string) => boolean + isModelAllowed: (model: string) => boolean isInvitationsDisabled: boolean isPublicApiDisabled: boolean } @@ -98,6 +99,14 @@ export function usePermissionConfig(): PermissionConfigResult { } }, [config.allowedModelProviders]) + const isModelAllowed = useMemo(() => { + return (model: string) => { + if (config.deniedModels.length === 0) return true + const normalized = model.toLowerCase() + return !config.deniedModels.some((denied) => denied.toLowerCase() === normalized) + } + }, [config.deniedModels]) + const filterBlocks = useMemo(() => { return (blocks: T[]): T[] => { if (mergedAllowedIntegrations === null) return blocks @@ -140,6 +149,7 @@ export function usePermissionConfig(): PermissionConfigResult { filterProviders, isBlockAllowed, isProviderAllowed, + isModelAllowed, isInvitationsDisabled, isPublicApiDisabled, }), @@ -151,6 +161,7 @@ export function usePermissionConfig(): PermissionConfigResult { filterProviders, isBlockAllowed, isProviderAllowed, + isModelAllowed, isInvitationsDisabled, isPublicApiDisabled, ] diff --git a/apps/sim/lib/api/contracts/permission-groups.ts b/apps/sim/lib/api/contracts/permission-groups.ts index 3b0bd1652e4..e39df04b3f0 100644 --- a/apps/sim/lib/api/contracts/permission-groups.ts +++ b/apps/sim/lib/api/contracts/permission-groups.ts @@ -5,6 +5,7 @@ import { permissionGroupConfigSchema } from '@/lib/permission-groups/types' export const permissionGroupFullConfigSchema = z.object({ allowedIntegrations: z.array(z.string()).nullable(), allowedModelProviders: z.array(z.string()).nullable(), + deniedModels: z.array(z.string()), hideTraceSpans: z.boolean(), hideKnowledgeBaseTab: z.boolean(), hideTablesTab: z.boolean(), diff --git a/apps/sim/lib/permission-groups/types.ts b/apps/sim/lib/permission-groups/types.ts index 3630691217c..4c66b77ce1c 100644 --- a/apps/sim/lib/permission-groups/types.ts +++ b/apps/sim/lib/permission-groups/types.ts @@ -8,6 +8,7 @@ export const PERMISSION_GROUP_MEMBER_CONSTRAINTS = { export const permissionGroupConfigSchema = z.object({ allowedIntegrations: z.array(z.string()).nullable().optional(), allowedModelProviders: z.array(z.string()).nullable().optional(), + deniedModels: z.array(z.string()).optional(), hideTraceSpans: z.boolean().optional(), hideKnowledgeBaseTab: z.boolean().optional(), hideTablesTab: z.boolean().optional(), @@ -32,6 +33,15 @@ export const permissionGroupConfigSchema = z.object({ export interface PermissionGroupConfig { allowedIntegrations: string[] | null allowedModelProviders: string[] | null + /** + * Denylist of fully-qualified model IDs (e.g. `ollama/llama3`, `gpt-4o`) that + * members of this group may not use. Empty means no model is blocked. Applied + * on top of `allowedModelProviders`: a model is usable only when its provider + * is allowed AND the model is not present here. A denylist (rather than an + * allowlist) keeps dynamically-discovered models — vLLM, Ollama, LiteLLM — + * usable by default as the upstream catalog changes. + */ + deniedModels: string[] hideTraceSpans: boolean hideKnowledgeBaseTab: boolean hideTablesTab: boolean @@ -56,6 +66,7 @@ export interface PermissionGroupConfig { export const DEFAULT_PERMISSION_GROUP_CONFIG: PermissionGroupConfig = { allowedIntegrations: null, allowedModelProviders: null, + deniedModels: [], hideTraceSpans: false, hideKnowledgeBaseTab: false, hideTablesTab: false, @@ -87,6 +98,9 @@ export function parsePermissionGroupConfig(config: unknown): PermissionGroupConf return { allowedIntegrations: Array.isArray(c.allowedIntegrations) ? c.allowedIntegrations : null, allowedModelProviders: Array.isArray(c.allowedModelProviders) ? c.allowedModelProviders : null, + deniedModels: Array.isArray(c.deniedModels) + ? c.deniedModels.filter((m): m is string => typeof m === 'string') + : [], hideTraceSpans: typeof c.hideTraceSpans === 'boolean' ? c.hideTraceSpans : false, hideKnowledgeBaseTab: typeof c.hideKnowledgeBaseTab === 'boolean' ? c.hideKnowledgeBaseTab : false, From c29d4da652e7fe93f6acefbc9bb95ffba3fc8b10 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 29 May 2026 12:42:08 -0700 Subject: [PATCH 2/3] fix(access-control): default deniedModels in response schema, hide blocked badge on disabled rows, trim comments --- .../ee/access-control/components/access-control.tsx | 10 +++------- apps/sim/lib/api/contracts/permission-groups.ts | 2 +- apps/sim/lib/permission-groups/types.ts | 8 ++------ 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx index 3adc57f4837..b0d8024ae11 100644 --- a/apps/sim/ee/access-control/components/access-control.tsx +++ b/apps/sim/ee/access-control/components/access-control.tsx @@ -256,11 +256,7 @@ function AccessControlSkeleton() { ) } -/** - * Providers whose model catalog is discovered at runtime from a user-configured - * endpoint rather than the static {@link PROVIDER_DEFINITIONS} list. Their models - * are fetched lazily via {@link useProviderModels} when a row is expanded. - */ +/** Providers whose models are fetched at runtime (on row expand) rather than from {@link PROVIDER_DEFINITIONS}. */ const DYNAMIC_MODEL_PROVIDERS = new Set([ 'ollama', 'vllm', @@ -419,7 +415,7 @@ function ProviderRow({ )} > {providerName} - {deniedCount > 0 && ( + {isProviderAllowed && deniedCount > 0 && ( {deniedCount} blocked @@ -1000,7 +996,7 @@ export function AccessControl() { const providerId = getProviderFromModel(model) counts[providerId] = (counts[providerId] ?? 0) + 1 } catch { - // Model maps to an unavailable provider (e.g. server-blacklisted); skip its badge. + // Unknown/blacklisted provider — omit from counts. } } return counts diff --git a/apps/sim/lib/api/contracts/permission-groups.ts b/apps/sim/lib/api/contracts/permission-groups.ts index e39df04b3f0..09bfe87176d 100644 --- a/apps/sim/lib/api/contracts/permission-groups.ts +++ b/apps/sim/lib/api/contracts/permission-groups.ts @@ -5,7 +5,7 @@ import { permissionGroupConfigSchema } from '@/lib/permission-groups/types' export const permissionGroupFullConfigSchema = z.object({ allowedIntegrations: z.array(z.string()).nullable(), allowedModelProviders: z.array(z.string()).nullable(), - deniedModels: z.array(z.string()), + deniedModels: z.array(z.string()).default([]), hideTraceSpans: z.boolean(), hideKnowledgeBaseTab: z.boolean(), hideTablesTab: z.boolean(), diff --git a/apps/sim/lib/permission-groups/types.ts b/apps/sim/lib/permission-groups/types.ts index 4c66b77ce1c..39320823081 100644 --- a/apps/sim/lib/permission-groups/types.ts +++ b/apps/sim/lib/permission-groups/types.ts @@ -34,12 +34,8 @@ export interface PermissionGroupConfig { allowedIntegrations: string[] | null allowedModelProviders: string[] | null /** - * Denylist of fully-qualified model IDs (e.g. `ollama/llama3`, `gpt-4o`) that - * members of this group may not use. Empty means no model is blocked. Applied - * on top of `allowedModelProviders`: a model is usable only when its provider - * is allowed AND the model is not present here. A denylist (rather than an - * allowlist) keeps dynamically-discovered models — vLLM, Ollama, LiteLLM — - * usable by default as the upstream catalog changes. + * Fully-qualified model IDs (e.g. `ollama/llama3`, `gpt-4o`) blocked for this + * group, checked after `allowedModelProviders`. Empty means nothing is blocked. */ deniedModels: string[] hideTraceSpans: boolean From 60802ea2c049e58d098f51deea6e7ac872790e12 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 29 May 2026 12:57:03 -0700 Subject: [PATCH 3/3] chore(access-control): reuse canonical DYNAMIC_MODEL_PROVIDERS from providers/models --- .../components/access-control.tsx | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx index b0d8024ae11..30f744225d6 100644 --- a/apps/sim/ee/access-control/components/access-control.tsx +++ b/apps/sim/ee/access-control/components/access-control.tsx @@ -45,9 +45,13 @@ import { import { useBlacklistedProviders } from '@/hooks/queries/allowed-providers' import { useProviderModels } from '@/hooks/queries/providers' import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace' -import { PROVIDER_DEFINITIONS } from '@/providers/models' +import { + DYNAMIC_MODEL_PROVIDERS, + getProviderModels, + PROVIDER_DEFINITIONS, +} from '@/providers/models' import type { ProviderId } from '@/providers/types' -import { getAllProviderIds, getProviderFromModel, getProviderModels } from '@/providers/utils' +import { getAllProviderIds, getProviderFromModel } from '@/providers/utils' import type { ProviderName } from '@/stores/providers' const logger = createLogger('AccessControl') @@ -256,15 +260,6 @@ function AccessControlSkeleton() { ) } -/** Providers whose models are fetched at runtime (on row expand) rather than from {@link PROVIDER_DEFINITIONS}. */ -const DYNAMIC_MODEL_PROVIDERS = new Set([ - 'ollama', - 'vllm', - 'litellm', - 'openrouter', - 'fireworks', -]) - interface ModelDenylistControls { isModelAllowed: (model: string) => boolean onToggleModel: (model: string) => void @@ -391,7 +386,7 @@ function ProviderRow({ const providerName = PROVIDER_DEFINITIONS[providerId]?.name || providerId.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) - const isDynamic = DYNAMIC_MODEL_PROVIDERS.has(providerId as ProviderName) + const isDynamic = (DYNAMIC_MODEL_PROVIDERS as readonly string[]).includes(providerId) const checkboxId = `provider-${providerId}` return (