mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-30 12:02:22 +02:00
fix(security): restore scoped bearer compatibility
This commit is contained in:
@@ -395,7 +395,7 @@ def test_bearer_context_preprocessing_does_not_fetch_embedded_urls(monkeypatch):
|
||||
async def test_sync_bearer_chat_cannot_use_research_memory_or_background_extraction(monkeypatch):
|
||||
from routes import chat_routes
|
||||
|
||||
calls = {"memory": 0, "research": 0, "post": [], "recovery": []}
|
||||
calls = {"memory": 0, "research": 0, "post": [], "recovery": [], "orphan": []}
|
||||
|
||||
class _ChatHandler:
|
||||
async def handle_memory_command(self, _session, _message):
|
||||
@@ -434,7 +434,11 @@ async def test_sync_bearer_chat_cannot_use_research_memory_or_background_extract
|
||||
return "answer", args[0][0], "selected-model"
|
||||
|
||||
monkeypatch.setattr(chat_routes, "_verify_session_owner", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(chat_routes, "_clear_orphaned_session_endpoint", lambda *args, **kwargs: False)
|
||||
def clear_orphan(*args, **kwargs):
|
||||
calls["orphan"].append(kwargs)
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(chat_routes, "_clear_orphaned_session_endpoint", clear_orphan)
|
||||
def recover(*args, **kwargs):
|
||||
calls["recovery"].append(kwargs)
|
||||
return False
|
||||
@@ -487,6 +491,7 @@ async def test_sync_bearer_chat_cannot_use_research_memory_or_background_extract
|
||||
assert calls["memory"] == 0
|
||||
assert calls["research"] == 0
|
||||
assert calls["post"] and calls["post"][0]["allow_background_extraction"] is False
|
||||
assert calls["orphan"] == [{"owner": "alice", "allow_live_probes": False}]
|
||||
assert calls["recovery"] == [{"owner": "alice", "allow_live_probes": False}]
|
||||
|
||||
|
||||
@@ -503,12 +508,28 @@ async def test_stream_bearer_chat_disables_deferred_memory_extraction(monkeypatc
|
||||
capture_completion=True,
|
||||
)
|
||||
recovery_calls = []
|
||||
boundary_calls = {"reconcile": [], "orphan": [], "auth": []}
|
||||
|
||||
def recover(*args, **kwargs):
|
||||
recovery_calls.append(kwargs)
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(chat_routes, "_recover_empty_session_model", recover)
|
||||
monkeypatch.setattr(
|
||||
chat_routes,
|
||||
"_reconcile_selected_route_from_request",
|
||||
lambda *args, **kwargs: boundary_calls["reconcile"].append(kwargs) or False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
chat_routes,
|
||||
"_clear_orphaned_session_endpoint",
|
||||
lambda *args, **kwargs: boundary_calls["orphan"].append(kwargs) or False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
chat_routes,
|
||||
"resolve_session_auth",
|
||||
lambda *args, **kwargs: boundary_calls["auth"].append(kwargs),
|
||||
)
|
||||
request = SimpleNamespace(
|
||||
headers={},
|
||||
app=SimpleNamespace(state=SimpleNamespace(auth_manager=None)),
|
||||
@@ -532,6 +553,9 @@ async def test_stream_bearer_chat_disables_deferred_memory_extraction(monkeypatc
|
||||
|
||||
assert captured["post_processed"]
|
||||
assert captured["post_processed"][0][1]["allow_background_extraction"] is False
|
||||
assert boundary_calls["reconcile"] == [{"owner": "alice", "allow_live_probes": False}]
|
||||
assert boundary_calls["orphan"] == [{"owner": "alice", "allow_live_probes": False}]
|
||||
assert boundary_calls["auth"] == [{"owner": "alice", "allow_live_probes": False}]
|
||||
assert recovery_calls == [{"owner": "alice", "allow_live_probes": False}]
|
||||
|
||||
|
||||
@@ -680,6 +704,114 @@ def test_bearer_model_recovery_uses_cache_without_endpoint_or_session_writes(mon
|
||||
assert db.rollbacks == 0
|
||||
|
||||
|
||||
def test_bearer_model_recovery_uses_pinned_only_cache_inventory(monkeypatch):
|
||||
chat_routes, db, endpoint, session_row, sess = _recovery_harness(
|
||||
monkeypatch,
|
||||
["stale-cached-model"],
|
||||
)
|
||||
endpoint.pinned_models = json.dumps(["pinned-model"])
|
||||
endpoint.hidden_models = json.dumps(["stale-cached-model"])
|
||||
|
||||
assert chat_routes._recover_empty_session_model(
|
||||
sess,
|
||||
"session-1",
|
||||
owner="alice",
|
||||
allow_live_probes=False,
|
||||
) is True
|
||||
assert sess.model == "pinned-model"
|
||||
assert session_row.model == ""
|
||||
assert db.commits == 0
|
||||
|
||||
|
||||
def test_bearer_no_live_recovery_boundaries_do_not_open_or_commit(monkeypatch):
|
||||
from routes import chat_helpers, chat_routes
|
||||
|
||||
session = SimpleNamespace(
|
||||
id="session-1",
|
||||
endpoint_url="https://api.example.test/v1/chat/completions",
|
||||
model="selected-model",
|
||||
headers={"Authorization": "Bearer selected"},
|
||||
)
|
||||
|
||||
def forbidden_db(*args, **kwargs):
|
||||
raise AssertionError("bearer no-live boundary opened a database session")
|
||||
|
||||
monkeypatch.setattr(chat_routes, "SessionLocal", forbidden_db)
|
||||
assert chat_routes._clear_orphaned_session_endpoint(
|
||||
session,
|
||||
owner="alice",
|
||||
allow_live_probes=False,
|
||||
) is False
|
||||
assert chat_routes._reconcile_selected_route_from_request(
|
||||
SimpleNamespace(),
|
||||
session,
|
||||
"session-1",
|
||||
{"selected_model": "new-model", "selected_endpoint_id": "ep"},
|
||||
owner="alice",
|
||||
allow_live_probes=False,
|
||||
) is False
|
||||
|
||||
monkeypatch.setattr(chat_helpers, "SessionLocal", forbidden_db)
|
||||
monkeypatch.setattr(
|
||||
"src.endpoint_resolver.resolve_endpoint_runtime",
|
||||
lambda *args, **kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("bearer no-live auth resolved provider credentials")
|
||||
),
|
||||
)
|
||||
original_headers = dict(session.headers)
|
||||
chat_helpers.resolve_session_auth(
|
||||
session,
|
||||
"session-1",
|
||||
owner="alice",
|
||||
allow_live_probes=False,
|
||||
)
|
||||
assert session.headers == original_headers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bearer_context_compaction_uses_session_route_without_utility_resolution(monkeypatch):
|
||||
from src import context_compactor
|
||||
|
||||
resolver_calls = []
|
||||
llm_calls = []
|
||||
monkeypatch.setattr(
|
||||
context_compactor,
|
||||
"resolve_endpoint",
|
||||
lambda *args, **kwargs: resolver_calls.append((args, kwargs)) or (
|
||||
"https://utility.example/v1",
|
||||
"utility-model",
|
||||
{"Authorization": "Bearer utility"},
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(context_compactor, "get_context_length", lambda *args, **kwargs: 1)
|
||||
|
||||
async def summarize(*args, **kwargs):
|
||||
llm_calls.append((args, kwargs))
|
||||
return "summary"
|
||||
|
||||
monkeypatch.setattr(context_compactor, "llm_call_async", summarize)
|
||||
session = SimpleNamespace()
|
||||
messages = [{"role": "user", "content": f"message {i}"} for i in range(6)]
|
||||
|
||||
_result, _context, compacted = await context_compactor.maybe_compact(
|
||||
session,
|
||||
"https://selected.example/v1/chat/completions",
|
||||
"selected-model",
|
||||
messages,
|
||||
{"Authorization": "Bearer selected"},
|
||||
owner="alice",
|
||||
persist=False,
|
||||
allow_live_probes=False,
|
||||
)
|
||||
|
||||
assert compacted is True
|
||||
assert resolver_calls == []
|
||||
assert llm_calls[0][0][:2] == (
|
||||
"https://selected.example/v1/chat/completions",
|
||||
"selected-model",
|
||||
)
|
||||
assert llm_calls[0][1]["headers"] == {"Authorization": "Bearer selected"}
|
||||
assert llm_calls[0][1]["allow_live_probes"] is False
|
||||
def test_interactive_model_recovery_retains_live_catalog_and_persistence(monkeypatch):
|
||||
chat_routes, db, endpoint, session_row, sess = _recovery_harness(monkeypatch, [])
|
||||
from src import chatgpt_subscription, endpoint_resolver
|
||||
@@ -998,19 +1130,40 @@ def test_bearer_cannot_reach_workspace_or_hwfit_direct_handlers(monkeypatch):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codex_bearer_rejected_before_direct_and_router_host_control(monkeypatch):
|
||||
async def test_codex_bearer_data_scope_is_allowed_but_host_control_is_denied(monkeypatch):
|
||||
import routes.codex_routes as codex_routes
|
||||
|
||||
async def manage_notes(*args, **kwargs):
|
||||
return {"owner": kwargs["owner"], "ok": True}
|
||||
|
||||
monkeypatch.setattr(codex_routes, "do_manage_notes", manage_notes)
|
||||
router = codex_routes.setup_codex_routes()
|
||||
bearer = _request(scopes=("chat", "cookbook:read", "cookbook:launch"))
|
||||
direct_cases = [
|
||||
("/api/codex/capabilities", "GET", (bearer,)),
|
||||
bearer = _request(scopes=("chat", "todos:read", "cookbook:read", "cookbook:launch"))
|
||||
capabilities = next(
|
||||
route.endpoint
|
||||
for route in router.routes
|
||||
if route.path == "/api/codex/capabilities" and "GET" in route.methods
|
||||
)
|
||||
assert capabilities(bearer)["tools"]["todos"]["read"] is True
|
||||
|
||||
todos = next(
|
||||
route.endpoint
|
||||
for route in router.routes
|
||||
if route.path == "/api/codex/todos" and "GET" in route.methods
|
||||
)
|
||||
assert await todos(bearer) == {"owner": "alice", "ok": True}
|
||||
|
||||
with pytest.raises(HTTPException) as missing_scope:
|
||||
await todos(_request(scopes=("chat",)))
|
||||
assert missing_scope.value.status_code == 403
|
||||
|
||||
direct_host_cases = [
|
||||
("/api/codex/plugin.zip", "GET", (bearer,)),
|
||||
("/api/codex/cookbook/tasks", "GET", (bearer,)),
|
||||
("/api/codex/cookbook/serve", "POST", (bearer, {})),
|
||||
("/api/codex/cookbook/output/{session_id}", "GET", (bearer, "serve-1")),
|
||||
]
|
||||
for path, method, args in direct_cases:
|
||||
for path, method, args in direct_host_cases:
|
||||
endpoint = next(
|
||||
route.endpoint
|
||||
for route in router.routes
|
||||
@@ -1027,11 +1180,19 @@ async def test_codex_bearer_rejected_before_direct_and_router_host_control(monke
|
||||
headers = {
|
||||
"x-api-token": "1",
|
||||
"x-api-owner": "alice",
|
||||
"x-api-scopes": "cookbook:read,cookbook:launch",
|
||||
"x-api-scopes": "todos:read,cookbook:read,cookbook:launch",
|
||||
}
|
||||
async with _client(_PrincipalState(app)) as client:
|
||||
capabilities_response = await client.get("/api/codex/capabilities", headers=headers)
|
||||
assert capabilities_response.status_code == 200, capabilities_response.text
|
||||
assert capabilities_response.json()["tools"]["todos"]["read"] is True
|
||||
|
||||
todos_response = await client.get("/api/codex/todos", headers=headers)
|
||||
assert todos_response.status_code == 200, todos_response.text
|
||||
assert todos_response.json() == {"owner": "alice", "ok": True}
|
||||
|
||||
for method, path, kwargs in (
|
||||
("GET", "/api/codex/capabilities", {}),
|
||||
("GET", "/api/codex/plugin.zip", {}),
|
||||
("GET", "/api/codex/cookbook/tasks", {}),
|
||||
("POST", "/api/codex/cookbook/serve", {"json": {}}),
|
||||
):
|
||||
|
||||
@@ -300,6 +300,97 @@ def test_session_creation_passes_bearer_no_live_capability_to_model_validation(m
|
||||
assert seen["allow_live_probes"] is False
|
||||
|
||||
|
||||
def test_bearer_session_creation_uses_pinned_only_cache_inventory(monkeypatch):
|
||||
from routes import session_routes as sr
|
||||
from src import database, llm_core
|
||||
|
||||
endpoint = SimpleNamespace(
|
||||
id="ep",
|
||||
is_enabled=True,
|
||||
base_url="https://api.example.test/v1",
|
||||
api_key=None,
|
||||
endpoint_kind="api",
|
||||
cached_models=json.dumps(["stale-cached-model"]),
|
||||
pinned_models=json.dumps(["server-pinned-model"]),
|
||||
hidden_models=json.dumps(["stale-cached-model"]),
|
||||
)
|
||||
db = _EndpointDb(endpoint)
|
||||
monkeypatch.setattr(sr, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(database, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(sr, "_reject_raw_endpoint_url_for_non_admin", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
llm_core,
|
||||
"httpx_get_kimi_aware",
|
||||
lambda *args, **kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("bearer setup attempted a live model probe")
|
||||
),
|
||||
)
|
||||
manager = SimpleNamespace(
|
||||
create_session=lambda **kwargs: SimpleNamespace(
|
||||
id=kwargs["session_id"],
|
||||
name=kwargs["name"],
|
||||
model=kwargs["model"],
|
||||
endpoint_url=kwargs["endpoint_url"],
|
||||
rag=kwargs["rag"],
|
||||
headers={},
|
||||
),
|
||||
)
|
||||
router = sr.setup_session_routes(manager, {})
|
||||
create_session = _endpoint(router, "/api/session", "POST")
|
||||
|
||||
result = create_session(
|
||||
request=_Request(),
|
||||
name="chat",
|
||||
endpoint_url="",
|
||||
model="",
|
||||
rag=None,
|
||||
skip_validation=None,
|
||||
api_key="",
|
||||
endpoint_id="ep",
|
||||
)
|
||||
|
||||
assert result.model == "server-pinned-model"
|
||||
|
||||
|
||||
def test_bearer_cache_only_model_normalization_rejects_forbidden_fallback(monkeypatch):
|
||||
from routes import chat_helpers
|
||||
from src import database, llm_core
|
||||
|
||||
endpoint = SimpleNamespace(
|
||||
id="ep",
|
||||
is_enabled=True,
|
||||
base_url="https://api.example.test/v1",
|
||||
endpoint_kind="api",
|
||||
cached_models=json.dumps(["stale-cached-model"]),
|
||||
pinned_models=json.dumps(["server-pinned-model"]),
|
||||
hidden_models=json.dumps(["stale-cached-model"]),
|
||||
)
|
||||
db = _EndpointDb(endpoint)
|
||||
monkeypatch.setattr(chat_helpers, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(database, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(
|
||||
llm_core,
|
||||
"httpx_get_kimi_aware",
|
||||
lambda *args, **kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("cache-only normalization attempted a live probe")
|
||||
),
|
||||
)
|
||||
|
||||
allowed = SimpleNamespace(
|
||||
endpoint_url="https://api.example.test/v1/chat/completions",
|
||||
model="server-pinned-model",
|
||||
owner="alice",
|
||||
)
|
||||
forbidden = SimpleNamespace(
|
||||
endpoint_url=allowed.endpoint_url,
|
||||
model="stale-cached-model",
|
||||
owner="alice",
|
||||
)
|
||||
|
||||
assert chat_helpers._normalize_model_id_from_cache(allowed) == "server-pinned-model"
|
||||
assert chat_helpers._normalize_model_id_from_cache(forbidden) is None
|
||||
|
||||
|
||||
def test_explicit_bearer_model_does_not_require_live_setup_probe(monkeypatch):
|
||||
from routes import session_routes as sr
|
||||
from src import llm_core
|
||||
@@ -530,6 +621,9 @@ class _EndpointDb:
|
||||
def first(self):
|
||||
return self.endpoint
|
||||
|
||||
def all(self):
|
||||
return [self.endpoint]
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
@@ -545,7 +639,9 @@ async def test_sync_chat_fallback_uses_cached_models_without_provider_probe(monk
|
||||
created_at=1,
|
||||
base_url="http://127.0.0.1:11434/v1",
|
||||
api_key="configured-key",
|
||||
cached_models=json.dumps(["cached-model"]),
|
||||
cached_models=json.dumps(["stale-cached-model"]),
|
||||
pinned_models=json.dumps(["server-pinned-model"]),
|
||||
hidden_models=json.dumps(["stale-cached-model"]),
|
||||
provider_auth_id=None,
|
||||
)
|
||||
monkeypatch.setattr(wr, "SessionLocal", lambda: _EndpointDb(endpoint))
|
||||
@@ -590,5 +686,5 @@ async def test_sync_chat_fallback_uses_cached_models_without_provider_probe(monk
|
||||
provider=None,
|
||||
)
|
||||
result = await sync_chat(request=_Request(), body=body)
|
||||
assert result["model"] == "cached-model"
|
||||
assert result["model"] == "server-pinned-model"
|
||||
assert seen["allow_live_probes"] is False
|
||||
|
||||
@@ -49,9 +49,10 @@ def test_chat_endpoint_recovery_paths_are_owner_scoped():
|
||||
chat_routes = (root / "routes" / "chat_routes.py").read_text(encoding="utf-8")
|
||||
chat_helpers = (root / "routes" / "chat_helpers.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "def _clear_orphaned_session_endpoint(sess, owner:" in chat_routes
|
||||
assert "def _clear_orphaned_session_endpoint(" in chat_routes
|
||||
assert "def _recover_empty_session_model(sess, session_id: str, owner:" in chat_routes
|
||||
assert "q = owner_filter(q, ModelEndpoint, owner)" in chat_routes
|
||||
assert "resolve_session_auth(sess, session, owner=effective_user(request))" in chat_routes
|
||||
assert "def resolve_session_auth(sess, session_id: str, owner:" in chat_helpers
|
||||
assert "allow_live_probes=request_capability.allow_live_probes" in chat_routes
|
||||
assert "def resolve_session_auth(" in chat_helpers
|
||||
assert "allow_live_probes: bool = True" in chat_helpers
|
||||
assert "update_q = update_q.filter(DBSession.owner == owner)" in chat_helpers
|
||||
|
||||
Reference in New Issue
Block a user