Spaces:
Sleeping
Sleeping
| """backend/api/auth_guard.py — GAP-A6: Authorization model granulare. | |
| Definisce ruoli gerarchici + dependency FastAPI per proteggere gli endpoint. | |
| Ruoli (IntEnum, gerarchici): | |
| USER — nessuna auth (endpoint pubblici, chiamate frontend) | |
| MACHINE — X-Internal-Token header = INTERNAL_TOKEN env var (backend↔backend) | |
| OPERATOR — X-Operator-Token header = OPERATOR_TOKEN env var (monitoring, trigger) | |
| ADMIN — X-Admin-Token header = ADMIN_TOKEN env var (operazioni distruttive) | |
| Utilizzo: | |
| from api.auth_guard import require_role, AuthRole | |
| @router.delete("/tasks/{id}") | |
| async def delete_task(role: AuthRole = Depends(require_role(AuthRole.OPERATOR))): | |
| ... | |
| @router.post("/incidents/purge") | |
| async def purge(role: AuthRole = Depends(require_role(AuthRole.ADMIN))): | |
| ... | |
| Token env vars (Railway): | |
| INTERNAL_TOKEN — già usato in main.py (generato al boot se assente) | |
| OPERATOR_TOKEN — opzionale; se assente, endpoint OPERATOR bloccati | |
| ADMIN_TOKEN — opzionale; se assente, endpoint ADMIN bloccati | |
| NOTA: endpoint USER non richiedono alcun header. | |
| Se OPERATOR_TOKEN non è impostato, gli endpoint OPERATOR ritornano 503 | |
| invece di 403 (distinzione "non configurato" vs "token errato"). | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import os | |
| from enum import IntEnum | |
| from typing import Optional | |
| from fastapi import Depends, Header, HTTPException, Request | |
| logger = logging.getLogger("agente_ai.auth_guard") | |
| # ── Rate limiter sliding window (RATE-LIMIT-FIX + GAP-AUTH-FIX) ────────────── | |
| # In-memory, per token hash (sicuro: non salva il token in chiaro). | |
| # Limiti per ruolo: USER=10/min, MACHINE=30/min, OPERATOR=60/min, ADMIN=illimitato. | |
| # Thread-safe in asyncio (GIL + nessun await interno). | |
| # | |
| # GAP-AUTH-FIX: per USER (role=0) la chiave usa client_ip invece di 'anonymous'. | |
| # Prima del fix: tutti gli utenti pubblici condividevano sha256("0:anonymous") → | |
| # un singolo client poteva svuotare il bucket per tutti gli utenti al mondo. | |
| # Fix: sha256("0:{client_ip}") — bucket separato per IP. | |
| # Fallback: 'unknown' se IP non rilevabile (raro su Railway/HF con proxy). | |
| import collections as _col | |
| # ── Redis-backed rate store (Upstash) — DoS-FIX: persiste cross-restart ──── | |
| # Fallback automatico a in-memory se UPSTASH_REDIS_URL non configurato. | |
| _upstash_url = os.getenv('UPSTASH_REDIS_URL', '') | |
| _upstash_token = os.getenv('UPSTASH_REDIS_TOKEN', '') | |
| _use_redis = bool(_upstash_url and _upstash_token) | |
| def _redis_rate_check(key: str, limit: int, window_s: int) -> tuple[bool, int]: | |
| """Rate check via Upstash Redis REST API (zero dipendenze extra). | |
| GAP-RATE-LIMIT-REDIS-FAILOPEN fix: distingue errori network/timeout (fail-open | |
| silenzioso — corretto) da errori di parsing/logica (fail-open MA loggati a WARNING | |
| per visibilità nel monitoraggio). In entrambi i casi non blocca l'utente, ma gli | |
| errori non-network diventano visibili nei log invece di essere silenziosi. | |
| """ | |
| import urllib.request as _ur, urllib.error as _ue, json as _js, time as _rt | |
| now_s = int(_rt.time()) | |
| window_key = f'{key}:{now_s // window_s}' | |
| try: | |
| req = _ur.Request( | |
| f'{_upstash_url}/pipeline', | |
| data=_js.dumps([ | |
| ['INCR', window_key], | |
| ['EXPIRE', window_key, window_s * 2], | |
| ]).encode(), | |
| headers={'Authorization': f'Bearer {_upstash_token}', 'Content-Type': 'application/json'}, | |
| method='POST', | |
| ) | |
| with _ur.urlopen(req, timeout=1) as r: | |
| results = _js.loads(r.read()) | |
| count = results[0]['result'] if isinstance(results[0], dict) else results[0] | |
| if count > limit: | |
| return False, window_s | |
| return True, 0 | |
| except (_ue.URLError, _ue.HTTPError, TimeoutError, OSError): | |
| # Rete/Redis non raggiungibile — fail-open silenzioso (comportamento atteso) | |
| return True, 0 | |
| except Exception as _e: | |
| # GAP-RATE-LIMIT-REDIS-FAILOPEN fix: errore di parsing JSON o bug nel codice — | |
| # fail-open ma loggato a WARNING per visibilità (non silenzioso come prima). | |
| # Causa tipica: struttura risposta Upstash cambiata, bug nel codice di parsing. | |
| logger.warning( | |
| "redis_rate_check: non-network error — rate limiter disabled for this request " | |
| "(key=%s): %s: %s", key, type(_e).__name__, _e | |
| ) | |
| return True, 0 # fail-open: mai bloccare per bug infrastrutturale | |
| import time as _rl_time | |
| import hashlib as _rl_hash | |
| _RATE_LIMITS: dict[int, int] = { | |
| 0: 10, # USER | |
| 1: 30, # MACHINE | |
| 2: 60, # OPERATOR | |
| 3: -1, # ADMIN — illimitato | |
| } | |
| _RATE_WINDOW_S = 60 # finestra sliding 60s | |
| _rate_store: dict[str, _col.deque] = {} # token_hash → deque di timestamps | |
| def _rate_key(role: int, token_header: str | None, client_ip: str | None = None) -> str: | |
| """Chiave rate limiter: hash(role + discriminante) — non espone token né IP in chiaro. | |
| GAP-AUTH-FIX: USER (role=0) usa client_ip come discriminante — bucket per IP, | |
| non bucket globale condiviso. Previene DoS a costo zero (un client svuota tutti). | |
| Ruoli autenticati (MACHINE/OPERATOR/ADMIN) continuano a usare il token hash. | |
| """ | |
| if role == 0: | |
| # USER: discrimina per IP — ogni client ha il proprio bucket | |
| raw = f"0:{client_ip or 'unknown'}" | |
| else: | |
| # Ruoli autenticati: discrimina per token (più preciso dell'IP) | |
| raw = f"{role}:{token_header or 'anonymous'}" | |
| return _rl_hash.sha256(raw.encode()).hexdigest()[:16] | |
| def _check_rate_limit( | |
| role: int, | |
| token_header: str | None, | |
| client_ip: str | None = None, | |
| ) -> tuple[bool, int]: | |
| """Controlla il rate limit per ruolo. | |
| Ritorna (ok, retry_after_seconds). | |
| ok=True → richiesta consentita. | |
| ok=False → limite superato, retry_after = secondi alla prossima finestra. | |
| ADMIN (role=3) è sempre ok. | |
| """ | |
| limit = _RATE_LIMITS.get(role, 10) | |
| if limit < 0: | |
| return True, 0 # ADMIN — illimitato | |
| key = _rate_key(role, token_header, client_ip) | |
| # DoS-FIX: usa Redis se disponibile (persiste cross-restart HF Space) | |
| if _use_redis: | |
| return _redis_rate_check(key, limit, int(_RATE_WINDOW_S)) | |
| now = _rl_time.monotonic() | |
| window_start = now - _RATE_WINDOW_S | |
| if key not in _rate_store: | |
| _rate_store[key] = _col.deque() | |
| dq = _rate_store[key] | |
| # Rimuovi timestamp fuori dalla finestra | |
| while dq and dq[0] < window_start: | |
| dq.popleft() | |
| if len(dq) >= limit: | |
| # Prossima slot disponibile = timestamp più vecchio + finestra | |
| retry_after = int(_RATE_WINDOW_S - (now - dq[0])) + 1 | |
| return False, max(retry_after, 1) | |
| dq.append(now) | |
| return True, 0 | |
| class AuthRole(IntEnum): | |
| """Gerarchia ruoli: USER < MACHINE < OPERATOR < ADMIN.""" | |
| USER = 0 | |
| MACHINE = 1 | |
| OPERATOR = 2 | |
| ADMIN = 3 | |
| def _get_token(env_var: str) -> str: | |
| return os.getenv(env_var, "").strip() | |
| async def _resolve_role( | |
| x_internal_token: Optional[str] = Header(None, alias="X-Internal-Token"), | |
| x_operator_token: Optional[str] = Header(None, alias="X-Operator-Token"), | |
| x_admin_token: Optional[str] = Header(None, alias="X-Admin-Token"), | |
| ) -> AuthRole: | |
| """Risolve il ruolo del chiamante in base agli header presenti.""" | |
| # ADMIN (massima priorità) | |
| admin_tok = _get_token("ADMIN_TOKEN") | |
| if admin_tok and x_admin_token == admin_tok: | |
| logger.debug("auth: ADMIN role granted") | |
| return AuthRole.ADMIN | |
| # OPERATOR | |
| op_tok = _get_token("OPERATOR_TOKEN") | |
| if op_tok and x_operator_token == op_tok: | |
| logger.debug("auth: OPERATOR role granted") | |
| return AuthRole.OPERATOR | |
| # MACHINE (INTERNAL_TOKEN, già generato al boot da main.py) | |
| int_tok = _get_token("INTERNAL_TOKEN") | |
| if int_tok and x_internal_token == int_tok: | |
| logger.debug("auth: MACHINE role granted") | |
| return AuthRole.MACHINE | |
| # Nessun token valido → ruolo USER (minimo) | |
| return AuthRole.USER | |
| def require_role(min_role: AuthRole): | |
| """ | |
| FastAPI Depends factory per autorizzazione granulare. | |
| Esempio: | |
| @router.post("/trigger") | |
| async def trigger(role: AuthRole = Depends(require_role(AuthRole.OPERATOR))): | |
| ... | |
| Se il ruolo risolto < min_role → 403 Forbidden. | |
| Se il token richiesto non è configurato (env var assente) → 503 Service Unavailable. | |
| """ | |
| async def _check( | |
| request: 'Request', | |
| resolved: AuthRole = Depends(_resolve_role), | |
| ) -> AuthRole: | |
| # RATE-LIMIT-FIX + GAP-AUTH-FIX: estrai IP cliente per bucket USER per-IP | |
| _token_hdr = ( | |
| request.headers.get('X-Admin-Token') or | |
| request.headers.get('X-Operator-Token') or | |
| request.headers.get('X-Internal-Token') | |
| ) | |
| # GAP-AUTH-FIX: estrai IP reale (Railway/HF dietro proxy → X-Forwarded-For) | |
| _client_ip: str | None = ( | |
| request.headers.get('X-Forwarded-For', '').split(',')[0].strip() | |
| or request.headers.get('X-Real-IP', '') | |
| or (request.client.host if request.client else None) | |
| ) or None | |
| _ok, _retry = _check_rate_limit(int(resolved), _token_hdr, _client_ip) | |
| if not _ok: | |
| raise HTTPException( | |
| status_code=429, | |
| detail={ | |
| 'error': 'Rate limit superato', | |
| 'role': resolved.name, | |
| 'limit': f'{_RATE_LIMITS.get(int(resolved), 10)}/min', | |
| 'retry_after_seconds': _retry, | |
| }, | |
| headers={'Retry-After': str(_retry)}, | |
| ) | |
| if resolved < min_role: | |
| # Distingue "token non configurato" (503) da "token errato" (403) | |
| if min_role == AuthRole.OPERATOR and not _get_token("OPERATOR_TOKEN"): | |
| raise HTTPException(503, { | |
| "error": "Endpoint non disponibile", | |
| "reason": "OPERATOR_TOKEN non configurato su Railway", | |
| "required": "AuthRole.OPERATOR", | |
| }) | |
| if min_role == AuthRole.ADMIN and not _get_token("ADMIN_TOKEN"): | |
| raise HTTPException(503, { | |
| "error": "Endpoint non disponibile", | |
| "reason": "ADMIN_TOKEN non configurato su Railway", | |
| "required": "AuthRole.ADMIN", | |
| }) | |
| raise HTTPException(403, { | |
| "error": "Permessi insufficienti", | |
| "required_role": min_role.name, | |
| "your_role": resolved.name, | |
| "hint": f"Fornire header X-{min_role.name.capitalize()}-Token con il token corretto", | |
| }) | |
| return resolved | |
| return _check | |
| # ── Convenienza: dependency per endpoint pubblici (nessun controllo) ───────── | |
| async def any_role(resolved: AuthRole = Depends(_resolve_role)) -> AuthRole: | |
| """Dependency che accetta qualsiasi ruolo (incluso USER senza token). | |
| Usare per endpoint pubblici che vogliono loggare il ruolo del chiamante.""" | |
| return resolved | |