feat(web): self-onboarding multi-tenant + auth sesiune (PRD 3.3a)

Canalul web trece de la 100% deschis (hardcodat cont 1) la autentificat si
multi-tenant. Un service nou se inregistreaza din browser, primeste o cheie API
(o singura data) si o sesiune; contul se creeaza "in asteptare" (active=0) si nu
trimite la RAR pana la activarea de catre admin (tools/account.py activate).

- users + app/users.py: parole scrypt (salt per-user, eticheta parametri onorata
  la verify pentru migrare cost), email unic case-insensitive
- sesiune: SessionMiddleware (same_site=strict, https_only config) + app/web/session.py
  (current_account/web_account/require_login->LoginRequired, set_session clear-inainte)
- CSRF (app/web/csrf.py) enforce in prod inclusiv pe login/signup + rate-limit
  in-proces (app/web/ratelimit.py) pe signup si login
- signup/login/logout (app/web/auth_routes.py): signup tranzactie atomica,
  cheie-o-data, log SIGNUP pentru descoperire admin
- dashboard + import scoped pe contul sesiunii (regula NULL->cont 1); toate rutele
  web care ating date sensibile sub require_login; nomenclator ramane global
- banner "cont in asteptare" pentru conturi active=0
- gate worker: claim_one LEFT JOIN accounts COALESCE(active,1)=1 (account_id NULL=activ)

VERIFY context curat (2 runde): leak cross-account /_fragments/mapari prins+reparat.
/code-review high: csrf_token lipsa pe re-randari de eroare, scrypt_params ignorat,
login fara rate-limit -- toate reparate. 361 teste pass (de la 313).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-18 16:43:21 +00:00
parent 748ab8b289
commit 504b490d3b
29 changed files with 2264 additions and 106 deletions

View File

