mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-17 02:05:22 -04:00
fix(companion): require chat scope for model inventory (#4319)
This commit is contained in:
+17
-3
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user