feat(V2): X-Bot-Tag header obbligatorio + endpoint /admin/audit con filtri
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ from typing import Literal, cast
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
|
||||
from cerbero_mcp import admin
|
||||
from cerbero_mcp.client_registry import ClientRegistry
|
||||
from cerbero_mcp.common.logging import configure_root_logging
|
||||
from cerbero_mcp.exchanges import build_client
|
||||
@@ -62,6 +63,7 @@ def _make_app(settings: Settings) -> FastAPI:
|
||||
app.include_router(alpaca.make_router())
|
||||
app.include_router(macro.make_router())
|
||||
app.include_router(sentiment.make_router())
|
||||
app.include_router(admin.make_admin_router())
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Endpoint admin: query audit log con filtri."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
|
||||
MAX_RECORDS = 10000
|
||||
DEFAULT_LIMIT = 1000
|
||||
|
||||
|
||||
def _parse_iso(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
# supporta sia "2026-05-01" sia "2026-05-01T12:34:56Z"
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, f"invalid datetime: {value}") from e
|
||||
|
||||
|
||||
def _record_timestamp(rec: dict[str, Any]) -> datetime | None:
|
||||
"""Estrae il timestamp da un record audit. JsonFormatter mette 'asctime'
|
||||
in formato '2026-05-01 12:34:56,789'. Lo parsiamo come UTC.
|
||||
"""
|
||||
ts = rec.get("asctime") or rec.get("timestamp")
|
||||
if not ts:
|
||||
return None
|
||||
try:
|
||||
# asctime format default: 'YYYY-MM-DD HH:MM:SS,mmm'
|
||||
ts_clean = ts.replace(",", ".")
|
||||
return datetime.fromisoformat(ts_clean)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _matches_filters(
|
||||
rec: dict[str, Any],
|
||||
*,
|
||||
from_dt: datetime | None,
|
||||
to_dt: datetime | None,
|
||||
actor: str | None,
|
||||
exchange: str | None,
|
||||
action: str | None,
|
||||
bot_tag: str | None,
|
||||
) -> bool:
|
||||
if rec.get("audit_event") != "write_op":
|
||||
return False
|
||||
if actor is not None and rec.get("actor") != actor:
|
||||
return False
|
||||
if exchange is not None and rec.get("exchange") != exchange:
|
||||
return False
|
||||
if action is not None and rec.get("action") != action:
|
||||
return False
|
||||
if bot_tag is not None and rec.get("bot_tag") != bot_tag:
|
||||
return False
|
||||
if from_dt is not None or to_dt is not None:
|
||||
rec_ts = _record_timestamp(rec)
|
||||
if rec_ts is None:
|
||||
return False
|
||||
if from_dt is not None and rec_ts < from_dt:
|
||||
return False
|
||||
if to_dt is not None and rec_ts > to_dt:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _read_audit_records(file_path: Path) -> list[dict[str, Any]]:
|
||||
if not file_path.exists():
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
with file_path.open("r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
try:
|
||||
out.append(json.loads(stripped))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def make_admin_router() -> APIRouter:
|
||||
r = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
@r.get("/audit")
|
||||
async def query_audit(
|
||||
request: Request,
|
||||
from_: str | None = Query(None, alias="from"),
|
||||
to: str | None = Query(None),
|
||||
actor: Literal["testnet", "mainnet"] | None = Query(None),
|
||||
exchange: str | None = Query(None),
|
||||
action: str | None = Query(None),
|
||||
bot_tag: str | None = Query(None),
|
||||
limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_RECORDS),
|
||||
) -> dict[str, Any]:
|
||||
"""Restituisce i record audit_write_op filtrati.
|
||||
|
||||
Param query (tutti opzionali):
|
||||
- from / to: ISO 8601 datetime (es. 2026-05-01 oppure 2026-05-01T12:34:56)
|
||||
- actor: testnet | mainnet
|
||||
- exchange: deribit | bybit | hyperliquid | alpaca
|
||||
- action: nome del tool (es. place_order)
|
||||
- bot_tag: identificatore bot
|
||||
- limit: max record da ritornare (default 1000, max 10000)
|
||||
|
||||
Source: AUDIT_LOG_FILE (env var). Se non settata, ritorna lista vuota
|
||||
con warning.
|
||||
"""
|
||||
from_dt = _parse_iso(from_)
|
||||
to_dt = _parse_iso(to)
|
||||
|
||||
file_str = os.environ.get("AUDIT_LOG_FILE", "").strip()
|
||||
if not file_str:
|
||||
return {
|
||||
"records": [],
|
||||
"count": 0,
|
||||
"warning": "AUDIT_LOG_FILE not configured; no persistent audit log to query",
|
||||
"from": from_,
|
||||
"to": to,
|
||||
}
|
||||
|
||||
file_path = Path(file_str)
|
||||
all_records = _read_audit_records(file_path)
|
||||
filtered = [
|
||||
rec for rec in all_records
|
||||
if _matches_filters(
|
||||
rec,
|
||||
from_dt=from_dt, to_dt=to_dt,
|
||||
actor=actor, exchange=exchange, action=action,
|
||||
bot_tag=bot_tag,
|
||||
)
|
||||
]
|
||||
# sort desc per timestamp (ultimi prima) + limit
|
||||
filtered.sort(
|
||||
key=lambda rec: _record_timestamp(rec) or datetime.min,
|
||||
reverse=True,
|
||||
)
|
||||
if len(filtered) > limit:
|
||||
filtered = filtered[:limit]
|
||||
|
||||
return {
|
||||
"records": filtered,
|
||||
"count": len(filtered),
|
||||
"from": from_,
|
||||
"to": to,
|
||||
"filters": {
|
||||
"actor": actor, "exchange": exchange,
|
||||
"action": action, "bot_tag": bot_tag,
|
||||
},
|
||||
}
|
||||
|
||||
return r
|
||||
+43
-4
@@ -1,4 +1,8 @@
|
||||
"""Bearer auth middleware: bearer token → request.state.environment."""
|
||||
"""Bearer auth middleware: bearer token → request.state.environment.
|
||||
|
||||
Inoltre richiede header `X-Bot-Tag` su tutte le chiamate non whitelisted,
|
||||
così che l'audit log identifichi il bot chiamante.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
@@ -9,7 +13,17 @@ from fastapi.responses import JSONResponse
|
||||
|
||||
Environment = Literal["testnet", "mainnet"]
|
||||
|
||||
WHITELIST_PATHS = frozenset({"/health", "/apidocs", "/openapi.json", "/docs", "/redoc"})
|
||||
# Path che bypassano sia bearer auth sia bot_tag check.
|
||||
PATH_WHITELIST_FULL = frozenset(
|
||||
{"/health", "/apidocs", "/openapi.json", "/docs", "/redoc"}
|
||||
)
|
||||
# Path che richiedono bearer ma NON il bot_tag (admin endpoint).
|
||||
PATH_WHITELIST_BOT_TAG_ONLY = frozenset({"/admin/audit"})
|
||||
|
||||
# Backward-compat alias (vecchi import).
|
||||
WHITELIST_PATHS = PATH_WHITELIST_FULL
|
||||
|
||||
MAX_BOT_TAG_LEN = 64
|
||||
|
||||
|
||||
def _extract_bearer(auth_header: str) -> str | None:
|
||||
@@ -35,13 +49,17 @@ def install_auth_middleware(
|
||||
testnet_token: str,
|
||||
mainnet_token: str,
|
||||
) -> None:
|
||||
"""Registra middleware di auth bearer sull'app FastAPI."""
|
||||
"""Registra middleware di auth bearer + bot_tag sull'app FastAPI."""
|
||||
|
||||
@app.middleware("http")
|
||||
async def auth_middleware(request: Request, call_next):
|
||||
if request.url.path in WHITELIST_PATHS:
|
||||
path = request.url.path
|
||||
|
||||
# 1. Whitelist totale: nessun check.
|
||||
if path in PATH_WHITELIST_FULL:
|
||||
return await call_next(request)
|
||||
|
||||
# 2. Bearer auth (sempre richiesto).
|
||||
token = _extract_bearer(request.headers.get("Authorization", ""))
|
||||
if token is None:
|
||||
return JSONResponse(
|
||||
@@ -57,4 +75,25 @@ def install_auth_middleware(
|
||||
"message": "invalid token"}},
|
||||
)
|
||||
request.state.environment = env
|
||||
|
||||
# 3. Whitelist parziale (admin): bearer ok, no bot_tag check.
|
||||
if path in PATH_WHITELIST_BOT_TAG_ONLY:
|
||||
return await call_next(request)
|
||||
|
||||
# 4. X-Bot-Tag obbligatorio.
|
||||
raw_tag = request.headers.get("X-Bot-Tag", "")
|
||||
tag = raw_tag.strip() if raw_tag else ""
|
||||
if not tag:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={"error": {"code": "BAD_REQUEST",
|
||||
"message": "missing X-Bot-Tag header"}},
|
||||
)
|
||||
if len(tag) > MAX_BOT_TAG_LEN:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={"error": {"code": "BAD_REQUEST",
|
||||
"message": "X-Bot-Tag too long"}},
|
||||
)
|
||||
request.state.bot_tag = tag
|
||||
return await call_next(request)
|
||||
|
||||
@@ -67,6 +67,7 @@ def _configure_audit_sink() -> None:
|
||||
def audit_write_op(
|
||||
*,
|
||||
actor: str | None = None,
|
||||
bot_tag: str | None = None,
|
||||
action: str,
|
||||
exchange: str,
|
||||
target: str | None = None,
|
||||
@@ -78,6 +79,7 @@ def audit_write_op(
|
||||
|
||||
actor: identificatore di chi ha invocato (es. "testnet", "mainnet",
|
||||
oppure None per logging anonimo).
|
||||
bot_tag: identificatore del bot chiamante (header X-Bot-Tag).
|
||||
action: nome del tool (es. "place_order", "cancel_order").
|
||||
exchange: identificatore servizio (deribit, bybit, alpaca, hyperliquid).
|
||||
target: instrument/symbol/order_id su cui si agisce.
|
||||
@@ -91,6 +93,7 @@ def audit_write_op(
|
||||
"action": action,
|
||||
"exchange": exchange,
|
||||
"actor": actor,
|
||||
"bot_tag": bot_tag,
|
||||
"target": target,
|
||||
"payload": payload or {},
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ async def audit_call(
|
||||
) -> Any:
|
||||
"""Esegue tool_fn e logga audit (success o error). Riraisola eccezioni."""
|
||||
actor = getattr(request.state, "environment", None)
|
||||
bot_tag = getattr(request.state, "bot_tag", None)
|
||||
target = _extract_target(params, target_field)
|
||||
payload = _safe_dump(params)
|
||||
|
||||
@@ -65,6 +66,7 @@ async def audit_call(
|
||||
except Exception as e:
|
||||
audit_write_op(
|
||||
actor=actor,
|
||||
bot_tag=bot_tag,
|
||||
action=action,
|
||||
exchange=exchange,
|
||||
target=target,
|
||||
@@ -85,6 +87,7 @@ async def audit_call(
|
||||
|
||||
audit_write_op(
|
||||
actor=actor,
|
||||
bot_tag=bot_tag,
|
||||
action=action,
|
||||
exchange=exchange,
|
||||
target=target,
|
||||
|
||||
Reference in New Issue
Block a user