@@ -22,6 +22,8 @@ from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from .. import __version__
from ..web.csrf import get_csrf_token, verify_csrf
from ..web.session import require_login
from ..api.v1.import_router import (
_already_sent_lookup,
_build_idempotency_key,
@@ -55,11 +57,31 @@ templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "tem
_BLOCKED = ("error", "needs_data", "needs_mapping")
def _status_counts(conn) -> dict[str, int]:
rows = conn.execute("SELECT status, COUNT(*) AS n FROM submissions GROUP BY status").fetchall()
def _ctx(request: Request, **extra) -> dict:
"""Context de baza pentru template-uri cu formulare: include mereu csrf_token.
Previne lock-out in prod (web_auth_required=True): orice re-randare de eroare
trebuie sa includa csrf_token negol altfel urmatorul submit da 403 (task #8).
"""
return {"request": request, "csrf_token": get_csrf_token(request), **extra}
def _status_counts(conn, account_id: int) -> dict[str, int]:
rows = conn.execute(
"SELECT status, COUNT(*) AS n FROM submissions "
"WHERE (account_id = ? OR (? = 1 AND account_id IS NULL)) "
"GROUP BY status",
(account_id, account_id),
).fetchall()
return {r["status"]: int(r["n"]) for r in rows}
def _account_active(conn, account_id: int) -> bool:
"""True daca contul e activ (sau legacy cu NULL/absent active)."""
row = conn.execute("SELECT active FROM accounts WHERE id=?", (account_id,)).fetchone()
return bool(row["active"]) if row else True
def _worker_alive(hb) -> bool:
if hb is None or not hb["last_beat"]:
return False
@@ -92,9 +114,10 @@ def _rar_state(hb, worker_alive: bool) -> str:
@router.get("/", response_class=HTMLResponse)
def dashboard(request: Request) -> HTMLResponse:
account_id = require_login(request)
conn = get_connection()
try:
counts = _status_counts(conn)
counts = _status_counts(conn, account_id)
hb = read_heartbeat(conn)
blocked = sum(counts.get(s, 0) for s in _BLOCKED)
worker_alive = _worker_alive(hb)
@@ -107,6 +130,8 @@ def dashboard(request: Request) -> HTMLResponse:
"worker_alive": worker_alive,
"last_login": hb["last_rar_login_ok"] if hb else None,
"rar_state": _rar_state(hb, worker_alive),
"account_active": _account_active(conn, account_id),
"csrf_token": get_csrf_token(request),
}
return templates.TemplateResponse("dashboard.html", ctx)
finally:
@@ -130,46 +155,62 @@ def fragment_nomenclator(request: Request) -> HTMLResponse:
@router.get("/_fragments/banner", response_class=HTMLResponse)
def fragment_banner(request: Request) -> HTMLResponse:
account_id = require_login(request)
conn = get_connection()
try:
counts = _status_counts(conn)
counts = _status_counts(conn, account_id)
blocked = sum(counts.get(s, 0) for s in _BLOCKED)
return templates.TemplateResponse("_banner.html", {"request": request, "blocked": blocked})
return templates.TemplateResponse("_banner.html", {
"request": request,
"blocked": blocked,
"account_active": _account_active(conn, account_id),
})
finally:
conn.close()
@router.get("/_fragments/submissions", response_class=HTMLResponse)
def fragment_submissions(request: Request) -> HTMLResponse:
account_id = require_login(request)
conn = get_connection()
try:
rows = conn.execute(
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, updated_at "
"FROM submissions ORDER BY id DESC LIMIT 100"
"FROM submissions "
"WHERE (account_id = ? OR (? = 1 AND account_id IS NULL)) "
"ORDER BY id DESC LIMIT 100",
(account_id, account_id),
).fetchall()
return templates.TemplateResponse("_submissions.html", {"request": request, "rows": rows})
finally:
conn.close()
def _render_mapari(request: Request, conn, *, message: str | None = None) -> HTMLResponse:
def _render_mapari(
request: Request, conn, account_id: int, *, message: str | None = None
) -> HTMLResponse:
return templates.TemplateResponse(
"_mapari.html",
{
"request": request,
"pending": pending_unmapped(conn),
"pending": pending_unmapped(conn, account_id),
"nomenclator": load_nomenclator(conn),
"message": message,
"csrf_token": get_csrf_token(request),
},
)
@router.get("/_fragments/mapari", response_class=HTMLResponse)
def fragment_mapari(request: Request) -> HTMLResponse:
"""Editor mapari: operatii ROAAUTO nemapate + sugestii fuzzy pe nomenclator RAR."""
"""Editor mapari: operatii ROAAUTO nemapate + sugestii fuzzy pe nomenclator RAR.
Scoped pe contul sesiunii (C6/task#7): pending_unmapped primeste account_id explicit.
"""
account_id = require_login(request)
conn = get_connection()
try:
return _render_mapari(request, conn)
return _render_mapari(request, conn, account_id)
finally:
conn.close()
@@ -179,16 +220,18 @@ def post_mapare(
request: Request,
cod_op_service: str = Form(...),
cod_prestatie: str = Form(...),
account_id: int | None = Form(None),
csrf_token: str | None = Form(None),
auto_send: bool = Form(False),
) -> HTMLResponse:
"""Salveaza maparea aleasa de user, re-rezolva submission-urile blocate, re-randeaza editorul."""
account_id = require_login(request)
verify_csrf(request, csrf_token)
conn = get_connection()
try:
cod = cod_prestatie.strip().upper()
exists = conn.execute("SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)).fetchone()
if not exists:
return _render_mapari(request, conn, message=f"Cod necunoscut: {cod}")
return _render_mapari(request, conn, account_id, message=f"Cod necunoscut: {cod}")
save_mapping(conn, account_id, cod_op_service, cod, auto_send)
stats = reresolve_account(conn, account_id)
msg = (
@@ -196,7 +239,7 @@ def post_mapare(
f"Deblocate: {stats['requeued']} in coada, {stats['needs_data']} cu date lipsa, "
f"{stats['still_blocked']} inca nemapate."
)
return _render_mapari(request, conn, message=msg)
return _render_mapari(request, conn, account_id, message=msg)
finally:
conn.close()
@@ -383,6 +426,7 @@ async def web_upload_import(
request: Request,
file: UploadFile = File(...),
sheet_name: str | None = Form(None),
csrf_token: str | None = Form(None),
) -> HTMLResponse:
"""Upload fisier xlsx/csv → staging; intoarce fragment HTML.
@@ -390,7 +434,8 @@ async def web_upload_import(
Daca nu: intoarce formularul de mapare coloane.
Nu editeaza import_router.py — apeleaza parse_file si DB direct.
"""
account_id = DEFAULT_ACCOUNT_ID
account_id = require_login(request)
verify_csrf(request, csrf_token)
acct = account_or_default(account_id)
data = await file.read()
@@ -400,30 +445,15 @@ async def web_upload_import(
try:
parsed = parse_file(data, filename, sheet_name=sheet_name)
except MultipleSheets as ms:
return templates.TemplateResponse("_upload.html", {
"request": request,
"sheets": ms.sheet_names,
})
return templates.TemplateResponse("_upload.html", _ctx(request, sheets=ms.sheet_names))
except FileTooLarge as e:
return templates.TemplateResponse("_upload.html", {
"request": request,
"error": str(e),
})
return templates.TemplateResponse("_upload.html", _ctx(request, error=str(e)))
except HeaderError as e:
return templates.TemplateResponse("_upload.html", {
"request": request,
"error": f"Antet neclar: {e}",
})
return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Antet neclar: {e}"))
except UnicodeDecodeError as e:
return templates.TemplateResponse("_upload.html", {
"request": request,
"error": f"Encoding nesuportat: {e.reason}",
})
return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Encoding nesuportat: {e.reason}"))
except Exception as e:
return templates.TemplateResponse("_upload.html", {
"request": request,
"error": f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}",
})
return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}"))
conn = get_connection()
try:
@@ -467,11 +497,13 @@ async def web_upload_import(
return templates.TemplateResponse("_upload.html", {
"request": request,
"error": result,
"csrf_token": get_csrf_token(request),
})
return templates.TemplateResponse("_preview_import.html", {
"request": request,
"import_id": batch_id_int,
"message": "Mapare retinuta aplicata automat.",
"csrf_token": get_csrf_token(request),
**result,
})
@@ -491,6 +523,7 @@ async def web_upload_import(
"fuzzy_suggestions": fuzzy_suggestions,
"canonical_fields": _CANONICAL_FIELDS,
"format_data": None,
"csrf_token": get_csrf_token(request),
})
finally:
conn.close()
@@ -502,13 +535,14 @@ async def web_save_mapare_coloane(
import_id: int,
) -> HTMLResponse:
"""Salveaza maparea de coloane si computa preview. Intoarce fragment HTML."""
account_id = DEFAULT_ACCOUNT_ID
account_id = require_login(request)
acct = account_or_default(account_id)
form = await request.form()
# Colectare perechi coloana fisier → camp canonic din form
# form.getlist intoarce List[str | UploadFile]; filtram la str (campuri text)
verify_csrf(request, str(form.get("csrf_token") or ""))
colnames = [str(v) for v in form.getlist("colname") if isinstance(v, str)]
canons = [str(v) for v in form.getlist("canon") if isinstance(v, str)]
format_data_val = str(form.get("format_data") or "").strip() or None
@@ -539,17 +573,17 @@ async def web_save_mapare_coloane(
sugg = _fuzzy_suggest_column(col, limit=3)
if sugg:
fuzzy[col] = sugg
return templates.TemplateResponse("_mapcoloane.html", {
"request": request,
"import_id": import_id,
"columns": columns,
"sample_rows": [],
"fuzzy_suggestions": fuzzy,
"canonical_fields": _CANONICAL_FIELDS,
"format_data": format_data_val,
"message": "Mapeaza cel putin un camp canonic inainte de a continua.",
"error": True,
})
return templates.TemplateResponse("_mapcoloane.html", _ctx(
request,
import_id=import_id,
columns=columns,
sample_rows=[],
fuzzy_suggestions=fuzzy,
canonical_fields=_CANONICAL_FIELDS,
format_data=format_data_val,
message="Mapeaza cel putin un camp canonic inainte de a continua.",
error=True,
))
finally:
conn.close()
@@ -561,10 +595,9 @@ async def web_save_mapare_coloane(
(import_id, acct),
).fetchone()
if not batch:
return templates.TemplateResponse("_upload.html", {
"request": request,
"error": "Batch de import inexistent sau expirat.",
})
return templates.TemplateResponse("_upload.html", _ctx(
request, error="Batch de import inexistent sau expirat."
))
sig = _signature(list(json_mapare.keys()))
@@ -580,15 +613,10 @@ async def web_save_mapare_coloane(
# Computa preview
result = _web_compute_preview(conn, import_id, account_id)
if isinstance(result, str):
return templates.TemplateResponse("_upload.html", {
"request": request,
"error": result,
})
return templates.TemplateResponse("_preview_import.html", {
"request": request,
"import_id": import_id,
**result,
})
return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
return templates.TemplateResponse("_preview_import.html", _ctx(
request, import_id=import_id, **result
))
finally:
conn.close()
@@ -599,7 +627,7 @@ def web_preview_import(
import_id: int,
) -> HTMLResponse:
"""Preview 6 stari per rand. Tinta HTMX dupa mapare retinuta sau navigare directa."""
account_id = DEFAULT_ACCOUNT_ID
account_id = require_login(request)
conn = get_connection()
try:
result = _web_compute_preview(conn, import_id, account_id)
@@ -607,10 +635,12 @@ def web_preview_import(
return templates.TemplateResponse("_upload.html", {
"request": request,
"error": result,
"csrf_token": get_csrf_token(request),
})
return templates.TemplateResponse("_preview_import.html", {
"request": request,
"import_id": import_id,
"csrf_token": get_csrf_token(request),
**result,
})
finally:
@@ -620,7 +650,10 @@ def web_preview_import(
@router.get("/_import/reset", response_class=HTMLResponse)
def web_import_reset(request: Request) -> HTMLResponse:
"""Reseteaza sectiunea de import la starea initiala (drop zone gol)."""
return templates.TemplateResponse("_upload.html", {"request": request})
return templates.TemplateResponse("_upload.html", {
"request": request,
"csrf_token": get_csrf_token(request),
})
@router.post("/_import/{import_id}/confirma", response_class=HTMLResponse)
@@ -632,11 +665,14 @@ async def web_confirma_import(
Replica logica din import_router.commit_import dar cu input din form HTML
si raspuns HTML (nu JSON). INSERT per-rand ON CONFLICT DO NOTHING (TOCTOU).
C8/OV-2: account_id din sesiune propagat consecvent la build_key si toate lookup-urile.
C12: require_login — pe scrieri NICIODATA fallback cont 1 in prod.
"""
account_id = DEFAULT_ACCOUNT_ID
account_id = require_login(request)
acct = account_or_default(account_id)
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
# Parseaza n_confirmat (form.get intoarce str | UploadFile | None → cast la str)
try:
@@ -662,16 +698,14 @@ async def web_confirma_import(
(import_id, acct),
).fetchone()
if not batch:
return templates.TemplateResponse("_upload.html", {
"request": request,
"error": "Batch de import inexistent sau expirat.",
})
return templates.TemplateResponse("_upload.html", _ctx(
request, error="Batch de import inexistent sau expirat."
))
if batch["status"] == "committed":
return templates.TemplateResponse("_upload.html", {
"request": request,
"message": "Acest batch a fost deja comis.",
})
return templates.TemplateResponse("_upload.html", _ctx(
request, message="Acest batch a fost deja comis."
))
# Incarca randurile cu stare ok si needs_review
ok_rows_db = conn.execute(
@@ -684,14 +718,14 @@ async def web_confirma_import(
# Re-arata preview cu eroare
result = _web_compute_preview(conn, import_id, account_id)
if isinstance(result, str):
return templates.TemplateResponse("_upload.html", {"request": request, "error": result})
return templates.TemplateResponse("_preview_import.html", {
"request": request,
"import_id": import_id,
"message": "Niciun rand ok de confirmat in acest batch.",
"error": True,
return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
return templates.TemplateResponse("_preview_import.html", _ctx(
request,
import_id=import_id,
message="Niciun rand ok de confirmat in acest batch.",
error=True,
**result,
})
))
# Decripteaza si construieste lista de randuri de trimis
to_enqueue: list[dict[str, Any]] = []
@@ -726,26 +760,22 @@ async def web_confirma_import(
f"Verifica preview-ul si retasteaza numarul corect."
)
if isinstance(result, str):
return templates.TemplateResponse("_upload.html", {"request": request, "error": msg})
return templates.TemplateResponse("_preview_import.html", {
"request": request,
"import_id": import_id,
"message": msg,
"error": True,
**result,
})
return templates.TemplateResponse("_upload.html", _ctx(request, error=msg))
return templates.TemplateResponse("_preview_import.html", _ctx(
request, import_id=import_id, message=msg, error=True, **result
))
if n_total_ok == 0:
result = _web_compute_preview(conn, import_id, account_id)
if isinstance(result, str):
return templates.TemplateResponse("_upload.html", {"request": request, "error": result})
return templates.TemplateResponse("_preview_import.html", {
"request": request,
"import_id": import_id,
"message": "Niciun rand ok de confirmat.",
"error": True,
return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
return templates.TemplateResponse("_preview_import.html", _ctx(
request,
import_id=import_id,
message="Niciun rand ok de confirmat.",
error=True,
**result,
})
))
# Incarca maparea de coloane pentru payload
first_row_db = conn.execute(
@@ -867,13 +897,13 @@ async def web_confirma_import(
# Succes → drop zone cu mesaj de confirmare
toctou_msg = f" ({len(toctou)} coliziuni TOCTOU excluse)" if toctou else ""
return templates.TemplateResponse("_upload.html", {
"request": request,
"message": (
return templates.TemplateResponse("_upload.html", _ctx(
request,
message=(
f"S-au pus in coada {n_enqueued} prezentari{toctou_msg}. "
f"Procesarea incepe in cateva secunde — urmareste coada de mai jos."
),
})
))
finally:
conn.close()