Implementeaza PRD 5.6 complet (14 stories, TDD). Doua axe:
Lifecycle trimiteri blocate (Val A):
- submissions_admin.py: sterge/repune scoped (404 cross-account inaintea lui 409 stare)
- reactivare dedup peste `error` cu CAS (WHERE id=? AND status='error'), creds noi in
submissions + accounts.rar_creds_enc; worker invalideaza sesiunea RAR la creds proaspete
(JWT 30h vechi nu mai trimite cu parola gresita); camp aditiv `reactivated:true`
- retentie randuri blocate 30z; purge_expired exclude queued/sending; purge_after curatat
la reactivare/requeue
- API DELETE /v1/prezentari/{id} + /repune (200+JSON); UI butoane + bulk + banner actionabil
Observabilitate:
- app/observ.py log_event: dublu canal app_events (DB) + RotatingFileHandler per-proces,
redactare creds/PII la scriere (redact_pii/vin_partial)
- request_id middleware + X-Request-ID pe toate raspunsurile
- handler global excepții -> 500 envelope 6-chei + request_id (traceback doar in jurnal)
- audit cerere API (api_prezentari/api_auth_esuat) + audit worker (rar_login/tranzitii)
- tab "Jurnal" filtrabil scoped (non-admin doar contul sau); retentie jurnal 90z
- rar_error expus in GET /v1/prezentari/{id} (recovery observabil)
pytest -q: 741 passed, 0 failed. Docs: PRD raport VERIFY, contract endpointuri noi, ROADMAP.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
165 lines
6.0 KiB
Python
165 lines
6.0 KiB
Python
"""Autentificare API-key per cont (CORE securitate).
|
|
|
|
Cheile API sunt separate de credentialele RAR: identifica CONTUL ROAAUTO care
|
|
foloseste gateway-ul, nu utilizatorul RAR. Stocam doar SHA-256 al cheii (tabela
|
|
`api_keys`), niciodata cheia in clar — emisa o singura data la creare.
|
|
|
|
Enforcement controlat de `AUTOPASS_require_api_key`:
|
|
- True (prod): orice /v1/* protejat cere cheie valida -> 401 fara/cu cheie gresita.
|
|
- False (dev/test): fara cheie -> cont implicit id=1 (back-compat); cheie prezenta
|
|
dar invalida -> tot 401 (o cheie gresita nu trebuie sa treaca tacit).
|
|
|
|
Lifecycle (emitere/rotire/revocare) se face din CLI `python -m tools.apikey`
|
|
(adminul ruleaza pe masina) — nicio suprafata HTTP de admin de securizat.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import secrets
|
|
import sqlite3
|
|
|
|
from fastapi import Header, HTTPException, Request
|
|
|
|
from .config import get_settings
|
|
from .db import get_connection
|
|
from .mapping import DEFAULT_ACCOUNT_ID
|
|
|
|
KEY_PREFIX = "rfak_" # romfast autopass key
|
|
|
|
|
|
def generate_key() -> str:
|
|
"""Cheie noua in clar (emisa o singura data). `rfak_` + 43 caractere urlsafe."""
|
|
return KEY_PREFIX + secrets.token_urlsafe(32)
|
|
|
|
|
|
def hash_key(plaintext: str) -> str:
|
|
"""SHA-256 hex al cheii. Comparam mereu pe hash, niciodata pe clar."""
|
|
return hashlib.sha256(plaintext.strip().encode("utf-8")).hexdigest()
|
|
|
|
|
|
def create_api_key(conn: sqlite3.Connection, account_id: int) -> str:
|
|
"""Emite o cheie pentru cont, stocheaza hash-ul, intoarce cheia in clar.
|
|
|
|
Verifica intai ca exista contul (FK ON DELETE CASCADE nu raporteaza un cont
|
|
inexistent la INSERT decat ca eroare obscura). Intoarce cheia in clar — NU se
|
|
mai poate recupera ulterior.
|
|
"""
|
|
acct = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
|
|
if not acct:
|
|
raise ValueError(f"cont inexistent: {account_id}")
|
|
plaintext = generate_key()
|
|
conn.execute(
|
|
"INSERT INTO api_keys (account_id, key_hash, active) VALUES (?, ?, 1)",
|
|
(account_id, hash_key(plaintext)),
|
|
)
|
|
return plaintext
|
|
|
|
|
|
def revoke_api_key(conn: sqlite3.Connection, key_id: int) -> bool:
|
|
"""Revoca o cheie (active=0 + revoked_at). Intoarce True daca a fost activa."""
|
|
cur = conn.execute(
|
|
"UPDATE api_keys SET active=0, revoked_at=datetime('now') WHERE id=? AND active=1",
|
|
(key_id,),
|
|
)
|
|
return cur.rowcount > 0
|
|
|
|
|
|
def rotate_api_key(conn: sqlite3.Connection, account_id: int) -> str:
|
|
"""Revoca toate cheile active ale contului si emite una noua. Intoarce cheia noua."""
|
|
conn.execute(
|
|
"UPDATE api_keys SET active=0, revoked_at=datetime('now') WHERE account_id=? AND active=1",
|
|
(account_id,),
|
|
)
|
|
return create_api_key(conn, account_id)
|
|
|
|
|
|
def list_keys(conn: sqlite3.Connection, account_id: int | None = None) -> list[dict]:
|
|
"""Metadate chei (FARA hash). Pentru CLI list."""
|
|
if account_id is not None:
|
|
rows = conn.execute(
|
|
"SELECT id, account_id, active, created_at, revoked_at FROM api_keys "
|
|
"WHERE account_id=? ORDER BY id",
|
|
(account_id,),
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute(
|
|
"SELECT id, account_id, active, created_at, revoked_at FROM api_keys ORDER BY id"
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
def account_for_key(conn: sqlite3.Connection, plaintext: str) -> int | None:
|
|
"""account_id pentru o cheie activa, sau None daca lipseste/revocata."""
|
|
if not plaintext:
|
|
return None
|
|
row = conn.execute(
|
|
"SELECT account_id FROM api_keys WHERE key_hash=? AND active=1",
|
|
(hash_key(plaintext),),
|
|
).fetchone()
|
|
return int(row["account_id"]) if row else None
|
|
|
|
|
|
def _extract_key(x_api_key: str | None, authorization: str | None) -> str | None:
|
|
"""Cheia din `X-API-Key` sau `Authorization: Bearer <key>` (prima are prioritate)."""
|
|
if x_api_key and x_api_key.strip():
|
|
return x_api_key.strip()
|
|
if authorization and authorization.strip():
|
|
parts = authorization.strip().split(None, 1)
|
|
if len(parts) == 2 and parts[0].lower() == "bearer":
|
|
return parts[1].strip()
|
|
return None
|
|
|
|
|
|
def _log_auth_esuat(request: Request | None, plaintext: str | None, motiv: str) -> None:
|
|
"""Eveniment de jurnal pentru un esec de auth (US-004): IP + prefix cheie, NU cheia.
|
|
|
|
Best-effort (log_event inghite erorile). Import local: evita cuplarea la import-time
|
|
(observ -> db; auth -> db) si pastreaza auth.py importabil din CLI fara efecte.
|
|
"""
|
|
from .observ import log_event
|
|
ip = None
|
|
if request is not None and request.client is not None:
|
|
ip = request.client.host
|
|
prefix = (plaintext[:8] + "…") if plaintext else None
|
|
log_event(
|
|
"api_auth_esuat",
|
|
nivel="WARNING",
|
|
cod="RAR_CREDS_INVALIDE" if plaintext else None,
|
|
mesaj=motiv,
|
|
context={"ip": ip, "key_prefix": prefix},
|
|
)
|
|
|
|
|
|
def resolve_account_id(
|
|
request: Request,
|
|
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
|
authorization: str | None = Header(default=None),
|
|
) -> int:
|
|
"""Dependency FastAPI: contul cererii din cheia API.
|
|
|
|
- cheie valida -> account_id al cheii
|
|
- cheie invalida (prezenta) -> 401 (mereu, indiferent de flag)
|
|
- fara cheie + flag off -> cont implicit (id=1), back-compat
|
|
- fara cheie + flag on -> 401
|
|
Esecurile de auth (401) emit `api_auth_esuat` cu IP + prefix cheie (US-004).
|
|
"""
|
|
settings = get_settings()
|
|
plaintext = _extract_key(x_api_key, authorization)
|
|
|
|
if plaintext is None:
|
|
if settings.require_api_key:
|
|
_log_auth_esuat(request, None, "cheie API lipsa (prod)")
|
|
raise HTTPException(status_code=401, detail="cheie API lipsa")
|
|
return DEFAULT_ACCOUNT_ID
|
|
|
|
conn = get_connection()
|
|
try:
|
|
account_id = account_for_key(conn, plaintext)
|
|
finally:
|
|
conn.close()
|
|
if account_id is None:
|
|
_log_auth_esuat(request, plaintext, "cheie API invalida sau revocata")
|
|
raise HTTPException(status_code=401, detail="cheie API invalida sau revocata")
|
|
return account_id
|