diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index d2c88cb2fb..d78c98e584 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -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, diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index ba7f74c830..5b3491efb4 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -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, diff --git a/backend/open_webui/models/knowledge.py b/backend/open_webui/models/knowledge.py index 2750ef6058..fb78f7c32d 100644 --- a/backend/open_webui/models/knowledge.py +++ b/backend/open_webui/models/knowledge.py @@ -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, diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index f503169fc0..9567c4b834 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -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] diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 9dec855e45..dd54bf719e 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -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 diff --git a/backend/open_webui/test/util/conftest.py b/backend/open_webui/test/util/conftest.py new file mode 100644 index 0000000000..4bff293ed7 --- /dev/null +++ b/backend/open_webui/test/util/conftest.py @@ -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' diff --git a/backend/open_webui/test/util/test_access_control.py b/backend/open_webui/test/util/test_access_control.py new file mode 100644 index 0000000000..e59b6912db --- /dev/null +++ b/backend/open_webui/test/util/test_access_control.py @@ -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 diff --git a/backend/open_webui/utils/access_control/__init__.py b/backend/open_webui/utils/access_control/__init__.py index 06e8d0aba0..0c409a99b1 100644 --- a/backend/open_webui/utils/access_control/__init__.py +++ b/backend/open_webui/utils/access_control/__init__.py @@ -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', diff --git a/src/lib/components/admin/Users/Groups/Permissions.svelte b/src/lib/components/admin/Users/Groups/Permissions.svelte index cbfcb67b0a..5d44c8a98f 100644 --- a/src/lib/components/admin/Users/Groups/Permissions.svelte +++ b/src/lib/components/admin/Users/Groups/Permissions.svelte @@ -81,7 +81,34 @@ - {#if defaultPermissions?.workspace?.knowledge && !permissions.workspace.knowledge} + {#if permissions.workspace.knowledge} +
+
+
+ {$i18n.t('Max Files per Knowledge Base')} +
+ +
+
+
+ {$i18n.t('Max File Size (MB)')} +
+ +
+
+ {:else if defaultPermissions?.workspace?.knowledge}
{$i18n.t('This is a default user permission and will remain enabled.')} diff --git a/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte b/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte index 64438e23fc..5f10b218ee 100644 --- a/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte +++ b/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte @@ -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; diff --git a/src/lib/components/workspace/Models/Knowledge.svelte b/src/lib/components/workspace/Models/Knowledge.svelte index 22cac5cd17..246fdd2dff 100644 --- a/src/lib/components/workspace/Models/Knowledge.svelte +++ b/src/lib/components/workspace/Models/Knowledge.svelte @@ -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; } diff --git a/src/lib/constants/permissions.ts b/src/lib/constants/permissions.ts index 69a8a3a664..0c9717d4ed 100644 --- a/src/lib/constants/permissions.ts +++ b/src/lib/constants/permissions.ts @@ -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, diff --git a/src/lib/i18n/locales/en-US/translation.json b/src/lib/i18n/locales/en-US/translation.json index ad0f42f733..aed092910d 100644 --- a/src/lib/i18n/locales/en-US/translation.json +++ b/src/lib/i18n/locales/en-US/translation.json @@ -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": "",