v1.0 - Version stable: multi-PC, détection UI-DETR-1, 3 modes exécution
- Frontend v4 accessible sur réseau local (192.168.1.40) - Ports ouverts: 3002 (frontend), 5001 (backend), 5004 (dashboard) - Ollama GPU fonctionnel - Self-healing interactif - Dashboard confiance Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
272
core/security/fastapi_security.py
Normal file
272
core/security/fastapi_security.py
Normal file
@@ -0,0 +1,272 @@
|
||||
"""core/security/fastapi_security.py
|
||||
|
||||
Fiche #23 - Middleware FastAPI (auth + allowlist + rate limit + audit + kill-switch + demo-safe)
|
||||
Fiche #24 - Observabilité (métriques HTTP + request id)
|
||||
|
||||
Intégration:
|
||||
from core.security.fastapi_security import install_security_middlewares
|
||||
install_security_middlewares(app)
|
||||
|
||||
Variables d'environnement:
|
||||
- RPA_AUTH_REQUIRED / RPA_ADMIN_TOKEN / RPA_READ_TOKEN
|
||||
- RPA_IP_ALLOWLIST / RPA_IP_TRUST_PROXY
|
||||
- RPA_RL_RPS / RPA_RL_BURST
|
||||
- RPA_AUDIT_DIR / RPA_AUDIT_ENABLED
|
||||
- DEMO_SAFE / RPA_KILL_SWITCH (ou RPA_KILL_SWITCH_FILE)
|
||||
- RPA_SERVICE_NAME (labels Prometheus)
|
||||
|
||||
Notes:
|
||||
- /metrics, /healthz, / sont publics pour ne pas casser systemd/Prometheus.
|
||||
- Les métriques sont enregistrées aussi pour les requêtes bloquées (401/403/423/429)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .api_tokens import (
|
||||
TokenRole,
|
||||
auth_required,
|
||||
can_read,
|
||||
can_write,
|
||||
classify_request_simple as classify_request,
|
||||
)
|
||||
from .ip_allowlist import IPAllowlist
|
||||
from .rate_limiter import RateLimiter
|
||||
from .audit_log import AuditLogger
|
||||
from core.system.safety_switch import demo_safe_enabled, kill_switch_enabled
|
||||
|
||||
# Fiche #24 - métriques HTTP
|
||||
from core.monitoring.http_server_metrics import (
|
||||
record_http_request,
|
||||
in_flight_inc,
|
||||
in_flight_dec,
|
||||
record_security_block,
|
||||
safe_template,
|
||||
)
|
||||
|
||||
DEFAULT_PUBLIC_PATHS = {
|
||||
"/healthz",
|
||||
"/metrics",
|
||||
"/",
|
||||
"/docs",
|
||||
"/redoc",
|
||||
"/openapi.json",
|
||||
"/api/traces/debug-auth", # Debug endpoint
|
||||
"/api/traces/debug-env", # Debug endpoint
|
||||
}
|
||||
|
||||
|
||||
def _client_ip_from_request(request: Request, trust_proxy: bool) -> str:
|
||||
"""Extrait l'IP client en tenant compte des proxies."""
|
||||
if trust_proxy:
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def _is_public(request: Request) -> bool:
|
||||
"""Vérifie si l'endpoint est public (pas d'auth/RL)."""
|
||||
path = request.url.path
|
||||
if path in DEFAULT_PUBLIC_PATHS:
|
||||
return True
|
||||
# Exact match uniquement pour éviter les bypasses
|
||||
return False
|
||||
|
||||
|
||||
def _route_template(request: Request) -> str:
|
||||
route = request.scope.get("route")
|
||||
template = getattr(route, "path", None)
|
||||
return safe_template(template, request.url.path)
|
||||
|
||||
|
||||
def _ensure_request_id(request: Request) -> str:
|
||||
# Si un reverse proxy injecte déjà un ID, on le conserve.
|
||||
rid = request.headers.get("x-request-id")
|
||||
if rid:
|
||||
return rid.strip()
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
def install_security_middlewares(app) -> None:
|
||||
"""Installe le middleware de sécurité sur une app FastAPI."""
|
||||
allowlist = IPAllowlist.from_env()
|
||||
limiter = RateLimiter.from_env()
|
||||
audit = AuditLogger.from_env()
|
||||
|
||||
service_name = os.getenv("RPA_SERVICE_NAME", "rpa-vision-v3-api")
|
||||
|
||||
@app.middleware("http")
|
||||
async def _security_middleware(request: Request, call_next: Callable):
|
||||
path = request.url.path
|
||||
method = request.method.upper()
|
||||
path_tpl = _route_template(request)
|
||||
|
||||
request_id = _ensure_request_id(request)
|
||||
# exposé aux handlers applicatifs si besoin
|
||||
request.state.request_id = request_id
|
||||
|
||||
t0 = time.monotonic()
|
||||
in_flight_inc(service_name)
|
||||
|
||||
def _finalize(resp: JSONResponse) -> JSONResponse:
|
||||
# attache toujours le request id
|
||||
try:
|
||||
resp.headers["X-Request-Id"] = request_id
|
||||
except Exception:
|
||||
pass
|
||||
duration = time.monotonic() - t0
|
||||
status = getattr(resp, "status_code", 0) or 0
|
||||
record_http_request(service_name, method, path_tpl, status, duration)
|
||||
in_flight_dec(service_name)
|
||||
return resp
|
||||
|
||||
# --- IP allowlist (early) ---
|
||||
client_ip = _client_ip_from_request(request, allowlist.trust_proxy)
|
||||
if allowlist.enabled and not allowlist.is_allowed(client_ip):
|
||||
audit.write({
|
||||
"event": "ip_block",
|
||||
"ip": client_ip,
|
||||
"method": method,
|
||||
"path": path,
|
||||
"request_id": request_id,
|
||||
})
|
||||
record_security_block(service_name, "ip_block")
|
||||
return _finalize(JSONResponse(status_code=403, content={"error": "forbidden"}))
|
||||
|
||||
# Public endpoints (health/metrics) : pas d'auth, pas de RL (pour ne pas casser systemd)
|
||||
if _is_public(request):
|
||||
try:
|
||||
response = await call_next(request)
|
||||
response.headers["X-Request-Id"] = request_id
|
||||
return _finalize(response)
|
||||
except Exception:
|
||||
# call_next a pu lever: on compte 500
|
||||
record_http_request(service_name, method, path_tpl, 500, time.monotonic() - t0)
|
||||
in_flight_dec(service_name)
|
||||
raise
|
||||
|
||||
# --- Auth ---
|
||||
ctx, source = classify_request(
|
||||
headers=dict(request.headers),
|
||||
cookies=dict(request.cookies),
|
||||
query_params=dict(request.query_params),
|
||||
)
|
||||
|
||||
if auth_required():
|
||||
if ctx.role == TokenRole.ANON:
|
||||
audit.write({
|
||||
"event": "auth_fail",
|
||||
"ip": client_ip,
|
||||
"method": method,
|
||||
"path": path,
|
||||
"source": source,
|
||||
"token_present": ctx.token_present,
|
||||
"request_id": request_id,
|
||||
})
|
||||
record_security_block(service_name, "auth_fail")
|
||||
return _finalize(JSONResponse(status_code=401, content={"error": "unauthorized"}))
|
||||
|
||||
# --- Kill-switch / DEMO_SAFE ---
|
||||
if kill_switch_enabled():
|
||||
# Bloque tout sauf admin/security (permet de désactiver)
|
||||
if not path.startswith("/admin/security"):
|
||||
audit.write({
|
||||
"event": "killswitch_block",
|
||||
"ip": client_ip,
|
||||
"method": method,
|
||||
"path": path,
|
||||
"role": ctx.role,
|
||||
"request_id": request_id,
|
||||
})
|
||||
record_security_block(service_name, "killswitch")
|
||||
return _finalize(JSONResponse(status_code=423, content={"error": "killswitch_enabled"}))
|
||||
|
||||
if demo_safe_enabled():
|
||||
# En mode démo : pas d'écriture, et on bloque explicitement l'admin.
|
||||
if path.startswith("/admin/"):
|
||||
# Exception: autorise GET /admin/security/status
|
||||
if not (path == "/admin/security/status" and method == "GET"):
|
||||
audit.write({
|
||||
"event": "demo_safe_block",
|
||||
"ip": client_ip,
|
||||
"method": method,
|
||||
"path": path,
|
||||
"role": ctx.role,
|
||||
"request_id": request_id,
|
||||
})
|
||||
record_security_block(service_name, "demo_safe")
|
||||
return _finalize(JSONResponse(status_code=423, content={"error": "demo_safe"}))
|
||||
|
||||
if not _is_read_only_method(method):
|
||||
audit.write({
|
||||
"event": "demo_safe_block",
|
||||
"ip": client_ip,
|
||||
"method": method,
|
||||
"path": path,
|
||||
"role": ctx.role,
|
||||
"request_id": request_id,
|
||||
})
|
||||
record_security_block(service_name, "demo_safe")
|
||||
return _finalize(JSONResponse(status_code=423, content={"error": "demo_safe"}))
|
||||
|
||||
# --- RBAC simple ---
|
||||
if _is_read_only_method(method):
|
||||
if auth_required() and not can_read(ctx.role):
|
||||
record_security_block(service_name, "forbidden_read")
|
||||
return _finalize(JSONResponse(status_code=403, content={"error": "forbidden"}))
|
||||
else:
|
||||
if auth_required() and not can_write(ctx.role):
|
||||
record_security_block(service_name, "forbidden_write")
|
||||
return _finalize(JSONResponse(status_code=403, content={"error": "forbidden"}))
|
||||
|
||||
# --- Rate limit ---
|
||||
# Key = token (si valide) sinon IP
|
||||
rl_key = ctx.token_hash if ctx.token_hash else client_ip
|
||||
allowed, retry_after = limiter.check(rl_key)
|
||||
if not allowed:
|
||||
audit.write({
|
||||
"event": "rate_limit",
|
||||
"ip": client_ip,
|
||||
"method": method,
|
||||
"path": path,
|
||||
"role": ctx.role,
|
||||
"retry_after_s": retry_after,
|
||||
"request_id": request_id,
|
||||
})
|
||||
record_security_block(service_name, "rate_limit")
|
||||
resp = JSONResponse(status_code=429, content={"error": "rate_limited"})
|
||||
resp.headers["Retry-After"] = str(int(max(1, retry_after)))
|
||||
return _finalize(resp)
|
||||
|
||||
# --- Exécution ---
|
||||
response = await call_next(request)
|
||||
|
||||
# Audit des succès
|
||||
audit.write({
|
||||
"event": "request_success",
|
||||
"ip": client_ip,
|
||||
"method": method,
|
||||
"path": path,
|
||||
"status": getattr(response, "status_code", None),
|
||||
"request_id": request_id,
|
||||
})
|
||||
|
||||
# Note: finalize ajoute header + enregistre métriques
|
||||
response.headers["X-Request-Id"] = request_id
|
||||
return _finalize(response)
|
||||
|
||||
|
||||
def _is_read_only_method(method: str) -> bool:
|
||||
"""Vérifie si la méthode HTTP est en lecture seule."""
|
||||
return method.upper() in {"GET", "HEAD", "OPTIONS"}
|
||||
Reference in New Issue
Block a user