fix(companion): require chat scope for model inventory (#4319)

This commit is contained in:
RaresKeY
2026-06-16 02:15:05 +03:00
committed by GitHub
parent 8ff76f083c
commit b58af4267b
2 changed files with 49 additions and 5 deletions
+17 -3
View File
@@ -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 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_` 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 API token. Ping/info accept either credential type, models requires a chat-
endpoints are admin-cookie only. 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 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 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 import html
from fastapi import APIRouter, Request from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from core.middleware import require_admin 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 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]: def mint_pairing_token(owner: str, invalidate=None) -> tuple[str, str]:
"""Mint a pairing token AND invalidate the auth middleware's in-memory token """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 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 rows -- the same rule as owner_filter. Read-only; never returns api_key
material. material.
""" """
require_models_scope(request)
import json as _json import json as _json
from core.database import SessionLocal, ModelEndpoint from core.database import SessionLocal, ModelEndpoint
+32 -2
View File
@@ -13,6 +13,9 @@ import json
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock 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__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# core.database instantiates SQLAlchemy declarative classes at import time, which # 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( endpoints = _call_models_route(
monkeypatch, monkeypatch,
rows, 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"] 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): def test_models_route_unresolved_owner_returns_only_shared_rows(monkeypatch):
rows = [ rows = [
_ep(1, "alice-endpoint", "alice"), _ep(1, "alice-endpoint", "alice"),
@@ -242,7 +267,12 @@ def test_models_route_unresolved_owner_returns_only_shared_rows(monkeypatch):
endpoints = _call_models_route( endpoints = _call_models_route(
monkeypatch, monkeypatch,
rows, 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"] assert _endpoint_names(endpoints) == ["shared-endpoint"]