feat(sofiia-console): protect audit endpoint with admin token
Made-with: Cursor
This commit is contained in:
120
services/sofiia-console/app/auth.py
Normal file
120
services/sofiia-console/app/auth.py
Normal file
@@ -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
|
||||
@@ -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)
|
||||
|
||||
12
tests/test_sofiia_audit_auth.py
Normal file
12
tests/test_sofiia_audit_auth.py
Normal file
@@ -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
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user