Terminal / api /auth_guard.py
Baida—-
deploy: full backend sync for Node A [Kernel Restore]
8b87770 verified
Raw
History Blame Contribute Delete
11.2 kB
"""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