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