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 @@