feat: add per-group knowledge base file count and size limits

Adds optional per-group (and global default) limits on the number of
files and maximum file size for knowledge base uploads, enforced both
in the backend and early in the frontend upload pipeline.

- `WorkspacePermissions` extended with `knowledge_max_count` and
  `knowledge_max_size` (nullable int) so the group permissions endpoint
  persists and returns these values
- `DEFAULT_PERMISSIONS` in `permissions.ts` gains the two nullable fields
- Group Permissions UI shows numeric inputs for both limits beneath the
  Knowledge Access toggle (0 / empty = unlimited)
- `combine_permissions` extended to handle integer-valued permissions
  using most-permissive semantics (0 = unlimited always wins, otherwise
  max across groups)
- New `get_permission_value()` utility mirrors `has_permission` but
  returns the raw value (int/bool/None) rather than a boolean
- `/api/config` resolves and exposes `knowledge.max_count` and
  `knowledge.max_size` per-user so the frontend can gate uploads before
  any processing begins
- `KnowledgeBase.svelte` and `Models/Knowledge.svelte` check limits
  before calling `uploadFile`, preventing unnecessary text-extraction
  work on oversized files
- Backend enforces limits in both `/{id}/file/add` and
  `/{id}/files/batch/add`; admins always bypass
This commit is contained in:
Alexey Rybalchenko 2026-04-21 11:58:41 +02:00
parent f162d4de90
commit 464a2dd58d
13 changed files with 346 additions and 25 deletions

View file

