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:
Waleed
2026-05-29 13:08:09 -07:00
committed by GitHub
parent 8daca91e52
commit c95aa879f7
9 changed files with 411 additions and 56 deletions
@@ -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: {
+6 -1
View File
@@ -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
@@ -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)
}
}
+11
View File
@@ -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(),
+10
View File
@@ -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,