Files
rar-autopass/app/main.py
Claude Agent 504b490d3b 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>
2026-06-18 16:43:21 +00:00

139 lines
4.8 KiB
Python

"""Aplicatia FastAPI: API v1 + dashboard web + /healthz + /metrics.
Worker-ul ruleaza ca PROCES SEPARAT (python -m app.worker), NU ca task aici
(plan.md sect. 4: un worker mort nu trebuie sa lase containerul "sanatos").
Pornire dev: uvicorn app.main:app --reload
"""
from __future__ import annotations
import secrets
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import RedirectResponse
from . import __version__
from .api.v1.import_router import router as import_v1_router
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
from .web.auth_routes import router as auth_router
from .web.csrf import CsrfError
from .web.session import LoginRequired
@asynccontextmanager
async def lifespan(app: FastAPI):
install_log_redaction()
init_db()
yield
app = FastAPI(title="Gateway RAR AUTOPASS", version=__version__, lifespan=lifespan)
settings = get_settings()
_session_secret = settings.session_secret or secrets.token_hex(32)
app.add_middleware(
SessionMiddleware,
secret_key=_session_secret,
session_cookie="autopass_session",
https_only=settings.session_https_only,
same_site="strict",
)
@app.exception_handler(LoginRequired)
async def login_required_handler(request: Request, exc: LoginRequired) -> RedirectResponse:
return RedirectResponse("/login", status_code=303)
@app.exception_handler(CsrfError)
async def csrf_error_handler(request: Request, exc: CsrfError) -> JSONResponse:
return JSONResponse(status_code=403, content={"detail": "CSRF invalid"})
@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})
# Assets servite local (htmx vendorizat), NU din CDN: gateway-ul ruleaza
# offline (LXC/VPS, Cloudflare Tunnel). Fara asta, dashboard-ul ramane static
# (zero polling banner/coada) cand unpkg e blocat/inaccesibil. Aceeasi decizie
# offline-first ca fontul UI (fara dependinta CDN).
_STATIC_DIR = Path(__file__).resolve().parent / "web" / "static"
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
app.include_router(api_v1_router)
app.include_router(import_v1_router)
app.include_router(web_router)
app.include_router(auth_router)
@app.get("/healthz")
def healthz() -> dict:
"""Sanatate: worker viu + ultimul login RAR reusit + adancime coada.
Pica (200 cu ok=False / sau folosit de orchestrator) cand worker-ul e mort
-> semnal de restart (plan.md sect. 8). Intoarce 200 mereu cu detalii;
orchestratorul decide pe campul `worker_alive`.
"""
settings = get_settings()
conn = get_connection()
try:
hb = read_heartbeat(conn)
depth = queue_depth(conn)
finally:
conn.close()
worker_alive = False
last_beat = hb["last_beat"] if hb else None
if last_beat:
try:
age = (datetime.now(timezone.utc) - datetime.fromisoformat(last_beat)).total_seconds()
worker_alive = age <= settings.worker_heartbeat_stale_s
except ValueError:
worker_alive = False
return {
"ok": True,
"version": __version__,
"rar_env": settings.rar_env,
"worker_alive": worker_alive,
"last_beat": last_beat,
"last_rar_login_ok": hb["last_rar_login_ok"] if hb else None,
"queue_depth": depth,
}
@app.get("/metrics", response_class=PlainTextResponse)
def metrics() -> str:
"""Metrici text simplu (submissions pe status + backlog). Format Prometheus-lite."""
conn = get_connection()
try:
rows = conn.execute("SELECT status, COUNT(*) AS n FROM submissions GROUP BY status").fetchall()
finally:
conn.close()
lines = ["# submissions pe status"]
for r in rows:
lines.append(f'autopass_submissions{{status="{r["status"]}"}} {r["n"]}')
return "\n".join(lines) + "\n"