diff --git a/contextual_orchestrator/server.py b/contextual_orchestrator/server.py index a76cdd427..66d6d0a0a 100644 --- a/contextual_orchestrator/server.py +++ b/contextual_orchestrator/server.py @@ -5965,10 +5965,21 @@ def _authorize( self._authorized_purpose = effective_purpose def _cache_partition(self) -> str: - """Return a non-secret cache partition for the authenticated bearer.""" + """Return a non-secret cache partition for the authenticated principal. + + Bearer-authenticated callers partition by their bearer token. + Browser admin sessions authenticate without a bearer header, so an + active opaque session id partitions those requests instead — the + session id is random per login, so cross-session cache reuse stays + impossible while cookie-authenticated operators still get hits + within their own session. + """ raw = self.headers.get("authorization", "") token = raw.split(" ", 1)[1].strip() if raw.lower().startswith("bearer ") else "" - if not token: # pragma: no cover - _authorize rejects this first + if not token: + session_id = security._extract_admin_session_cookie(self.headers) + if session_id and security._admin_session_is_active(session_id): + return hashlib.sha256(f"admin-session:{session_id}".encode("utf-8")).hexdigest() raise RequestError(401, "unauthorized", "bearer token is required") return hashlib.sha256(token.encode("utf-8")).hexdigest() diff --git a/tests/test_security_hardening.py b/tests/test_security_hardening.py index a60217abf..cfb219004 100644 --- a/tests/test_security_hardening.py +++ b/tests/test_security_hardening.py @@ -141,6 +141,51 @@ def test_admin_session_is_opaque_scoped_and_revocable() -> None: thread.join(timeout=5) + +def test_admin_session_requests_partition_cache_without_bearer() -> None: + """A cookie-authenticated admin POST must not 401 in the cache partitioner. + + Regression: #772's cache partitioner required a bearer header, breaking + every state-changing admin route for opaque-session operators after #788. + """ + server = build_server(build(), port=0, security=SecurityConfig(auth_token="secret_token")) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base = f"http://127.0.0.1:{server.server_address[1]}" + try: + status, body, headers = request_json( + f"{base}/admin/session", + "POST", + body={"token": "secret_token"}, + ) + assert status == 200 + set_cookie = headers.get("set-cookie") or headers.get("Set-Cookie") or "" + cookie_pair = set_cookie.split(";", 1)[0] + status, evaluation, _ = request_json( + f"{base}/api/v1/evaluation_runs", + "POST", + body={"prompts": ["evaluate this"]}, + headers={"cookie": cookie_pair, "origin": base}, + ) + assert status == 201 and evaluation["prompt_count"] == 1 + + status, other, other_headers = request_json( + f"{base}/admin/session", + "POST", + body={"token": "secret_token"}, + ) + other_cookie = (other_headers.get("set-cookie") or "").split(";", 1)[0] + status2, evaluation2, _ = request_json( + f"{base}/api/v1/evaluation_runs", + "POST", + body={"prompts": ["evaluate this"]}, + headers={"cookie": other_cookie, "origin": base}, + ) + assert status2 == 201 + finally: + server.shutdown() + thread.join(timeout=5) + def test_http_api_requires_bearer_token_and_hides_trace_by_default() -> None: server = build_server(build(), port=0, security=SecurityConfig(auth_token="secret_token")) thread = threading.Thread(target=server.serve_forever, daemon=True)