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:
@@ -23,7 +23,6 @@ import os
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
from typing import Any
|
||||
|
||||
from cerbero_mcp.common.auth import Principal
|
||||
from cerbero_mcp.common.logging import SecretsFilter, get_json_logger
|
||||
|
||||
try:
|
||||
@@ -67,7 +66,7 @@ def _configure_audit_sink() -> None:
|
||||
|
||||
def audit_write_op(
|
||||
*,
|
||||
principal: Principal | None,
|
||||
actor: str | None = None,
|
||||
action: str,
|
||||
exchange: str,
|
||||
target: str | None = None,
|
||||
@@ -77,8 +76,8 @@ def audit_write_op(
|
||||
) -> None:
|
||||
"""Emit a structured audit log record per write operation.
|
||||
|
||||
principal: chi ha invocato (None se anonimo, ma normalmente _check
|
||||
impedisce di arrivare qui senza principal).
|
||||
actor: identificatore di chi ha invocato (es. "testnet", "mainnet",
|
||||
oppure None per logging anonimo).
|
||||
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,7 +90,7 @@ def audit_write_op(
|
||||
"audit_event": "write_op",
|
||||
"action": action,
|
||||
"exchange": exchange,
|
||||
"principal": principal.name if principal else None,
|
||||
"actor": actor,
|
||||
"target": target,
|
||||
"payload": payload or {},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -28,8 +28,6 @@ import httpx
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from cerbero_mcp.common.auth import TokenStore
|
||||
|
||||
MCP_PROTOCOL_VERSION = "2024-11-05"
|
||||
|
||||
|
||||
@@ -95,20 +93,22 @@ def mount_mcp_endpoint(
|
||||
*,
|
||||
name: str,
|
||||
version: str,
|
||||
token_store: TokenStore,
|
||||
valid_tokens: set[str],
|
||||
internal_base_url: str,
|
||||
tools: list[dict],
|
||||
) -> None:
|
||||
"""Registra un endpoint MCP JSON-RPC 2.0 su POST /mcp.
|
||||
|
||||
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:
|
||||
app: istanza FastAPI del service
|
||||
name: nome server MCP
|
||||
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")
|
||||
tools: lista di {"name": str, "description": str, "input_schema"?: dict}
|
||||
"""
|
||||
@@ -207,8 +207,7 @@ def mount_mcp_endpoint(
|
||||
if not auth.startswith("Bearer "):
|
||||
return JSONResponse({"error": "missing bearer token"}, status_code=401)
|
||||
token = auth[len("Bearer "):].strip()
|
||||
principal = token_store.get(token)
|
||||
if principal is None:
|
||||
if token not in valid_tokens:
|
||||
return JSONResponse({"error": "invalid token"}, status_code=403)
|
||||
|
||||
body = await request.json()
|
||||
|
||||
Reference in New Issue
Block a user