refactor(V2): audit.py usa actor:str invece di Principal, rimuovi legacy common/auth.py

- Eliminato src/cerbero_mcp/common/auth.py (V1 Principal/TokenStore/ACL)
- audit_write_op: parametro principal:Principal → actor:str|None
- mcp_bridge.py: TokenStore → valid_tokens:set[str] (V2 bearer model)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AdrianoDev
2026-04-30 18:14:10 +02:00
parent 3868ba60ce
commit 1a1f9c43ba
3 changed files with 10 additions and 110 deletions
+4 -5
View File
@@ -23,7 +23,6 @@ import os
from logging.handlers import TimedRotatingFileHandler from logging.handlers import TimedRotatingFileHandler
from typing import Any from typing import Any
from cerbero_mcp.common.auth import Principal
from cerbero_mcp.common.logging import SecretsFilter, get_json_logger from cerbero_mcp.common.logging import SecretsFilter, get_json_logger
try: try:
@@ -67,7 +66,7 @@ def _configure_audit_sink() -> None:
def audit_write_op( def audit_write_op(
*, *,
principal: Principal | None, actor: str | None = None,
action: str, action: str,
exchange: str, exchange: str,
target: str | None = None, target: str | None = None,
@@ -77,8 +76,8 @@ def audit_write_op(
) -> None: ) -> None:
"""Emit a structured audit log record per write operation. """Emit a structured audit log record per write operation.
principal: chi ha invocato (None se anonimo, ma normalmente _check actor: identificatore di chi ha invocato (es. "testnet", "mainnet",
impedisce di arrivare qui senza principal). oppure None per logging anonimo).
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,7 +90,7 @@ def audit_write_op(
"audit_event": "write_op", "audit_event": "write_op",
"action": action, "action": action,
"exchange": exchange, "exchange": exchange,
"principal": principal.name if principal else None, "actor": actor,
"target": target, "target": target,
"payload": payload or {}, "payload": payload or {},
} }
-98
View File
@@ -1,98 +0,0 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from functools import wraps
from fastapi import HTTPException, Request, status
@dataclass
class Principal:
name: str
capabilities: set[str] = field(default_factory=set)
@dataclass
class TokenStore:
tokens: dict[str, Principal]
def get(self, token: str) -> Principal | None:
return self.tokens.get(token)
def require_principal(request: Request) -> Principal:
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "missing bearer token")
token = auth[len("Bearer "):].strip()
store: TokenStore = request.app.state.token_store
principal = store.get(token)
if principal is None:
raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid token")
return principal
def acl_requires(*, core: bool = False, observer: bool = False) -> Callable:
"""Decorator: require at least one matching capability."""
allowed: set[str] = set()
if core:
allowed.add("core")
if observer:
allowed.add("observer")
def decorator(func: Callable) -> Callable:
@wraps(func)
async def async_wrapper(*args, **kwargs):
principal = kwargs.get("principal")
if principal is None:
for a in args:
if isinstance(a, Principal):
principal = a
break
if principal is None or not (principal.capabilities & allowed):
raise HTTPException(
status.HTTP_403_FORBIDDEN,
f"capability required: {allowed}",
)
return await func(*args, **kwargs) if _is_coro(func) else func(*args, **kwargs)
@wraps(func)
def sync_wrapper(*args, **kwargs):
principal = kwargs.get("principal")
if principal is None:
for a in args:
if isinstance(a, Principal):
principal = a
break
if principal is None or not (principal.capabilities & allowed):
raise HTTPException(
status.HTTP_403_FORBIDDEN,
f"capability required: {allowed}",
)
return func(*args, **kwargs)
return async_wrapper if _is_coro(func) else sync_wrapper
return decorator
def _is_coro(func: Callable) -> bool:
import asyncio
return asyncio.iscoroutinefunction(func)
def load_token_store_from_files(
core_token_file: str | None,
observer_token_file: str | None,
) -> TokenStore:
tokens: dict[str, Principal] = {}
if core_token_file:
with open(core_token_file) as f:
tokens[f.read().strip()] = Principal(name="core", capabilities={"core"})
if observer_token_file:
with open(observer_token_file) as f:
tokens[f.read().strip()] = Principal(
name="observer", capabilities={"observer"}
)
return TokenStore(tokens=tokens)
+6 -7
View File
@@ -28,8 +28,6 @@ import httpx
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from cerbero_mcp.common.auth import TokenStore
MCP_PROTOCOL_VERSION = "2024-11-05" MCP_PROTOCOL_VERSION = "2024-11-05"
@@ -95,20 +93,22 @@ def mount_mcp_endpoint(
*, *,
name: str, name: str,
version: str, version: str,
token_store: TokenStore, valid_tokens: set[str],
internal_base_url: str, internal_base_url: str,
tools: list[dict], tools: list[dict],
) -> None: ) -> None:
"""Registra un endpoint MCP JSON-RPC 2.0 su POST /mcp. """Registra un endpoint MCP JSON-RPC 2.0 su POST /mcp.
Ogni tool è proxato verso POST {internal_base_url}/tools/<name> con il Ogni tool è proxato verso POST {internal_base_url}/tools/<name> con il
Bearer token del client MCP (preservando le ACL REST esistenti). Bearer token del client MCP. L'auth è già gestita dal middleware V2
(bearer testnet/mainnet); qui si ricontrolla che il token sia nei
valid_tokens prima di proxare.
Args: Args:
app: istanza FastAPI del service app: istanza FastAPI del service
name: nome server MCP name: nome server MCP
version: versione del service version: versione del service
token_store: lo stesso usato dai tool REST valid_tokens: set di token validi (testnet + mainnet)
internal_base_url: URL base interno (es. "http://localhost:9015") internal_base_url: URL base interno (es. "http://localhost:9015")
tools: lista di {"name": str, "description": str, "input_schema"?: dict} tools: lista di {"name": str, "description": str, "input_schema"?: dict}
""" """
@@ -207,8 +207,7 @@ def mount_mcp_endpoint(
if not auth.startswith("Bearer "): if not auth.startswith("Bearer "):
return JSONResponse({"error": "missing bearer token"}, status_code=401) return JSONResponse({"error": "missing bearer token"}, status_code=401)
token = auth[len("Bearer "):].strip() token = auth[len("Bearer "):].strip()
principal = token_store.get(token) if token not in valid_tokens:
if principal is None:
return JSONResponse({"error": "invalid token"}, status_code=403) return JSONResponse({"error": "invalid token"}, status_code=403)
body = await request.json() body = await request.json()