From f6028195230073d270e71c2be0db5d87846e36fa Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:38:41 +0300 Subject: [PATCH] fix(models): scope API-token model listing (#4292) --- routes/model_routes.py | 19 ++++++---- tests/test_model_routes.py | 77 +++++++++++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 9 deletions(-) diff --git a/routes/model_routes.py b/routes/model_routes.py index b5bd6ead8..8ab42e99c 100644 --- a/routes/model_routes.py +++ b/routes/model_routes.py @@ -26,7 +26,7 @@ from src.endpoint_resolver import ( build_models_url, build_headers, ) -from src.auth_helpers import _auth_disabled, owner_filter +from src.auth_helpers import _auth_disabled, effective_user, owner_filter logger = logging.getLogger(__name__) @@ -1255,13 +1255,16 @@ def setup_model_routes(model_discovery): # Require auth; "" is the unconfigured single-user mode, treated as # "see everything" by _fetch_models. try: - from src.auth_helpers import get_current_user as _gcu - owner = _gcu(request) or "" - except Exception: - owner = "" - # Reject anonymous in configured deployments — no leaking the model - # list to unauthenticated callers. - try: + if getattr(request.state, "api_token", False): + scopes = set(getattr(request.state, "api_token_scopes", []) or []) + if "chat" not in scopes: + raise HTTPException(403, "API token is not scoped for chat") + if not getattr(request.state, "api_token_owner", None): + raise HTTPException(403, "API token has no owner") + owner = effective_user(request) or "" + + # Reject anonymous in configured deployments — no leaking the model + # list to unauthenticated callers. auth_mgr = getattr(request.app.state, "auth_manager", None) if not owner and not _auth_disabled() and auth_mgr is not None and getattr(auth_mgr, "is_configured", False): raise HTTPException(401, "Not authenticated") diff --git a/tests/test_model_routes.py b/tests/test_model_routes.py index bceb6c11f..b4150a331 100644 --- a/tests/test_model_routes.py +++ b/tests/test_model_routes.py @@ -1286,6 +1286,14 @@ class _ImmediateThread: self.target() +class _NoopThread: + def __init__(self, target, daemon=None): + self.target = target + + def start(self): + return None + + def _wait_for(predicate, timeout=2.0): deadline = time.time() + timeout while time.time() < deadline: @@ -1313,6 +1321,7 @@ def _route_ep( pinned_models=None, refresh_mode="auto", refresh_timeout=None, + owner=None, ): return SimpleNamespace( id=id, @@ -1329,7 +1338,7 @@ def _route_ep( model_refresh_interval=None, model_refresh_timeout=refresh_timeout, supports_tools=None, - owner=None, + owner=owner, created_at=None, updated_at=None, ) @@ -1342,6 +1351,72 @@ def _route_request(): ) +def test_api_models_rejects_api_token_without_chat_scope(monkeypatch): + router = model_routes.setup_model_routes(model_discovery=None) + + def fail_session(): + raise AssertionError("model DB should not be queried without chat scope") + + monkeypatch.setattr(model_routes, "SessionLocal", fail_session) + + request = SimpleNamespace( + state=SimpleNamespace( + current_user="api", + api_token=True, + api_token_owner="alice", + api_token_scopes=["documents:read"], + ), + app=SimpleNamespace( + state=SimpleNamespace( + auth_manager=SimpleNamespace(is_configured=True, is_admin=lambda user: False), + ), + ), + ) + + with pytest.raises(HTTPException) as exc: + _route_endpoint(router, "/api/models")(request) + + assert exc.value.status_code == 403 + assert "chat" in str(exc.value.detail) + + +def test_api_models_scopes_api_token_to_token_owner(monkeypatch): + rows = [ + _route_ep("alice", "http://alice.example/v1", cached_models=["alice-model"], owner="alice"), + _route_ep("shared", "http://shared.example/v1", cached_models=["shared-model"], owner=None), + _route_ep("bob", "http://bob.example/v1", cached_models=["bob-model"], owner="bob"), + ] + db = _RouteDb(rows) + router = model_routes.setup_model_routes(model_discovery=None) + admin_checks = [] + + monkeypatch.setattr(model_routes, "ModelEndpoint", _RouteModelEndpoint) + monkeypatch.setattr(model_routes, "SessionLocal", lambda: db) + monkeypatch.setattr(threading, "Thread", _NoopThread) + + request = SimpleNamespace( + state=SimpleNamespace( + current_user="api", + api_token=True, + api_token_owner="alice", + api_token_scopes=["chat"], + ), + app=SimpleNamespace( + state=SimpleNamespace( + auth_manager=SimpleNamespace( + is_configured=True, + is_admin=lambda user: admin_checks.append(user) or False, + ), + ), + ), + ) + + result = _route_endpoint(router, "/api/models")(request) + + assert [item["endpoint_name"] for item in result["items"]] == ["alice", "shared"] + assert admin_checks == ["alice"] + + def test_api_models_returns_cached_proxy_models_without_refresh_probe(monkeypatch): row = _route_ep( "proxy",