fix: enforce base-model grants for admins and preserve 403 at dispatch

Two follow-ups on review feedback:

check_base_model_access delegated to check_model_access, which only
enforces grants for user.role == 'user' and admits admins
unconditionally. Callers were passing a bypass_filter that combined
BYPASS_MODEL_ACCESS_CONTROL with admin + BYPASS_ADMIN_ACCESS_CONTROL —
the shape of that expression implies admins are supposed to be
enforced when BYPASS_ADMIN_ACCESS_CONTROL is False, but the delegated
check let them through regardless. Inline the grant resolution so the
helper enforces ownership-or-read-grant for every role when
bypass_filter is False. Admin opt-out happens in the caller by setting
bypass_filter=True, which is what the existing call sites already do.

The dispatch-time base check in main.py's chat_completion called
check_model_access from utils/models.py, which raises bare Exception.
The surrounding try/except Exception remapped that to HTTP 400 Bad
Request, so base authz failures looked like malformed requests. Swap
in check_base_model_access (which raises HTTPException 403) and add
except HTTPException: raise before the broad handler so intentional
statuses — the 403 from this check and the 404 from the chat-ownership
check above it — are preserved.
This commit is contained in:
DrMelone 2026-04-13 01:41:56 +02:00
parent e874469d0f
commit 7a0fd37a66
2 changed files with 45 additions and 5 deletions

View file

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

View file

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