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:
@@ -62,6 +62,20 @@ Le tool puramente read-only (`/mcp-macro/*` e `/mcp-sentiment/*`)
|
|||||||
richiedono comunque un bearer valido, ma il valore (testnet o mainnet) è
|
richiedono comunque un bearer valido, ma il valore (testnet o mainnet) è
|
||||||
indifferente perché non hanno endpoint testnet.
|
indifferente perché non hanno endpoint testnet.
|
||||||
|
|
||||||
|
### Header X-Bot-Tag (identificazione bot)
|
||||||
|
|
||||||
|
Tutte le chiamate a `/mcp-*` richiedono inoltre l'header `X-Bot-Tag` con
|
||||||
|
una stringa identificativa del bot chiamante (massimo 64 caratteri). Il
|
||||||
|
valore viene loggato negli audit record per tracciare quale bot ha
|
||||||
|
eseguito ogni operazione write. Esempio di richiesta:
|
||||||
|
|
||||||
|
Authorization: Bearer $MAINNET_TOKEN
|
||||||
|
X-Bot-Tag: scanner-alpha-prod
|
||||||
|
|
||||||
|
Se l'header è assente o vuoto la risposta è `400 BAD_REQUEST`. L'header
|
||||||
|
non è richiesto sugli endpoint pubblici (`/health`, `/apidocs`,
|
||||||
|
`/openapi.json`) né sull'endpoint admin `/admin/audit`.
|
||||||
|
|
||||||
## Endpoint principali
|
## Endpoint principali
|
||||||
|
|
||||||
| Path | Descrizione |
|
| Path | Descrizione |
|
||||||
@@ -75,6 +89,33 @@ indifferente perché non hanno endpoint testnet.
|
|||||||
| `POST /mcp-alpaca/tools/{tool}` | Tool exchange Alpaca |
|
| `POST /mcp-alpaca/tools/{tool}` | Tool exchange Alpaca |
|
||||||
| `POST /mcp-macro/tools/{tool}` | Tool macro/market data |
|
| `POST /mcp-macro/tools/{tool}` | Tool macro/market data |
|
||||||
| `POST /mcp-sentiment/tools/{tool}` | Tool sentiment/news |
|
| `POST /mcp-sentiment/tools/{tool}` | Tool sentiment/news |
|
||||||
|
| `GET /admin/audit` | Query dell'audit log JSONL (bearer richiesto, no X-Bot-Tag) |
|
||||||
|
|
||||||
|
## Audit query
|
||||||
|
|
||||||
|
`GET /admin/audit` legge il file JSONL puntato da `AUDIT_LOG_FILE` e
|
||||||
|
restituisce i record filtrati. Richiede un bearer valido (testnet o
|
||||||
|
mainnet); non richiede l'header `X-Bot-Tag`.
|
||||||
|
|
||||||
|
Parametri di query (tutti opzionali):
|
||||||
|
|
||||||
|
- `from`, `to`: ISO 8601 datetime (es. `2026-05-01` o `2026-05-01T12:34:56Z`)
|
||||||
|
- `actor`: `testnet` | `mainnet`
|
||||||
|
- `exchange`: nome dell'exchange (`deribit`, `bybit`, `hyperliquid`, `alpaca`)
|
||||||
|
- `action`: nome del tool (es. `place_order`)
|
||||||
|
- `bot_tag`: identificatore del bot
|
||||||
|
- `limit`: massimo record restituiti, default `1000`, massimo `10000`
|
||||||
|
|
||||||
|
Esempio di chiamata:
|
||||||
|
|
||||||
|
curl -H "Authorization: Bearer $MAINNET_TOKEN" \
|
||||||
|
"http://localhost:9000/admin/audit?from=2026-05-01&actor=mainnet&action=place_order&limit=100"
|
||||||
|
|
||||||
|
Se `AUDIT_LOG_FILE` non è configurata l'endpoint risponde `count: 0` con
|
||||||
|
un campo `warning`. Per abilitare il sink persistente impostare nel `.env`:
|
||||||
|
|
||||||
|
AUDIT_LOG_FILE=/var/log/cerbero-mcp/audit.jsonl
|
||||||
|
AUDIT_LOG_BACKUP_DAYS=30
|
||||||
|
|
||||||
## Tool disponibili
|
## Tool disponibili
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from typing import Literal, cast
|
|||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from cerbero_mcp import admin
|
||||||
from cerbero_mcp.client_registry import ClientRegistry
|
from cerbero_mcp.client_registry import ClientRegistry
|
||||||
from cerbero_mcp.common.logging import configure_root_logging
|
from cerbero_mcp.common.logging import configure_root_logging
|
||||||
from cerbero_mcp.exchanges import build_client
|
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(alpaca.make_router())
|
||||||
app.include_router(macro.make_router())
|
app.include_router(macro.make_router())
|
||||||
app.include_router(sentiment.make_router())
|
app.include_router(sentiment.make_router())
|
||||||
|
app.include_router(admin.make_admin_router())
|
||||||
|
|
||||||
return app
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
@@ -9,7 +13,17 @@ from fastapi.responses import JSONResponse
|
|||||||
|
|
||||||
Environment = Literal["testnet", "mainnet"]
|
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:
|
def _extract_bearer(auth_header: str) -> str | None:
|
||||||
@@ -35,13 +49,17 @@ def install_auth_middleware(
|
|||||||
testnet_token: str,
|
testnet_token: str,
|
||||||
mainnet_token: str,
|
mainnet_token: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Registra middleware di auth bearer sull'app FastAPI."""
|
"""Registra middleware di auth bearer + bot_tag sull'app FastAPI."""
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def auth_middleware(request: Request, call_next):
|
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)
|
return await call_next(request)
|
||||||
|
|
||||||
|
# 2. Bearer auth (sempre richiesto).
|
||||||
token = _extract_bearer(request.headers.get("Authorization", ""))
|
token = _extract_bearer(request.headers.get("Authorization", ""))
|
||||||
if token is None:
|
if token is None:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -57,4 +75,25 @@ def install_auth_middleware(
|
|||||||
"message": "invalid token"}},
|
"message": "invalid token"}},
|
||||||
)
|
)
|
||||||
request.state.environment = env
|
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)
|
return await call_next(request)
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ def _configure_audit_sink() -> None:
|
|||||||
def audit_write_op(
|
def audit_write_op(
|
||||||
*,
|
*,
|
||||||
actor: str | None = None,
|
actor: str | None = None,
|
||||||
|
bot_tag: str | None = None,
|
||||||
action: str,
|
action: str,
|
||||||
exchange: str,
|
exchange: str,
|
||||||
target: str | None = None,
|
target: str | None = None,
|
||||||
@@ -78,6 +79,7 @@ def audit_write_op(
|
|||||||
|
|
||||||
actor: identificatore di chi ha invocato (es. "testnet", "mainnet",
|
actor: identificatore di chi ha invocato (es. "testnet", "mainnet",
|
||||||
oppure None per logging anonimo).
|
oppure None per logging anonimo).
|
||||||
|
bot_tag: identificatore del bot chiamante (header X-Bot-Tag).
|
||||||
action: nome del tool (es. "place_order", "cancel_order").
|
action: nome del tool (es. "place_order", "cancel_order").
|
||||||
exchange: identificatore servizio (deribit, bybit, alpaca, hyperliquid).
|
exchange: identificatore servizio (deribit, bybit, alpaca, hyperliquid).
|
||||||
target: instrument/symbol/order_id su cui si agisce.
|
target: instrument/symbol/order_id su cui si agisce.
|
||||||
@@ -91,6 +93,7 @@ def audit_write_op(
|
|||||||
"action": action,
|
"action": action,
|
||||||
"exchange": exchange,
|
"exchange": exchange,
|
||||||
"actor": actor,
|
"actor": actor,
|
||||||
|
"bot_tag": bot_tag,
|
||||||
"target": target,
|
"target": target,
|
||||||
"payload": payload or {},
|
"payload": payload or {},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ async def audit_call(
|
|||||||
) -> Any:
|
) -> Any:
|
||||||
"""Esegue tool_fn e logga audit (success o error). Riraisola eccezioni."""
|
"""Esegue tool_fn e logga audit (success o error). Riraisola eccezioni."""
|
||||||
actor = getattr(request.state, "environment", None)
|
actor = getattr(request.state, "environment", None)
|
||||||
|
bot_tag = getattr(request.state, "bot_tag", None)
|
||||||
target = _extract_target(params, target_field)
|
target = _extract_target(params, target_field)
|
||||||
payload = _safe_dump(params)
|
payload = _safe_dump(params)
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@ async def audit_call(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
audit_write_op(
|
audit_write_op(
|
||||||
actor=actor,
|
actor=actor,
|
||||||
|
bot_tag=bot_tag,
|
||||||
action=action,
|
action=action,
|
||||||
exchange=exchange,
|
exchange=exchange,
|
||||||
target=target,
|
target=target,
|
||||||
@@ -85,6 +87,7 @@ async def audit_call(
|
|||||||
|
|
||||||
audit_write_op(
|
audit_write_op(
|
||||||
actor=actor,
|
actor=actor,
|
||||||
|
bot_tag=bot_tag,
|
||||||
action=action,
|
action=action,
|
||||||
exchange=exchange,
|
exchange=exchange,
|
||||||
target=target,
|
target=target,
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ def app(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def _bearer_test():
|
def _bearer_test():
|
||||||
return {"Authorization": "Bearer t_test_123"}
|
return {"Authorization": "Bearer t_test_123", "X-Bot-Tag": "test-bot"}
|
||||||
|
|
||||||
|
|
||||||
def _bearer_live():
|
def _bearer_live():
|
||||||
return {"Authorization": "Bearer t_live_456"}
|
return {"Authorization": "Bearer t_live_456", "X-Bot-Tag": "test-bot"}
|
||||||
|
|
||||||
|
|
||||||
# ── Spy helpers ──────────────────────────────────────────────────────────────
|
# ── Spy helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -101,3 +101,67 @@ async def test_audit_call_no_params_no_target():
|
|||||||
tool_fn=tool_fn,
|
tool_fn=tool_fn,
|
||||||
)
|
)
|
||||||
assert result == {"ok": True}
|
assert result == {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_audit_call_propagates_bot_tag(monkeypatch):
|
||||||
|
"""bot_tag letto da request.state e propagato a audit_write_op."""
|
||||||
|
from cerbero_mcp.common.audit_helpers import audit_call
|
||||||
|
|
||||||
|
logged = []
|
||||||
|
|
||||||
|
def fake_audit(**kw):
|
||||||
|
logged.append(kw)
|
||||||
|
|
||||||
|
monkeypatch.setattr("cerbero_mcp.common.audit_helpers.audit_write_op", fake_audit)
|
||||||
|
|
||||||
|
class FakeRequest:
|
||||||
|
class _State:
|
||||||
|
environment = "testnet"
|
||||||
|
bot_tag = "scanner-alpha"
|
||||||
|
state = _State()
|
||||||
|
|
||||||
|
async def tool_fn():
|
||||||
|
return {"order_id": "abc"}
|
||||||
|
|
||||||
|
await audit_call(
|
||||||
|
request=FakeRequest(), # type: ignore[arg-type]
|
||||||
|
exchange="deribit",
|
||||||
|
action="place_order",
|
||||||
|
target_field="instrument_name",
|
||||||
|
params=FakeReq(instrument_name="BTC-PERPETUAL", qty=0.1),
|
||||||
|
tool_fn=tool_fn,
|
||||||
|
)
|
||||||
|
assert len(logged) == 1
|
||||||
|
assert logged[0]["bot_tag"] == "scanner-alpha"
|
||||||
|
assert logged[0]["actor"] == "testnet"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_audit_call_bot_tag_none_when_missing(monkeypatch):
|
||||||
|
"""Se request.state.bot_tag non esiste, audit riceve None senza errore."""
|
||||||
|
from cerbero_mcp.common.audit_helpers import audit_call
|
||||||
|
|
||||||
|
logged = []
|
||||||
|
|
||||||
|
def fake_audit(**kw):
|
||||||
|
logged.append(kw)
|
||||||
|
|
||||||
|
monkeypatch.setattr("cerbero_mcp.common.audit_helpers.audit_write_op", fake_audit)
|
||||||
|
|
||||||
|
class FakeRequest:
|
||||||
|
class _State:
|
||||||
|
environment = "testnet"
|
||||||
|
state = _State()
|
||||||
|
|
||||||
|
async def tool_fn():
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
await audit_call(
|
||||||
|
request=FakeRequest(), # type: ignore[arg-type]
|
||||||
|
exchange="bybit",
|
||||||
|
action="cancel_all_orders",
|
||||||
|
tool_fn=tool_fn,
|
||||||
|
)
|
||||||
|
assert len(logged) == 1
|
||||||
|
assert logged[0]["bot_tag"] is None
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmp_audit_file(tmp_path, monkeypatch):
|
||||||
|
file_path = tmp_path / "audit.jsonl"
|
||||||
|
monkeypatch.setenv("AUDIT_LOG_FILE", str(file_path))
|
||||||
|
return file_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app(monkeypatch, tmp_audit_file):
|
||||||
|
from tests.unit.test_settings import _minimal_env
|
||||||
|
for k, v in _minimal_env().items():
|
||||||
|
monkeypatch.setenv(k, v)
|
||||||
|
from cerbero_mcp.__main__ import _make_app
|
||||||
|
from cerbero_mcp.settings import Settings
|
||||||
|
return _make_app(Settings())
|
||||||
|
|
||||||
|
|
||||||
|
def _write_records(file_path: Path, records: list[dict]) -> None:
|
||||||
|
with file_path.open("w") as f:
|
||||||
|
for r in records:
|
||||||
|
f.write(json.dumps(r) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _bearer_test():
|
||||||
|
return {"Authorization": "Bearer t_test_123"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_audit_no_file(app):
|
||||||
|
"""Senza AUDIT_LOG_FILE settato, ritorna empty + warning."""
|
||||||
|
import os
|
||||||
|
os.environ.pop("AUDIT_LOG_FILE", None)
|
||||||
|
c = TestClient(app)
|
||||||
|
r = c.get("/admin/audit", headers=_bearer_test())
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["count"] == 0
|
||||||
|
assert "warning" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_audit_no_bearer_returns_401(app):
|
||||||
|
c = TestClient(app)
|
||||||
|
r = c.get("/admin/audit")
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_audit_no_bot_tag_required(app, tmp_audit_file):
|
||||||
|
"""Endpoint admin NON richiede X-Bot-Tag (solo bearer)."""
|
||||||
|
_write_records(tmp_audit_file, [])
|
||||||
|
c = TestClient(app)
|
||||||
|
r = c.get("/admin/audit", headers=_bearer_test())
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_audit_returns_records(app, tmp_audit_file):
|
||||||
|
records = [
|
||||||
|
{
|
||||||
|
"audit_event": "write_op",
|
||||||
|
"asctime": "2026-05-01 10:00:00,000",
|
||||||
|
"actor": "testnet", "bot_tag": "alpha",
|
||||||
|
"exchange": "deribit", "action": "place_order",
|
||||||
|
"target": "BTC-PERPETUAL",
|
||||||
|
"payload": {"qty": 0.1},
|
||||||
|
"result": {"order_id": "abc"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"audit_event": "write_op",
|
||||||
|
"asctime": "2026-05-01 11:00:00,000",
|
||||||
|
"actor": "mainnet", "bot_tag": "beta",
|
||||||
|
"exchange": "bybit", "action": "cancel_order",
|
||||||
|
"target": "ord-1",
|
||||||
|
"payload": {},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
_write_records(tmp_audit_file, records)
|
||||||
|
c = TestClient(app)
|
||||||
|
r = c.get("/admin/audit", headers=_bearer_test())
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_audit_filter_by_actor(app, tmp_audit_file):
|
||||||
|
records = [
|
||||||
|
{"audit_event": "write_op", "asctime": "2026-05-01 10:00:00,000",
|
||||||
|
"actor": "testnet", "bot_tag": "a", "exchange": "deribit", "action": "place_order"},
|
||||||
|
{"audit_event": "write_op", "asctime": "2026-05-01 11:00:00,000",
|
||||||
|
"actor": "mainnet", "bot_tag": "b", "exchange": "bybit", "action": "place_order"},
|
||||||
|
]
|
||||||
|
_write_records(tmp_audit_file, records)
|
||||||
|
c = TestClient(app)
|
||||||
|
r = c.get("/admin/audit?actor=mainnet", headers=_bearer_test())
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["count"] == 1
|
||||||
|
assert body["records"][0]["actor"] == "mainnet"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_audit_filter_by_date_range(app, tmp_audit_file):
|
||||||
|
records = [
|
||||||
|
{"audit_event": "write_op", "asctime": "2026-04-30 10:00:00,000",
|
||||||
|
"actor": "testnet", "exchange": "deribit", "action": "place_order"},
|
||||||
|
{"audit_event": "write_op", "asctime": "2026-05-01 10:00:00,000",
|
||||||
|
"actor": "testnet", "exchange": "deribit", "action": "place_order"},
|
||||||
|
{"audit_event": "write_op", "asctime": "2026-05-02 10:00:00,000",
|
||||||
|
"actor": "testnet", "exchange": "deribit", "action": "place_order"},
|
||||||
|
]
|
||||||
|
_write_records(tmp_audit_file, records)
|
||||||
|
c = TestClient(app)
|
||||||
|
r = c.get("/admin/audit?from=2026-05-01&to=2026-05-01T23:59:59", headers=_bearer_test())
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_audit_filter_by_bot_tag(app, tmp_audit_file):
|
||||||
|
records = [
|
||||||
|
{"audit_event": "write_op", "asctime": "2026-05-01 10:00:00,000",
|
||||||
|
"actor": "testnet", "bot_tag": "alpha", "exchange": "deribit", "action": "place_order"},
|
||||||
|
{"audit_event": "write_op", "asctime": "2026-05-01 11:00:00,000",
|
||||||
|
"actor": "testnet", "bot_tag": "beta", "exchange": "deribit", "action": "place_order"},
|
||||||
|
]
|
||||||
|
_write_records(tmp_audit_file, records)
|
||||||
|
c = TestClient(app)
|
||||||
|
r = c.get("/admin/audit?bot_tag=alpha", headers=_bearer_test())
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["count"] == 1
|
||||||
|
assert r.json()["records"][0]["bot_tag"] == "alpha"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_audit_invalid_date(app, tmp_audit_file):
|
||||||
|
_write_records(tmp_audit_file, [])
|
||||||
|
c = TestClient(app)
|
||||||
|
r = c.get("/admin/audit?from=not-a-date", headers=_bearer_test())
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_audit_limit(app, tmp_audit_file):
|
||||||
|
records = [
|
||||||
|
{"audit_event": "write_op", "asctime": f"2026-05-01 10:{i:02d}:00,000",
|
||||||
|
"actor": "testnet", "exchange": "deribit", "action": "place_order"}
|
||||||
|
for i in range(50)
|
||||||
|
]
|
||||||
|
_write_records(tmp_audit_file, records)
|
||||||
|
c = TestClient(app)
|
||||||
|
r = c.get("/admin/audit?limit=10", headers=_bearer_test())
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["count"] == 10
|
||||||
+78
-2
@@ -67,7 +67,10 @@ def test_testnet_token_sets_env_testnet():
|
|||||||
return {"env": request.state.environment}
|
return {"env": request.state.environment}
|
||||||
|
|
||||||
c = TestClient(fa)
|
c = TestClient(fa)
|
||||||
r = c.get("/mcp-deribit/peek", headers={"Authorization": "Bearer tk_test"})
|
r = c.get(
|
||||||
|
"/mcp-deribit/peek",
|
||||||
|
headers={"Authorization": "Bearer tk_test", "X-Bot-Tag": "test-bot"},
|
||||||
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json() == {"env": "testnet"}
|
assert r.json() == {"env": "testnet"}
|
||||||
|
|
||||||
@@ -83,7 +86,10 @@ def test_mainnet_token_sets_env_mainnet():
|
|||||||
return {"env": request.state.environment}
|
return {"env": request.state.environment}
|
||||||
|
|
||||||
c = TestClient(fa)
|
c = TestClient(fa)
|
||||||
r = c.get("/mcp-deribit/peek", headers={"Authorization": "Bearer tk_live"})
|
r = c.get(
|
||||||
|
"/mcp-deribit/peek",
|
||||||
|
headers={"Authorization": "Bearer tk_live", "X-Bot-Tag": "test-bot"},
|
||||||
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json() == {"env": "mainnet"}
|
assert r.json() == {"env": "mainnet"}
|
||||||
|
|
||||||
@@ -96,3 +102,73 @@ def test_uses_compare_digest():
|
|||||||
|
|
||||||
src = inspect.getsource(auth)
|
src = inspect.getsource(auth)
|
||||||
assert "compare_digest" in src, "auth.py deve usare secrets.compare_digest"
|
assert "compare_digest" in src, "auth.py deve usare secrets.compare_digest"
|
||||||
|
|
||||||
|
|
||||||
|
# ── X-Bot-Tag header ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_missing_bot_tag_returns_400():
|
||||||
|
from cerbero_mcp.auth import install_auth_middleware
|
||||||
|
fa = FastAPI()
|
||||||
|
install_auth_middleware(fa, testnet_token="t", mainnet_token="m")
|
||||||
|
|
||||||
|
@fa.get("/mcp-deribit/health")
|
||||||
|
def h():
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
c = TestClient(fa)
|
||||||
|
r = c.get("/mcp-deribit/health", headers={"Authorization": "Bearer t"})
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "X-Bot-Tag" in r.json()["error"]["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bot_tag_accepted_and_set_on_state():
|
||||||
|
from cerbero_mcp.auth import install_auth_middleware
|
||||||
|
fa = FastAPI()
|
||||||
|
install_auth_middleware(fa, testnet_token="t", mainnet_token="m")
|
||||||
|
|
||||||
|
@fa.get("/mcp-deribit/peek")
|
||||||
|
def peek(request: Request):
|
||||||
|
return {
|
||||||
|
"env": request.state.environment,
|
||||||
|
"bot_tag": request.state.bot_tag,
|
||||||
|
}
|
||||||
|
|
||||||
|
c = TestClient(fa)
|
||||||
|
r = c.get(
|
||||||
|
"/mcp-deribit/peek",
|
||||||
|
headers={"Authorization": "Bearer t", "X-Bot-Tag": "scanner-alpha"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"env": "testnet", "bot_tag": "scanner-alpha"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_bot_tag_too_long_returns_400():
|
||||||
|
from cerbero_mcp.auth import install_auth_middleware
|
||||||
|
fa = FastAPI()
|
||||||
|
install_auth_middleware(fa, testnet_token="t", mainnet_token="m")
|
||||||
|
|
||||||
|
@fa.get("/mcp-deribit/health")
|
||||||
|
def h():
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
c = TestClient(fa)
|
||||||
|
r = c.get(
|
||||||
|
"/mcp-deribit/health",
|
||||||
|
headers={"Authorization": "Bearer t", "X-Bot-Tag": "x" * 65},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_bot_tag_not_required_on_health():
|
||||||
|
"""Health endpoint deve restare senza auth e senza bot tag."""
|
||||||
|
from cerbero_mcp.auth import install_auth_middleware
|
||||||
|
fa = FastAPI()
|
||||||
|
install_auth_middleware(fa, testnet_token="t", mainnet_token="m")
|
||||||
|
|
||||||
|
@fa.get("/health")
|
||||||
|
def h():
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
c = TestClient(fa)
|
||||||
|
r = c.get("/health")
|
||||||
|
assert r.status_code == 200
|
||||||
|
|||||||
Reference in New Issue
Block a user