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:
Dom
2026-01-29 11:23:51 +01:00
parent 21bfa3b337
commit a27b74cf22
1595 changed files with 412691 additions and 400 deletions

View 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"}