diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 2761b7f81b..8780b87015 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -899,7 +899,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, check_base_model_access connections = app.state.config.TOOL_SERVER_CONNECTIONS if any('access_control' in c.get('config', {}) for c in connections): @@ -1682,9 +1682,16 @@ async def chat_completion( # Re-run the access check against the resolved base model. Access on # the user-facing wrapper does not extend to the chain target, so - # each link must be authorized independently. - if not BYPASS_MODEL_ACCESS_CONTROL and (user.role != 'admin' or not BYPASS_ADMIN_ACCESS_CONTROL): - await check_model_access(user, request.app.state.MODELS[resolved_base_model_id]) + # each link must be authorized independently. check_base_model_access + # raises HTTPException(403) — which the outer except HTTPException: raise + # preserves — rather than the bare Exception raised by the utils/models.py + # helper, so authorization failures reach the client as 403, not 400. + await check_base_model_access( + user, + resolved_base_model_id, + bypass_filter=BYPASS_MODEL_ACCESS_CONTROL + or (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL), + ) # Chat Params stream_delta_chunk_size = form_data.get('params', {}).get('stream_delta_chunk_size') @@ -1763,6 +1770,11 @@ async def chat_completion( request.state.metadata = metadata form_data['metadata'] = metadata + except HTTPException: + # Preserve intentional HTTP statuses — e.g. the 403 raised by the + # base-model access check and the 404 from the chat-ownership check — + # so authorization failures are not remapped to 400. + raise except Exception as e: log.debug(f'Error processing chat metadata: {e}') raise HTTPException( diff --git a/backend/open_webui/utils/access_control/__init__.py b/backend/open_webui/utils/access_control/__init__.py index 2d76dfa7d0..e8fa86f605 100644 --- a/backend/open_webui/utils/access_control/__init__.py +++ b/backend/open_webui/utils/access_control/__init__.py @@ -316,13 +316,41 @@ async def check_base_model_access( access decision. Call this before any code path that resolves or dispatches to the base, and before persisting a base_model_id chosen by the caller. + Unlike the generic check_model_access (which only enforces grants for + user.role == 'user' and lets admins through unconditionally), this helper + enforces the grant check for every role when bypass_filter is False. + Callers are expected to opt admins out explicitly by passing + bypass_filter=True when BYPASS_ADMIN_ACCESS_CONTROL is set — otherwise an + admin creating a chain or dispatching through one still needs read access + to the base, since the wrapper's ownership does not transfer to the base. + Raises HTTPException(403) if not authorized. Does nothing if bypass_filter is True or base_model_id is falsy. """ + from fastapi import HTTPException + if bypass_filter or not base_model_id: return from open_webui.models.models import Models + from open_webui.models.access_grants import AccessGrants base_model_info = await Models.get_model_by_id(base_model_id, db=db) - await check_model_access(user, base_model_info, bypass_filter) + if base_model_info is None: + raise HTTPException(status_code=403, detail='Model not found') + + if user.id == base_model_info.user_id: + return + + user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)} + if await AccessGrants.has_access( + user_id=user.id, + resource_type='model', + resource_id=base_model_info.id, + permission='read', + user_group_ids=user_group_ids, + db=db, + ): + return + + raise HTTPException(status_code=403, detail='Model not found')