@ -1357,6 +1357,24 @@ USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ACCESS = (
os.environ.get('USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ACCESS', 'False').lower() == 'true'
)
_knowledge_max_count_raw = os.environ.get('USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_MAX_COUNT', '').strip()
if _knowledge_max_count_raw:
_knowledge_max_count_val = int(_knowledge_max_count_raw)
if _knowledge_max_count_val < 0:
raise ValueError(f"USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_MAX_COUNT must be >= 0, got {_knowledge_max_count_val}")
USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_MAX_COUNT = _knowledge_max_count_val
else:
USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_MAX_COUNT = None
_knowledge_max_size_raw = os.environ.get('USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_MAX_SIZE', '').strip()
if _knowledge_max_size_raw:
_knowledge_max_size_val = int(_knowledge_max_size_raw)
if _knowledge_max_size_val < 0:
raise ValueError(f"USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_MAX_SIZE must be >= 0, got {_knowledge_max_size_val}")
USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_MAX_SIZE = _knowledge_max_size_val
else:
USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_MAX_SIZE = None
USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS = (
os.environ.get('USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS', 'False').lower() == 'true'
)
@ -1534,6 +1552,8 @@ DEFAULT_USER_PERMISSIONS = {
'workspace': {
'models': USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS,
'knowledge': USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ACCESS,
'knowledge_max_count': USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_MAX_COUNT,
'knowledge_max_size': USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_MAX_SIZE,
'prompts': USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS,
'tools': USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS,
'skills': USER_PERMISSIONS_WORKSPACE_SKILLS_ACCESS,

View file

@ -916,7 +916,7 @@ app.state.config.ENABLE_EVALUATION_ARENA_MODELS = ENABLE_EVALUATION_ARENA_MODELS
app.state.config.EVALUATION_ARENA_MODELS = EVALUATION_ARENA_MODELS
# Migrate legacy access_control → access_grants on boot
from open_webui.utils.access_control import migrate_access_control
from open_webui.utils.access_control import migrate_access_control, get_permission_value
connections = app.state.config.TOOL_SERVER_CONNECTIONS
if any('access_control' in c.get('config', {}) for c in connections):
@ -1387,6 +1387,18 @@ app.add_middleware(AuthTokenMiddleware, fastapi_app=app)
app.add_middleware(WebsocketUpgradeGuardMiddleware)
async def _resolve_knowledge_limit(user, permission_key: str, user_permissions: dict):
"""Return the effective knowledge file limit for a user.
Admin users get None (unlimited). For regular users, group/default permissions
are checked; 0 means unlimited (returns None).
"""
if user is None or user.role == 'admin':
return None
value = await get_permission_value(user.id, permission_key, user_permissions)
return None if (value is None or value == 0) else value
app.add_middleware(
CORSMiddleware,
allow_origins=CORS_ALLOW_ORIGIN,
@ -2282,6 +2294,16 @@ async def get_app_config(request: Request):
'height': app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT,
},
},
'knowledge': {
'max_size': await _resolve_knowledge_limit(
user, 'workspace.knowledge_max_size',
app.state.config.USER_PERMISSIONS,
),
'max_count': await _resolve_knowledge_limit(
user, 'workspace.knowledge_max_count',
app.state.config.USER_PERMISSIONS,
),
},
'permissions': {**app.state.config.USER_PERMISSIONS},
'google_drive': {
'client_id': GOOGLE_DRIVE_CLIENT_ID.value,

View file

@ -537,6 +537,18 @@ class KnowledgeTable:
except Exception:
return []
async def get_file_count_by_id(
self, knowledge_id: str, db: Optional[AsyncSession] = None
) -> int:
try:
async with get_async_db_context(db) as db:
result = await db.execute(
select(func.count()).select_from(KnowledgeFile).filter_by(knowledge_id=knowledge_id)
)
return result.scalar() or 0
except Exception:
return 0
async def add_file_to_knowledge_by_id(
self,
knowledge_id: str,

View file

@ -30,7 +30,7 @@ from open_webui.storage.provider import Storage
from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_verified_user, get_admin_user
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants, get_permission_value
from open_webui.models.access_grants import AccessGrants
@ -656,6 +656,29 @@ async def add_file_to_knowledge_by_id(
detail=ERROR_MESSAGES.FILE_NOT_PROCESSED,
)
if user.role != 'admin':
max_count = await get_permission_value(
user.id, 'workspace.knowledge_max_count',
request.app.state.config.USER_PERMISSIONS, db=db,
)
if max_count is not None and max_count != 0:
current_count = await Knowledges.get_file_count_by_id(id, db=db)
if current_count >= max_count:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Knowledge base file limit reached ({max_count} files maximum).',
)
max_size = await get_permission_value(
user.id, 'workspace.knowledge_max_size',
request.app.state.config.USER_PERMISSIONS, db=db,
)
if max_size is not None and max_size != 0 and file.meta and file.meta.get('size', 0) > max_size * 1024 * 1024:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'File size exceeds the {max_size} MB limit for knowledge base uploads.',
)
# Add content to the vector database
try:
await process_file(
@ -1008,6 +1031,33 @@ async def add_files_to_knowledge_batch(
file_ids = [form.file_id for form in form_data]
files = await Files.get_files_by_ids(file_ids, db=db)
if user.role != 'admin':
max_count = await get_permission_value(
user.id, 'workspace.knowledge_max_count',
request.app.state.config.USER_PERMISSIONS, db=db,
)
if max_count is not None and max_count != 0:
current_count = await Knowledges.get_file_count_by_id(id, db=db)
if current_count + len(files) > max_count:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Knowledge base file limit exceeded: adding {len(files)} file(s) would exceed the {max_count} file maximum (currently {current_count}).',
)
max_size = await get_permission_value(
user.id, 'workspace.knowledge_max_size',
request.app.state.config.USER_PERMISSIONS, db=db,
)
if max_size is not None and max_size != 0:
oversized = [
f.filename for f in files if f.meta and f.meta.get('size', 0) > max_size * 1024 * 1024
]
if oversized:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'File(s) exceed the {max_size} MB limit for knowledge base uploads: {", ".join(oversized)}',
)
# Verify all requested files were found
found_ids = {file.id for file in files}
missing_ids = [fid for fid in file_ids if fid not in found_ids]

View file

@ -7,7 +7,7 @@ import io
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import Response, StreamingResponse, FileResponse
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from open_webui.models.auths import Auths
@ -169,6 +169,8 @@ async def get_user_permissisions(
class WorkspacePermissions(BaseModel):
models: bool = False
knowledge: bool = False
knowledge_max_count: Optional[int] = Field(default=None, ge=0)
knowledge_max_size: Optional[int] = Field(default=None, ge=0)
prompts: bool = False
tools: bool = False
skills: bool = False

View file

@ -0,0 +1,5 @@
import os
# Set required env vars before any open_webui modules are imported during collection.
if not os.environ.get('WEBUI_SECRET_KEY'):
os.environ['WEBUI_SECRET_KEY'] = 'test-secret-key-for-unit-tests'

View file

@ -0,0 +1,125 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from open_webui.utils.access_control import get_permission_value, get_permissions
def _group(permissions: dict):
return SimpleNamespace(permissions=permissions)
GROUPS_PATH = 'open_webui.utils.access_control.Groups.get_groups_by_member_id'
class TestCombinePermissionsIntegers:
"""get_permissions merges integer-valued permissions using most-permissive semantics."""
@pytest.mark.anyio
@patch(GROUPS_PATH, new_callable=AsyncMock)
async def test_single_group_integer_limit(self, mock_groups):
mock_groups.return_value = [_group({'workspace': {'knowledge_max_count': 10}})]
result = await get_permissions('user1', {'workspace': {'knowledge_max_count': None}})
assert result['workspace']['knowledge_max_count'] == 10
@pytest.mark.anyio
@patch(GROUPS_PATH, new_callable=AsyncMock)
async def test_most_permissive_takes_higher_limit(self, mock_groups):
mock_groups.return_value = [
_group({'workspace': {'knowledge_max_count': 10}}),
_group({'workspace': {'knowledge_max_count': 25}}),
]
result = await get_permissions('user1', {'workspace': {'knowledge_max_count': None}})
assert result['workspace']['knowledge_max_count'] == 25
@pytest.mark.anyio
@patch(GROUPS_PATH, new_callable=AsyncMock)
async def test_zero_means_unlimited_always_wins(self, mock_groups):
mock_groups.return_value = [
_group({'workspace': {'knowledge_max_count': 10}}),
_group({'workspace': {'knowledge_max_count': 0}}),
]
result = await get_permissions('user1', {'workspace': {'knowledge_max_count': None}})
assert result['workspace']['knowledge_max_count'] == 0
@pytest.mark.anyio
@patch(GROUPS_PATH, new_callable=AsyncMock)
async def test_none_group_value_is_skipped(self, mock_groups):
mock_groups.return_value = [
_group({'workspace': {'knowledge_max_count': None}}),
_group({'workspace': {'knowledge_max_count': 15}}),
]
result = await get_permissions('user1', {'workspace': {'knowledge_max_count': None}})
assert result['workspace']['knowledge_max_count'] == 15
@pytest.mark.anyio
@patch(GROUPS_PATH, new_callable=AsyncMock)
async def test_boolean_permissions_unaffected(self, mock_groups):
mock_groups.return_value = [
_group({'workspace': {'knowledge': True, 'knowledge_max_count': 10}}),
_group({'workspace': {'knowledge': False, 'knowledge_max_count': 5}}),
]
result = await get_permissions('user1', {'workspace': {'knowledge': False, 'knowledge_max_count': None}})
assert result['workspace']['knowledge'] is True
assert result['workspace']['knowledge_max_count'] == 10
class TestGetPermissionValue:
"""get_permission_value returns the raw resolved value for a permission key."""
@pytest.mark.anyio
@patch(GROUPS_PATH, new_callable=AsyncMock)
async def test_returns_none_when_no_groups_and_no_default(self, mock_groups):
mock_groups.return_value = []
assert await get_permission_value('user1', 'workspace.knowledge_max_count') is None
@pytest.mark.anyio
@patch(GROUPS_PATH, new_callable=AsyncMock)
async def test_falls_back_to_default_permissions(self, mock_groups):
mock_groups.return_value = []
result = await get_permission_value(
'user1', 'workspace.knowledge_max_count',
{'workspace': {'knowledge_max_count': 20}},
)
assert result == 20
@pytest.mark.anyio
@patch(GROUPS_PATH, new_callable=AsyncMock)
async def test_group_value_overrides_default(self, mock_groups):
mock_groups.return_value = [_group({'workspace': {'knowledge_max_count': 5}})]
result = await get_permission_value(
'user1', 'workspace.knowledge_max_count',
{'workspace': {'knowledge_max_count': 20}},
)
assert result == 5
@pytest.mark.anyio
@patch(GROUPS_PATH, new_callable=AsyncMock)
async def test_most_permissive_across_groups(self, mock_groups):
mock_groups.return_value = [
_group({'workspace': {'knowledge_max_count': 5}}),
_group({'workspace': {'knowledge_max_count': 30}}),
]
assert await get_permission_value('user1', 'workspace.knowledge_max_count') == 30
@pytest.mark.anyio
@patch(GROUPS_PATH, new_callable=AsyncMock)
async def test_zero_unlimited_wins_over_any_limit(self, mock_groups):
mock_groups.return_value = [
_group({'workspace': {'knowledge_max_count': 5}}),
_group({'workspace': {'knowledge_max_count': 0}}),
]
assert await get_permission_value('user1', 'workspace.knowledge_max_count') == 0
@pytest.mark.anyio
@patch(GROUPS_PATH, new_callable=AsyncMock)
async def test_missing_key_returns_none(self, mock_groups):
mock_groups.return_value = [_group({'workspace': {'knowledge': True}})]
assert await get_permission_value('user1', 'workspace.knowledge_max_count') is None
@pytest.mark.anyio
@patch(GROUPS_PATH, new_callable=AsyncMock)
async def test_size_limit_key(self, mock_groups):
mock_groups.return_value = [_group({'workspace': {'knowledge_max_size': 50}})]
assert await get_permission_value('user1', 'workspace.knowledge_max_size') == 50

View file

@ -42,10 +42,21 @@ async def get_permissions(
def combine_permissions(permissions: dict[str, Any], group_permissions: dict[str, Any]) -> dict[str, Any]:
"""Combine permissions from multiple groups by taking the most permissive value."""
for key, value in group_permissions.items():
if value is None:
continue # None means "not configured for this group", skip
if isinstance(value, dict):
if key not in permissions:
permissions[key] = {}
permissions[key] = combine_permissions(permissions[key], value)
elif isinstance(value, int) and not isinstance(value, bool):
if key not in permissions or permissions[key] is None:
permissions[key] = value
elif isinstance(permissions[key], int) and not isinstance(permissions[key], bool):
# 0 = unlimited (most permissive), otherwise take the highest limit
if permissions[key] == 0 or value == 0:
permissions[key] = 0
else:
permissions[key] = max(permissions[key], value)
else:
if key not in permissions:
permissions[key] = value
@ -104,6 +115,53 @@ async def has_permission(
return get_permission(default_permissions, permission_hierarchy)
async def get_permission_value(
user_id: str,
permission_key: str,
default_permissions: dict[str, Any] = {},
db: AsyncSession | None = None,
) -> Any:
"""
Return the raw value of a permission for a user (int, bool, None, etc.).
For integer-valued permissions across multiple groups the most permissive
value is returned: 0 means unlimited (always wins), otherwise the maximum.
Returns None when no group or default has a value for the key.
"""
def traverse(permissions: dict[str, Any], keys: list[str]) -> Any:
for key in keys:
if not isinstance(permissions, dict) or key not in permissions:
return None
permissions = permissions[key]
return permissions
permission_hierarchy = permission_key.split('.')
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
best: Any = None
for group in user_groups:
value = traverse(group.permissions or {}, permission_hierarchy)
if value is None:
continue
if isinstance(value, int) and not isinstance(value, bool):
if best is None:
best = value
elif best == 0 or value == 0:
best = 0 # 0 = unlimited, always wins
else:
best = max(best, value)
elif value and best is None:
best = value
if best is not None:
return best
# Fall back to default permissions
merged = fill_missing_permissions({}, default_permissions)
return traverse(merged, permission_hierarchy)
async def has_access(
user_id: str,
permission: str = 'read',

View file

@ -81,7 +81,34 @@
</div>
<Switch bind:state={permissions.workspace.knowledge} />
</div>
{#if defaultPermissions?.workspace?.knowledge && !permissions.workspace.knowledge}
{#if permissions.workspace.knowledge}
<div class="ml-2 flex flex-col gap-2 pt-0.5 pb-1">
<div class="flex w-full justify-between">
<div class="self-center text-xs">
{$i18n.t('Max Files per Knowledge Base')}
</div>
<input
class="w-20 text-xs text-right bg-transparent outline-hidden border-b border-gray-300 dark:border-gray-600"
type="number"
min="0"
placeholder={$i18n.t('Unlimited')}
bind:value={permissions.workspace.knowledge_max_count}
/>
</div>
<div class="flex w-full justify-between">
<div class="self-center text-xs">
{$i18n.t('Max File Size (MB)')}
</div>
<input
class="w-20 text-xs text-right bg-transparent outline-hidden border-b border-gray-300 dark:border-gray-600"
type="number"
min="0"
placeholder={$i18n.t('Unlimited')}
bind:value={permissions.workspace.knowledge_max_size}
/>
</div>
</div>
{:else if defaultPermissions?.workspace?.knowledge}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}

View file

@ -281,17 +281,19 @@
return null;
}
if (
($config?.file?.max_size ?? null) !== null &&
file.size > ($config?.file?.max_size ?? 0) * 1024 * 1024
) {
console.log('File exceeds max size limit:', {
fileSize: file.size,
maxSize: ($config?.file?.max_size ?? 0) * 1024 * 1024
});
const knowledgeMaxSize = $config?.knowledge?.max_size ?? null;
if (knowledgeMaxSize !== null && file.size > knowledgeMaxSize * 1024 * 1024) {
toast.error(
$i18n.t(`File size should not exceed {{maxSize}} MB.`, {
maxSize: $config?.file?.max_size
$i18n.t(`File size should not exceed {{maxSize}} MB.`, { maxSize: knowledgeMaxSize })
);
return;
}
const knowledgeMaxCount = $config?.knowledge?.max_count ?? null;
if (knowledgeMaxCount !== null && (fileItemsTotal ?? 0) >= knowledgeMaxCount) {
toast.error(
$i18n.t(`Knowledge base file limit reached ({{maxCount}} files maximum).`, {
maxCount: knowledgeMaxCount
})
);
return;

View file

@ -107,18 +107,10 @@
extension: file.name.split('.').at(-1)
});
if (
($config?.file?.max_size ?? null) !== null &&
file.size > ($config?.file?.max_size ?? 0) * 1024 * 1024
) {
console.log('File exceeds max size limit:', {
fileSize: file.size,
maxSize: ($config?.file?.max_size ?? 0) * 1024 * 1024
});
const knowledgeMaxSize = $config?.knowledge?.max_size ?? null;
if (knowledgeMaxSize !== null && file.size > knowledgeMaxSize * 1024 * 1024) {
toast.error(
$i18n.t(`File size should not exceed {{maxSize}} MB.`, {
maxSize: $config?.file?.max_size
})
$i18n.t(`File size should not exceed {{maxSize}} MB.`, { maxSize: knowledgeMaxSize })
);
return;
}

View file

@ -2,6 +2,8 @@ export const DEFAULT_PERMISSIONS = {
workspace: {
models: false,
knowledge: false,
knowledge_max_count: null as number | null,
knowledge_max_size: null as number | null,
prompts: false,
tools: false,
skills: false,

View file

@ -1166,6 +1166,7 @@
"Keyboard Shortcuts": "",
"Knowledge": "",
"Knowledge Access": "",
"Knowledge base file limit reached ({{maxCount}} files maximum).": "",
"Knowledge Base": "",
"Knowledge created successfully.": "",
"Knowledge deleted successfully.": "",
@ -1252,6 +1253,8 @@
"March": "",
"Markdown": "",
"Markdown Header Text Splitter": "",
"Max File Size (MB)": "",
"Max Files per Knowledge Base": "",
"Max Speakers": "",
"Max Upload Count": "",
"Max Upload Size": "",
@ -2108,6 +2111,7 @@
"Underline": "",
"Unknown": "",
"Unknown User": "",
"Unlimited": "",
"Unloads {{FROM_NOW}}": "",
"Unlock mysteries": "",
"Unpin": "",