From e2c2333b6fe80db067f15003a97191f85f917dd7 Mon Sep 17 00:00:00 2001 From: Apple Date: Mon, 2 Mar 2026 09:42:10 -0800 Subject: [PATCH] feat(sofiia-console): protect audit endpoint with admin token Made-with: Cursor --- services/sofiia-console/app/auth.py | 120 ++++++++++++++++++++++++++++ services/sofiia-console/app/main.py | 4 +- tests/test_sofiia_audit_auth.py | 12 +++ tests/test_sofiia_audit_read.py | 14 ++-- 4 files changed, 143 insertions(+), 7 deletions(-) create mode 100644 services/sofiia-console/app/auth.py create mode 100644 tests/test_sofiia_audit_auth.py diff --git a/services/sofiia-console/app/auth.py b/services/sofiia-console/app/auth.py new file mode 100644 index 00000000..9dec1a52 --- /dev/null +++ b/services/sofiia-console/app/auth.py @@ -0,0 +1,120 @@ +""" +Auth for Sofiia Console. + +Strategy: cookie-based session (httpOnly, Secure, SameSite=Lax). +Login: POST /api/auth/login { "key": "..." } → sets console_token cookie +Logout: POST /api/auth/logout → clears cookie + +All protected endpoints check: + 1. Cookie "console_token" (browser sessions) + 2. X-API-Key header (backward compat: curl, API clients) + +Dev mode (ENV != prod, no key configured): open access. +""" +import hashlib +import logging +import os +import secrets + +from fastapi import Cookie, HTTPException, Request, Security +from fastapi.security import APIKeyHeader + +logger = logging.getLogger(__name__) + +_IS_PROD = os.getenv("ENV", "dev").strip().lower() in ("prod", "production", "staging") +_COOKIE_NAME = "console_token" +_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 # 7 days + +API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False) + + +def get_console_api_key() -> str: + return os.getenv("SOFIIA_CONSOLE_API_KEY", "").strip() + + +def _key_valid(provided: str) -> bool: + configured = get_console_api_key() + if not configured: + return True # no key set → open + return secrets.compare_digest(provided.strip(), configured) + + +def _cookie_token(api_key: str) -> str: + """Derive a stable session token from the api key (so we never store key directly in cookie).""" + return hashlib.sha256(api_key.encode()).hexdigest() + + +def _expected_cookie_token() -> str: + return _cookie_token(get_console_api_key()) + + +def require_auth( + request: Request, + x_api_key: str = Security(API_KEY_HEADER), +) -> str: + """ + Check cookie OR X-API-Key header. + Localhost (127.0.0.1 / ::1) is ALWAYS allowed — no key needed. + In dev mode without a configured key: pass through. + """ + # Localhost bypass — always open for local development + client_ip = (request.client.host if request.client else "") or "" + if client_ip in ("127.0.0.1", "::1", "localhost"): + return "localhost" + + configured = get_console_api_key() + if not configured: + if _IS_PROD: + logger.warning("SOFIIA_CONSOLE_API_KEY not set in prod — console is OPEN") + return "" + + # 1) Cookie check + cookie_val = request.cookies.get(_COOKIE_NAME, "") + if cookie_val and secrets.compare_digest(cookie_val, _expected_cookie_token()): + return "cookie" + + # 2) X-API-Key header (for API clients / curl) + if x_api_key and _key_valid(x_api_key): + return "header" + + raise HTTPException(status_code=401, detail="Unauthorized") + + +def require_auth_strict( + request: Request, + x_api_key: str = Security(API_KEY_HEADER), +) -> str: + """Like require_auth but always fails if no key is configured.""" + configured = get_console_api_key() + if not configured: + raise HTTPException(status_code=503, detail="SOFIIA_CONSOLE_API_KEY not configured") + return require_auth(request, x_api_key) + + +def require_audit_auth( + request: Request, + x_api_key: str = Security(API_KEY_HEADER), +) -> str: + """ + Strict auth for sensitive audit endpoint: + - Requires configured SOFIIA_CONSOLE_API_KEY + - Does NOT allow localhost bypass + - Accepts cookie or X-API-Key when key is configured + """ + configured = get_console_api_key() + if not configured: + raise HTTPException(status_code=401, detail="Audit endpoint requires SOFIIA_CONSOLE_API_KEY") + + cookie_val = request.cookies.get(_COOKIE_NAME, "") + if cookie_val and secrets.compare_digest(cookie_val, _expected_cookie_token()): + return "cookie" + + if x_api_key and _key_valid(x_api_key): + return "header" + + raise HTTPException(status_code=401, detail="Unauthorized") + + +# Keep old names as aliases so existing imports don't break +require_api_key = require_auth +require_api_key_strict = require_auth_strict diff --git a/services/sofiia-console/app/main.py b/services/sofiia-console/app/main.py index 4908084b..b9507d8d 100644 --- a/services/sofiia-console/app/main.py +++ b/services/sofiia-console/app/main.py @@ -35,7 +35,7 @@ except Exception: # pragma: no cover - optional dependency in console env cv2 = None from .auth import ( - require_api_key, require_api_key_strict, require_auth, require_auth_strict, + require_api_key, require_api_key_strict, require_auth, require_auth_strict, require_audit_auth, get_console_api_key, _key_valid, _cookie_token, _expected_cookie_token, _COOKIE_NAME, _COOKIE_MAX_AGE, _IS_PROD, ) @@ -3728,7 +3728,7 @@ async def api_audit_list( node_id: Optional[str] = Query(None), limit: int = Query(50, ge=1, le=200), cursor: Optional[str] = Query(None), - _auth: str = Depends(require_auth), + _auth: str = Depends(require_audit_auth), ): SOFIIA_CURSOR_REQUESTS_TOTAL.labels(resource="audit").inc() cur = _cursor_decode(cursor) diff --git a/tests/test_sofiia_audit_auth.py b/tests/test_sofiia_audit_auth.py new file mode 100644 index 00000000..ae220155 --- /dev/null +++ b/tests/test_sofiia_audit_auth.py @@ -0,0 +1,12 @@ +from __future__ import annotations + + +def test_audit_requires_key_when_not_configured(sofiia_client): + r = sofiia_client.get("/api/audit") + assert r.status_code == 401, r.text + + +def test_audit_accepts_valid_x_api_key_when_configured(sofiia_client, monkeypatch): + monkeypatch.setenv("SOFIIA_CONSOLE_API_KEY", "audit-secret") + r = sofiia_client.get("/api/audit", headers={"X-API-Key": "audit-secret"}) + assert r.status_code == 200, r.text diff --git a/tests/test_sofiia_audit_read.py b/tests/test_sofiia_audit_read.py index b37ded0c..0f9a439b 100644 --- a/tests/test_sofiia_audit_read.py +++ b/tests/test_sofiia_audit_read.py @@ -19,19 +19,21 @@ def _append(event: str, *, chat_id: str, operator_id: str = "op-a", status: str ) -def test_audit_read_cursor_pagination(sofiia_client): +def test_audit_read_cursor_pagination(sofiia_client, monkeypatch): + monkeypatch.setenv("SOFIIA_CONSOLE_API_KEY", "audit-secret") + headers = {"X-API-Key": "audit-secret"} chat_id = "chat:NODA2:sofiia:web:audit-read" for i in range(5): _append("chat.send.result", chat_id=chat_id, operator_id=f"op-{i}") - r1 = sofiia_client.get(f"/api/audit?chat_id={chat_id}&limit=2") + r1 = sofiia_client.get(f"/api/audit?chat_id={chat_id}&limit=2", headers=headers) assert r1.status_code == 200, r1.text j1 = r1.json() assert len(j1["items"]) == 2 assert j1["has_more"] is True assert j1["next_cursor"] - r2 = sofiia_client.get(f"/api/audit?chat_id={chat_id}&limit=2&cursor={j1['next_cursor']}") + r2 = sofiia_client.get(f"/api/audit?chat_id={chat_id}&limit=2&cursor={j1['next_cursor']}", headers=headers) assert r2.status_code == 200, r2.text j2 = r2.json() assert len(j2["items"]) >= 1 @@ -40,13 +42,15 @@ def test_audit_read_cursor_pagination(sofiia_client): assert ids_1.isdisjoint(ids_2) -def test_audit_read_filter_by_event(sofiia_client): +def test_audit_read_filter_by_event(sofiia_client, monkeypatch): + monkeypatch.setenv("SOFIIA_CONSOLE_API_KEY", "audit-secret") + headers = {"X-API-Key": "audit-secret"} chat_id = "chat:NODA2:sofiia:web:audit-filter" _append("chat.send.error", chat_id=chat_id, status="error") _append("chat.send.result", chat_id=chat_id, status="ok") _append("chat.create", chat_id=chat_id, status="ok") - r = sofiia_client.get("/api/audit?event=chat.send.error&limit=50") + r = sofiia_client.get("/api/audit?event=chat.send.error&limit=50", headers=headers) assert r.status_code == 200, r.text items = r.json()["items"] assert items, "Expected at least one audit item for event filter"