mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(access-control): add per-model denylist to permission groups (#4794)
* feat(access-control): add per-model denylist to permission groups * fix(access-control): default deniedModels in response schema, hide blocked badge on disabled rows, trim comments * chore(access-control): reuse canonical DYNAMIC_MODEL_PROVIDERS from providers/models
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
+11
-6
@@ -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
|
||||
|
||||
@@ -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,16 @@ 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 {
|
||||
DYNAMIC_MODEL_PROVIDERS,
|
||||
getProviderModels,
|
||||
PROVIDER_DEFINITIONS,
|
||||
} from '@/providers/models'
|
||||
import type { ProviderId } from '@/providers/types'
|
||||
import { getAllProviderIds, getProviderFromModel } from '@/providers/utils'
|
||||
import type { ProviderName } from '@/stores/providers'
|
||||
|
||||
const logger = createLogger('AccessControl')
|
||||
|
||||
@@ -252,6 +260,188 @@ function AccessControlSkeleton() {
|
||||
)
|
||||
}
|
||||
|
||||
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 <div className='px-2 py-3 text-[var(--text-muted)] text-xs'>Loading models…</div>
|
||||
}
|
||||
|
||||
if (models.length === 0) {
|
||||
return (
|
||||
<div className='px-2 py-3 text-[var(--text-muted)] text-xs'>
|
||||
No models available for this provider.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const allFilteredAllowed = filteredModels.every((model) => isModelAllowed(model))
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex flex-1 items-center gap-2 rounded-lg border border-[var(--border)] bg-transparent px-2 py-[5px]'>
|
||||
<Search className='size-[14px] flex-shrink-0 text-[var(--text-tertiary)]' />
|
||||
<BaseInput
|
||||
placeholder='Search models...'
|
||||
value={search}
|
||||
onChange={(e) => 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'
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant='default'
|
||||
className='h-8'
|
||||
onClick={() => onSetModelsDenied(filteredModels, allFilteredAllowed)}
|
||||
>
|
||||
{allFilteredAllowed ? 'Block All' : 'Allow All'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-x-2 gap-y-0.5'>
|
||||
{filteredModels.map((model) => {
|
||||
const checkboxId = `model-${model}`
|
||||
return (
|
||||
<label
|
||||
key={model}
|
||||
htmlFor={checkboxId}
|
||||
className='flex cursor-pointer items-center gap-2 rounded-md px-2 py-[5px] transition-colors hover-hover:bg-[var(--surface-2)]'
|
||||
>
|
||||
<Checkbox
|
||||
id={checkboxId}
|
||||
checked={isModelAllowed(model)}
|
||||
onCheckedChange={() => onToggleModel(model)}
|
||||
/>
|
||||
<span className='truncate text-sm'>{model}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DynamicProviderModelsProps extends ModelDenylistControls {
|
||||
provider: ProviderName
|
||||
workspaceId?: string
|
||||
}
|
||||
|
||||
function DynamicProviderModels({ provider, workspaceId, ...controls }: DynamicProviderModelsProps) {
|
||||
const { data, isPending } = useProviderModels(provider, workspaceId)
|
||||
return <ModelCheckboxGrid models={data?.models ?? []} isLoading={isPending} {...controls} />
|
||||
}
|
||||
|
||||
interface StaticProviderModelsProps extends ModelDenylistControls {
|
||||
providerId: ProviderId
|
||||
}
|
||||
|
||||
function StaticProviderModels({ providerId, ...controls }: StaticProviderModelsProps) {
|
||||
const models = useMemo(() => getProviderModels(providerId), [providerId])
|
||||
return <ModelCheckboxGrid models={models} isLoading={false} {...controls} />
|
||||
}
|
||||
|
||||
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 as readonly string[]).includes(providerId)
|
||||
const checkboxId = `provider-${providerId}`
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='flex items-center gap-2 rounded-md px-2 py-[5px] transition-colors hover-hover:bg-[var(--surface-2)]'>
|
||||
<Checkbox
|
||||
id={checkboxId}
|
||||
checked={isProviderAllowed}
|
||||
onCheckedChange={() => onToggleProvider()}
|
||||
/>
|
||||
<div className='relative flex size-[16px] flex-shrink-0 items-center justify-center'>
|
||||
{ProviderIcon && <ProviderIcon className='!h-[16px] !w-[16px]' />}
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => isProviderAllowed && setExpanded((prev) => !prev)}
|
||||
disabled={!isProviderAllowed}
|
||||
className={cn(
|
||||
'flex flex-1 items-center gap-2 text-left',
|
||||
isProviderAllowed ? 'cursor-pointer' : 'cursor-default opacity-60'
|
||||
)}
|
||||
>
|
||||
<span className='truncate font-medium text-sm'>{providerName}</span>
|
||||
{isProviderAllowed && deniedCount > 0 && (
|
||||
<span className='rounded-sm bg-[var(--surface-3)] px-1.5 py-0.5 text-[var(--text-muted)] text-micro'>
|
||||
{deniedCount} blocked
|
||||
</span>
|
||||
)}
|
||||
{isProviderAllowed && (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'ml-auto size-[14px] flex-shrink-0 text-[var(--text-tertiary)] transition-transform',
|
||||
expanded && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{expanded && isProviderAllowed && (
|
||||
<div className='border-[var(--border)] border-t px-2 pt-2 pb-3'>
|
||||
{isDynamic ? (
|
||||
<DynamicProviderModels
|
||||
provider={providerId as ProviderName}
|
||||
workspaceId={workspaceId}
|
||||
{...controls}
|
||||
/>
|
||||
) : (
|
||||
<StaticProviderModels providerId={providerId} {...controls} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AccessControl() {
|
||||
const params = useParams()
|
||||
const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : undefined
|
||||
@@ -748,6 +938,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<string, number> = {}
|
||||
for (const model of editingConfig?.deniedModels ?? []) {
|
||||
try {
|
||||
const providerId = getProviderFromModel(model)
|
||||
counts[providerId] = (counts[providerId] ?? 0) + 1
|
||||
} catch {
|
||||
// Unknown/blacklisted provider — omit from counts.
|
||||
}
|
||||
}
|
||||
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 +1194,20 @@ export function AccessControl() {
|
||||
: 'Select All'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='grid grid-cols-3 gap-x-2 gap-y-0.5'>
|
||||
{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 (
|
||||
<label
|
||||
key={providerId}
|
||||
htmlFor={checkboxId}
|
||||
className='flex cursor-pointer items-center gap-2 rounded-md px-2 py-[5px] transition-colors hover-hover:bg-[var(--surface-2)]'
|
||||
>
|
||||
<Checkbox
|
||||
id={checkboxId}
|
||||
checked={isProviderAllowed(providerId)}
|
||||
onCheckedChange={() => toggleProvider(providerId)}
|
||||
/>
|
||||
<div className='relative flex size-[16px] flex-shrink-0 items-center justify-center'>
|
||||
{ProviderIcon && <ProviderIcon className='!h-[16px] !w-[16px]' />}
|
||||
</div>
|
||||
<span className='truncate font-medium text-sm'>{providerName}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
{filteredProviders.map((providerId) => (
|
||||
<ProviderRow
|
||||
key={providerId}
|
||||
providerId={providerId}
|
||||
isProviderAllowed={isProviderAllowed(providerId)}
|
||||
onToggleProvider={() => toggleProvider(providerId)}
|
||||
deniedCount={deniedCountByProvider[providerId] ?? 0}
|
||||
workspaceId={workspaceId}
|
||||
isModelAllowed={isModelAllowed}
|
||||
onToggleModel={toggleModel}
|
||||
onSetModelsDenied={setModelsDenied}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ModalTabsContent>
|
||||
|
||||
|
||||
@@ -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'] } }]
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <T extends { type: string }>(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,
|
||||
]
|
||||
|
||||
@@ -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()).default([]),
|
||||
hideTraceSpans: z.boolean(),
|
||||
hideKnowledgeBaseTab: z.boolean(),
|
||||
hideTablesTab: z.boolean(),
|
||||
|
||||
@@ -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,11 @@ export const permissionGroupConfigSchema = z.object({
|
||||
export interface PermissionGroupConfig {
|
||||
allowedIntegrations: string[] | null
|
||||
allowedModelProviders: string[] | null
|
||||
/**
|
||||
* 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
|
||||
hideKnowledgeBaseTab: boolean
|
||||
hideTablesTab: boolean
|
||||
@@ -56,6 +62,7 @@ export interface PermissionGroupConfig {
|
||||
export const DEFAULT_PERMISSION_GROUP_CONFIG: PermissionGroupConfig = {
|
||||
allowedIntegrations: null,
|
||||
allowedModelProviders: null,
|
||||
deniedModels: [],
|
||||
hideTraceSpans: false,
|
||||
hideKnowledgeBaseTab: false,
|
||||
hideTablesTab: false,
|
||||
@@ -87,6 +94,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,
|
||||
|
||||
Reference in New Issue
Block a user