diff --git a/companion/routes.py b/companion/routes.py index 9c8464f0f..0191640ef 100644 --- a/companion/routes.py +++ b/companion/routes.py @@ -5,8 +5,9 @@ offers and pair to it, without duplicating any LLM logic. Auth is enforced globally by AuthMiddleware (app.py), so reaching a handler here means the caller is authenticated by either a cookie session or a Bearer `ody_` -API token. The read endpoints (ping/info/models) accept either; the pairing -endpoints are admin-cookie only. +API token. Ping/info accept either credential type, models requires a chat- +scoped API token for bearer callers, and the pairing endpoints are admin-cookie +only. Pairing CSRF posture: minting happens ONLY on POST. The session cookie is SameSite=Lax (routes/auth_routes.py), which a browser does not send on a @@ -18,7 +19,7 @@ on a GET would be unsafe (Lax cookies ride top-level GET navigations), so GET import html -from fastapi import APIRouter, Request +from fastapi import APIRouter, HTTPException, Request from fastapi.responses import HTMLResponse from core.middleware import require_admin @@ -52,6 +53,18 @@ def owner_can_see(row_owner, owner) -> bool: return row_owner is None or row_owner == owner +def require_models_scope(request: Request) -> None: + """Require the companion chat scope for bearer-token model inventory.""" + if not getattr(request.state, "api_token", False): + return + scopes = getattr(request.state, "api_token_scopes", None) or [] + if isinstance(scopes, str): + scopes = [scope.strip() for scope in scopes.split(",")] + scope_set = {str(scope).strip() for scope in scopes if str(scope).strip()} + if _pairing.COMPANION_SCOPE not in scope_set: + raise HTTPException(403, "API token requires chat scope") + + def mint_pairing_token(owner: str, invalidate=None) -> tuple[str, str]: """Mint a pairing token AND invalidate the auth middleware's in-memory token cache, so the new token is accepted on the very next request without a server @@ -103,6 +116,7 @@ def setup_companion_routes() -> APIRouter: rows -- the same rule as owner_filter. Read-only; never returns api_key material. """ + require_models_scope(request) import json as _json from core.database import SessionLocal, ModelEndpoint diff --git a/tests/test_companion_readonly.py b/tests/test_companion_readonly.py index 3dd7e68b5..589621b66 100644 --- a/tests/test_companion_readonly.py +++ b/tests/test_companion_readonly.py @@ -13,6 +13,9 @@ import json from types import SimpleNamespace from unittest.mock import MagicMock +import pytest +from fastapi import HTTPException + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # core.database instantiates SQLAlchemy declarative classes at import time, which @@ -225,12 +228,34 @@ def test_models_route_scopes_api_token_to_token_owner(monkeypatch): endpoints = _call_models_route( monkeypatch, rows, - _request(api_token=True, api_token_owner="alice", current_user="api"), + _request( + api_token=True, + api_token_owner="alice", + api_token_scopes=["chat"], + current_user="api", + ), ) assert _endpoint_names(endpoints) == ["alice-endpoint", "shared-endpoint"] +def test_models_route_rejects_api_token_without_chat_scope(monkeypatch): + monkeypatch.setattr(companion_routes, "get_current_user", lambda request: "api") + + with pytest.raises(HTTPException) as exc: + _models_route()( + _request( + api_token=True, + api_token_owner="alice", + api_token_scopes=["todos:read"], + current_user="api", + ) + ) + + assert exc.value.status_code == 403 + assert "chat scope" in exc.value.detail + + def test_models_route_unresolved_owner_returns_only_shared_rows(monkeypatch): rows = [ _ep(1, "alice-endpoint", "alice"), @@ -242,7 +267,12 @@ def test_models_route_unresolved_owner_returns_only_shared_rows(monkeypatch): endpoints = _call_models_route( monkeypatch, rows, - _request(api_token=True, api_token_owner=None, current_user="api"), + _request( + api_token=True, + api_token_owner=None, + api_token_scopes=["chat"], + current_user="api", + ), ) assert _endpoint_names(endpoints) == ["shared-endpoint"]