feat(securitate-CORE): redactare creds + auth API-key per cont
Redactare: - handler RequestValidationError dropeaza input/ctx din 422 (vectorul de scurgere a rar_credentials.password pe /v1/prezentari); pastreaza type/loc/msg - app/security.py: scrub/scrub_text + CredentialRedactingFilter pe root+uvicorn - models.py: password cu repr=False Auth API-key: - app/auth.py: hash SHA-256 in api_keys (cheia in clar emisa o singura data), header X-API-Key / Authorization: Bearer, dependency resolve_account_id - enforcement pe flag AUTOPASS_require_api_key (prod on->401, dev off->cont default id=1; cheie prezenta invalida->401 mereu) - account_id real curge din cheie in ingestie + mapare - tools/apikey.py: CLI create/rotate/revoke/list (fara endpoint HTTP admin) 16 teste noi (tests/test_security.py). 85 pass total. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,9 +13,10 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ...auth import resolve_account_id
|
||||
from ...db import get_connection
|
||||
from ...idempotency import idempotency_key
|
||||
from ...mapping import (
|
||||
@@ -33,17 +34,20 @@ router = APIRouter(prefix="/v1", tags=["v1"])
|
||||
|
||||
|
||||
@router.post("/prezentari", response_model=PrezentariResponse)
|
||||
def create_prezentari(req: PrezentareRequest) -> PrezentariResponse:
|
||||
def create_prezentari(
|
||||
req: PrezentareRequest,
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
) -> PrezentariResponse:
|
||||
"""Enqueue una/mai multe prezentari. Idempotent: continut identic -> acelasi submission.
|
||||
|
||||
Validarea de continut (T3, app.validation) ruleaza inainte de enqueue:
|
||||
esecurile NU resping cererea, ci enqueue-aza cu status `needs_data` + motiv
|
||||
(plan.md sect. 3). JSON malformat -> 422 din Pydantic (validare de shape).
|
||||
TODO(auth): rezolva account_id din API key (acum None).
|
||||
account_id vine din cheia API (resolve_account_id): cont real cu cheie,
|
||||
implicit id=1 in dev fara cheie, 401 fara cheie valida in prod.
|
||||
Nota: rar_credentials NU se persista (zero-storage) — worker-ul le va primi
|
||||
pe alt canal (T2); in schelet enqueue-ul doar stocheaza prezentarea.
|
||||
"""
|
||||
account_id = None # TODO(auth): din API key
|
||||
acct = account_or_default(account_id)
|
||||
conn = get_connection()
|
||||
results: list[SubmissionResult] = []
|
||||
@@ -174,18 +178,21 @@ def get_mapari_pending() -> dict:
|
||||
|
||||
|
||||
class MapareIn(BaseModel):
|
||||
account_id: int | None = None
|
||||
cod_op_service: str = Field(..., min_length=1)
|
||||
cod_prestatie: str = Field(..., min_length=1)
|
||||
auto_send: bool = True
|
||||
|
||||
|
||||
@router.post("/mapari")
|
||||
def create_mapare(req: MapareIn) -> dict:
|
||||
def create_mapare(
|
||||
req: MapareIn,
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
) -> dict:
|
||||
"""Salveaza/actualizeaza o mapare op->cod si re-rezolva submission-urile blocate.
|
||||
|
||||
Verifica intai ca `cod_prestatie` exista in nomenclator (nu lasam mapari catre
|
||||
coduri inexistente). Apoi upsert + re-rezolvare automata a `needs_mapping`.
|
||||
Contul vine din cheia API (NU din body) — un cont nu poate edita maparile
|
||||
altuia. Verifica intai ca `cod_prestatie` exista in nomenclator (nu lasam
|
||||
mapari catre coduri inexistente). Apoi upsert + re-rezolvare `needs_mapping`.
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
@@ -195,8 +202,8 @@ def create_mapare(req: MapareIn) -> dict:
|
||||
).fetchone()
|
||||
if not exists:
|
||||
raise HTTPException(status_code=422, detail=f"cod_prestatie '{cod}' nu exista in nomenclator")
|
||||
save_mapping(conn, req.account_id, req.cod_op_service, cod, req.auto_send)
|
||||
stats = reresolve_account(conn, req.account_id)
|
||||
save_mapping(conn, account_id, req.cod_op_service, cod, req.auto_send)
|
||||
stats = reresolve_account(conn, account_id)
|
||||
return {"saved": {"cod_op_service": req.cod_op_service.strip(), "cod_prestatie": cod}, "reresolve": stats}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
140
app/auth.py
Normal file
140
app/auth.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""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
|
||||
|
||||
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 resolve_account_id(
|
||||
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
|
||||
"""
|
||||
settings = get_settings()
|
||||
plaintext = _extract_key(x_api_key, authorization)
|
||||
|
||||
if plaintext is None:
|
||||
if settings.require_api_key:
|
||||
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:
|
||||
raise HTTPException(status_code=401, detail="cheie API invalida sau revocata")
|
||||
return account_id
|
||||
@@ -22,6 +22,12 @@ class Settings(BaseSettings):
|
||||
# --- Bază de date ---
|
||||
db_path: Path = ROOT / "data" / "autopass.db"
|
||||
|
||||
# --- Securitate (CORE) ---
|
||||
# Enforcement auth API-key pe /v1/* protejat. False (dev/test): fara cheie ->
|
||||
# cont implicit id=1. True (prod): fara cheie valida -> 401. O cheie PREZENTA
|
||||
# dar invalida da 401 indiferent de flag.
|
||||
require_api_key: bool = False
|
||||
|
||||
# --- RAR ---
|
||||
rar_env: str = "test" # "test" | "prod"
|
||||
rar_base_url_test: str = "https://apps.rarom.ro/test-rar-autopass"
|
||||
|
||||
21
app/main.py
21
app/main.py
@@ -11,24 +11,41 @@ from __future__ import annotations
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse, PlainTextResponse
|
||||
|
||||
from . import __version__
|
||||
from .api.v1.router import router as api_v1_router
|
||||
from .config import get_settings
|
||||
from .db import get_connection, init_db, queue_depth, read_heartbeat
|
||||
from .security import install_log_redaction
|
||||
from .web.routes import router as web_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
install_log_redaction()
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="Gateway RAR AUTOPASS", version=__version__, lifespan=lifespan)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
"""422 fara echo de credentiale.
|
||||
|
||||
Pydantic include implicit `input` (+ uneori `ctx`) in fiecare eroare — pe
|
||||
/v1/prezentari asta ar reflecta inapoi `rar_credentials.password`. Pastram
|
||||
type/loc/msg (clientul stie ce camp e gresit) si DROP-am input/ctx. Defense
|
||||
in depth pe TOATE rutele, nu doar prezentari.
|
||||
"""
|
||||
cleaned = [{"type": e.get("type"), "loc": e.get("loc"), "msg": e.get("msg")} for e in exc.errors()]
|
||||
return JSONResponse(status_code=422, content={"detail": cleaned})
|
||||
|
||||
|
||||
app.include_router(api_v1_router)
|
||||
app.include_router(web_router)
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ class RarCredentials(BaseModel):
|
||||
"""Credentiale RAR per-cerere (vin de la ROAAUTO din Oracle). NU se stocheaza."""
|
||||
|
||||
email: str
|
||||
password: str
|
||||
# repr=False: str(creds) / loguri care fac repr pe model NU expun parola.
|
||||
password: str = Field(..., repr=False)
|
||||
|
||||
|
||||
class PrestatieItem(BaseModel):
|
||||
|
||||
124
app/security.py
Normal file
124
app/security.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Redactare credentiale (CORE securitate).
|
||||
|
||||
Corpul lui POST /v1/prezentari contine `rar_credentials.password` (creds RAR
|
||||
per-cerere, zero-storage). Aceste valori NU trebuie sa apara NICIODATA in:
|
||||
- raspunsuri de eroare (422 Pydantic echo-eaza `input` => parola) — vezi
|
||||
`app.main.validation_exception_handler`;
|
||||
- loguri / traceback-uri uvicorn — vezi `CredentialRedactingFilter`;
|
||||
- repr-ul modelelor (str(creds)) — vezi `RarCredentials` (Field repr=False).
|
||||
|
||||
Modulul e pur (fara DB/HTTP), unit-testabil direct.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
MASK = "***REDACTED***"
|
||||
|
||||
# Chei al caror continut e secret oriunde apar (case-insensitive). `denumire`
|
||||
# etc. raman in clar — doar credentialele si token-urile se mascheaza.
|
||||
SENSITIVE_KEYS = frozenset(
|
||||
{
|
||||
"password",
|
||||
"parola",
|
||||
"pwd",
|
||||
"pass",
|
||||
"rar_credentials",
|
||||
"credentials",
|
||||
"token",
|
||||
"jwt",
|
||||
"authorization",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"x-api-key",
|
||||
"secret",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def scrub(obj: Any) -> Any:
|
||||
"""Copie a structurii cu valorile cheilor sensibile mascate, recursiv.
|
||||
|
||||
Pentru `rar_credentials`/`credentials` masheaza intregul subarbore (nu doar
|
||||
`password`) — un dict de creds e secret integral. Listele si dict-urile se
|
||||
parcurg in adancime; scalarii trec neatinsi.
|
||||
"""
|
||||
if isinstance(obj, dict):
|
||||
out: dict = {}
|
||||
for k, v in obj.items():
|
||||
if isinstance(k, str) and k.lower() in SENSITIVE_KEYS:
|
||||
out[k] = MASK
|
||||
else:
|
||||
out[k] = scrub(v)
|
||||
return out
|
||||
if isinstance(obj, (list, tuple)):
|
||||
return [scrub(v) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
# Mascare in text liber (mesaje de log, traceback-uri formatate). Acopera formele
|
||||
# uzuale: JSON ("password": "x"), kwargs (password='x'), Bearer <token>.
|
||||
_TEXT_PATTERNS = [
|
||||
# "password": "secret" / 'password' : 'secret' (JSON / dict repr)
|
||||
re.compile(
|
||||
r"""(?P<key>["']?(?:password|parola|pwd|pass|token|jwt|secret|api[_-]?key)["']?\s*[:=]\s*)"""
|
||||
r"""(?P<q>["'])(?P<val>(?:\\.|[^"'\\])*)(?P=q)""",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
# password=secret (fara ghilimele, pana la separator)
|
||||
re.compile(
|
||||
r"""(?P<key>\b(?:password|parola|pwd|token|jwt|secret|api[_-]?key)\s*=\s*)(?P<val>[^\s,;&)}\]]+)""",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
# Authorization: Bearer <token> / Bearer eyJ...
|
||||
re.compile(r"""(?P<key>Bearer\s+)(?P<val>[A-Za-z0-9._\-]+)""", re.IGNORECASE),
|
||||
]
|
||||
|
||||
|
||||
def scrub_text(text: str) -> str:
|
||||
"""Masheaza credentiale dintr-un sir liber (loguri, traceback)."""
|
||||
if not text:
|
||||
return text
|
||||
out = text
|
||||
for pat in _TEXT_PATTERNS:
|
||||
out = pat.sub(lambda m: m.group("key") + MASK, out)
|
||||
return out
|
||||
|
||||
|
||||
class CredentialRedactingFilter(logging.Filter):
|
||||
"""Filtru de logging care masheaza credentiale din orice record emis.
|
||||
|
||||
Atasat pe root + logger-ele uvicorn (vezi `install_log_redaction`). Lucreaza
|
||||
pe mesajul DEJA formatat (cu args interpolate), apoi goleste args ca
|
||||
formatter-ul sa nu reinterpoleze. Tot ce trece prin logging e curatat;
|
||||
parolele in variabile locale de traceback nu ajung in mesaj (Python nu
|
||||
formateaza locals implicit), deci raman protejate.
|
||||
"""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
try:
|
||||
msg = record.getMessage()
|
||||
except Exception:
|
||||
return True
|
||||
scrubbed = scrub_text(msg)
|
||||
if scrubbed != msg:
|
||||
record.msg = scrubbed
|
||||
record.args = ()
|
||||
return True
|
||||
|
||||
|
||||
def install_log_redaction() -> None:
|
||||
"""Instaleaza filtrul de redactare pe root + logger-ele uvicorn (idempotent)."""
|
||||
filt = CredentialRedactingFilter()
|
||||
targets = [
|
||||
logging.getLogger(), # root
|
||||
logging.getLogger("uvicorn"),
|
||||
logging.getLogger("uvicorn.error"),
|
||||
logging.getLogger("uvicorn.access"),
|
||||
]
|
||||
for lg in targets:
|
||||
if not any(isinstance(f, CredentialRedactingFilter) for f in lg.filters):
|
||||
lg.addFilter(filt)
|
||||
Reference in New Issue
Block a user