Files
rar-autopass/docs/prd/prd-3.3-self-onboarding-web.md
Claude Agent b92055eb01 feat(web): self-service cheie/creds + admin web + email signup (PRD 3.3b)
US-007: rute web proprii /cont/roteste-cheie + /cont/rar-creds scoped pe
sesiune (C13), sectiune "Contul meu" cu cheie afisata o data.
US-010: rol admin (users.is_admin) + require_admin->403 + CLI set-admin +
bootstrap primul cont=admin (count_admins in BEGIN IMMEDIATE, anti-race).
US-011: panou /admin (activare/dezactivare conturi, CSRF + PRG), link admin
+ logout pe dashboard.
US-012: app/email.py notify_signup best-effort degradat fara SMTP + config smtp_*.
Fix: migrare defensiva users.is_admin/email_verified in _migrate.

VERIFY x2 context curat (PASS) + /code-review high. 393 teste pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:19:06 +00:00

522 lines
35 KiB
Markdown

# PRD 3.3 — Self-onboarding web (login email+parola → emite cheie)
**Stare**: inchis (3.3a + 3.3b livrate)
> **Decizii la poarta EXECUTE (2026-06-17, confirmate de utilizator):**
> - **Livrabila sparta in doua faze** (scope 12 stories prea mare pentru un singur EXECUTE):
> - **3.3a (in executie acum)** — self-onboarding core: US-001, US-002, US-009, US-003, US-004,
> US-005, US-006a, US-006b, US-008. Commit + VERIFY propriu.
> - **3.3b (urmeaza)** — admin web + email: US-010, US-011, US-012. Commit + VERIFY propriu.
> - **Bootstrap admin (US-010, 3.3b):** primul cont creat devine automat admin (`is_admin=1`).
> - **US-012 email:** livrare DEGRADATA fara SMTP — doar log `SIGNUP cont=N email=...` (C16) +
> `/admin` (US-011) + `tools/account.py list --pending`. Trimiterea efectiva = follow-up cand exista SMTP.
> - Prerechizita C1 confirmata: 3.1 livrat (`app/accounts.py`, `tools/account.py`, `active` migrat in `_migrate`).
> Proces complet: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`.
> Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead).
>
> **Aceasta este cea mai mare livrabila a Etapei 3.** Schimba postura de securitate a intregului
> canal web (azi 100% deschis, hardcodat pe contul 1). Intrebarile din §5 trebuie rezolvate la
> poarta de aprobare ÎNAINTE de executie — in special **cine are voie sa se inregistreze**.
## 1. Obiectiv
Un service nou se inregistreaza singur din browser (companie + email + parola), primeste pe loc o
**cheie API** (afisata o singura data) si o **sesiune web** legata de contul lui. Contul se creeaza
**„in asteptare" (`active=0`)**: userul poate naviga, configura creds RAR si pregati import, dar
**nicio prezentare nu se trimite la RAR** pana cand un admin activeaza contul (`tools/account.py
activate`, din 3.1). Inlocuieste `account_id = DEFAULT_ACCOUNT_ID` hardcodat din `app/web/routes.py`
cu contul din sesiune, facand dashboard-ul si importul **multi-tenant**. Construieste peste 3.1
(helper cont + `active`) si peste pattern-ul de scoping din 3.2 (acum aplicat si rutelor web).
Fara dependinte noi: sesiuni via `starlette.middleware.sessions.SessionMiddleware` (cookie semnat,
`itsdangerous` deja prezent), parole via `hashlib.scrypt` (stdlib).
## 2. Non-Goals (anti scope-creep)
- **Fara reset parola pe email / verificare email** — MVP: parola setata la signup, schimbata doar
prin admin (sau livrabila viitoare). Niciun SMTP.
- **Fara multi-user per cont in UI** — schema permite (tabela `users` cu `account_id`), dar fluxul
MVP creeaza **un user → un cont nou**. Invitare de colegi = viitor.
- **Fara roluri/permisiuni** — un user vede integral contul lui.
- **Fara admin web** — listarea/stergerea conturilor ramane CLI (3.1).
- **Fara OAuth/SSO**.
- **Setarea creds RAR ramane prin fluxul existent** (`POST /v1/conturi/rar-creds`); un buton web care
il apeleaza e optional (US-007), nu inima livrabilei.
## 3. Stories atomice
### US-001: Schema `users` + helper-e `app/users.py` (parole scrypt)
**Ca** dezvoltator **vreau** un model de utilizator cu parola hash-uita **pentru ca** autentificarea
web are nevoie de o identitate legata de un cont, separat de cheia API si de creds RAR.
- **Depinde de**: 3.1/US-001 (`app/accounts.py`)
- **Fisiere**: `app/schema.sql` (tabela `users`), `app/db.py` (migrare `_migrate`),
`app/users.py` (nou), `tests/test_users.py` (nou) (~4 fisiere)
- **Test intai (RED)**: `tests/test_users.py``test_create_user_hash_nu_e_plaintext`,
`test_verify_parola_corecta_si_gresita`, `test_email_unic_global`, `test_get_user_by_email`
- **Acceptance criteria**:
- [ ] `users(id, account_id FK, email TEXT UNIQUE COLLATE NOCASE, password_hash, salt, created_at)`
creata in `schema.sql` + migrata idempotent in `_migrate` (DB existenta nu se strica).
- [ ] `create_user(conn, account_id, email, password) -> int`: stocheaza **doar** `scrypt(salt+parola)`
+ `salt` (per-user, `secrets.token_bytes`), niciodata parola in clar.
- [ ] `verify_password(conn, email, password) -> int | None`: intoarce `account_id` la potrivire,
`None` altfel (comparatie constant-time `hmac.compare_digest`).
- [ ] `email` duplicat (case-insensitive) → `ValueError`.
- [ ] `get_user_by_email(conn, email)` intoarce metadate fara hash/salt.
- **Verificare E2E**: n/a (helper) — teste unitare.
### US-002: Middleware sesiune + guard web
**Ca** dezvoltator **vreau** sesiuni cookie semnate + un helper de contul-curent **pentru ca**
rutele web sa stie cine e logat si sa redirectioneze nelogatii.
- **Depinde de**: US-001
- **Fisiere**: `app/config.py` (`session_secret`), `app/main.py` (`add_middleware`),
`app/web/session.py` (nou: `current_account(request)`, `require_login`),
`tests/test_web_session.py` (nou) (~4 fisiere)
- **Test intai (RED)**: `tests/test_web_session.py``test_ruta_protejata_redirect_login`,
`test_sesiune_seteaza_si_citeste_cont`, `test_logout_curata_sesiunea`
- **Acceptance criteria**:
- [ ] `AUTOPASS_session_secret` in `Settings` (default efemer dev, persistent in prod, ca `creds_key`).
- [ ] `SessionMiddleware` montat in `main.py` cu secretul; cookie `httponly`, `samesite=lax`.
- [ ] `current_account(request) -> int | None` citeste `request.session["account_id"]`.
- [ ] `require_login` (dependency): nelogat → `RedirectResponse('/login', 303)`.
- **Verificare E2E**: GET `/` fara sesiune → redirect `/login`; cu sesiune setata → 200.
### US-003: Signup web (`GET/POST /signup`)
**Ca** service nou **vreau** sa-mi creez cont din browser **pentru ca** sa incep fara admin.
- **Depinde de**: US-001, US-002, 3.1/US-001
- **Fisiere**: `app/web/auth_routes.py` (nou), `app/web/templates/signup.html` (nou),
`tests/test_web_signup.py` (nou) (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_signup.py``test_signup_creeaza_cont_user_si_cheie`,
`test_signup_email_duplicat_eroare`, `test_signup_parola_scurta_eroare`, `test_cheie_afisata_o_data`
- **Acceptance criteria**:
- [ ] `GET /signup` randeaza formular (companie, CUI optional, email, parola).
- [ ] `POST /signup`: `create_account(active=False)` (3.1) + `create_user` + `create_api_key`
intr-o tranzactie; seteaza sesiunea; randeaza pagina cu **cheia afisata o singura data** +
avertisment + mesaj clar „cont in asteptare — trimiterea incepe dupa activarea de catre admin".
- [ ] Email duplicat → re-randare cu eroare, **fara** a crea cont orfan (tranzactie rollback).
- [ ] Parola sub minim (10 caractere, §5) → eroare, fara creare.
- **Verificare E2E**: browser pe `/signup` (Playwright MCP) → completare → vezi cheia `rfak_...` +
banner „in asteptare"; `python -m tools.apikey list` arata cheia; sesiunea e activa.
### US-004: Login / Logout web (`GET/POST /login`, `POST /logout`)
**Ca** utilizator existent **vreau** sa ma autentific **pentru ca** sa-mi accesez contul intre sesiuni.
- **Depinde de**: US-001, US-002
- **Fisiere**: `app/web/auth_routes.py`, `app/web/templates/login.html` (nou),
`tests/test_web_login.py` (nou) (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_login.py``test_login_corect_seteaza_sesiune`,
`test_login_gresit_401_fara_leak`, `test_logout_redirect_login`
- **Acceptance criteria**:
- [ ] `POST /login` cu credentiale corecte → sesiune setata + redirect `/`.
- [ ] Credentiale gresite → re-randare cu mesaj generic („email sau parola incorecte"), fara a
dezvalui daca emailul exista.
- [ ] `POST /logout``session.clear()` + redirect `/login`.
- **Verificare E2E**: browser `/login` → logare → dashboard; `/logout` → inapoi la `/login`.
### US-005: Dashboard scoped pe sesiune (citiri)
**Ca** utilizator logat **vreau** ca dashboard-ul sa-mi arate doar contul meu **pentru ca**
multi-tenant inseamna ca nu vad coada/banner-ul altui service.
- **Depinde de**: US-002
- **Fisiere**: `app/web/routes.py` (rutele GET dashboard + `_fragments/*`),
`tests/test_dashboard_scope.py` (nou) (~2 fisiere)
- **Test intai (RED)**: `tests/test_dashboard_scope.py``test_counts_doar_contul_sesiunii`,
`test_submissions_fragment_scoped`, `test_nelogat_redirect`
- **Acceptance criteria**:
- [ ] `_status_counts`, `_fragments/banner`, `_fragments/submissions` filtreaza pe
`current_account(request)` (NULL→cont 1, ca in 3.2).
- [ ] Rutele dashboard cer `require_login` (sau degradeaza la `/login`).
- [ ] `_fragments/nomenclator` ramane global (referinta, ca in 3.2).
- **Verificare E2E**: doi useri, doua conturi, prezentari diferite → fiecare vede doar coada lui.
### US-006: Import web legat de sesiune (scriere)
**Ca** utilizator logat **vreau** ca importul sa intre pe contul meu **pentru ca** azi toate
upload-urile aterizeaza pe contul 1, indiferent cine le face.
- **Depinde de**: US-002
- **Fisiere**: `app/web/routes.py` (rutele `/_import/*` + `/mapari`),
`tests/test_import_web_scope.py` (nou) (~2 fisiere)
- **Test intai (RED)**: `tests/test_import_web_scope.py``test_upload_pe_contul_sesiunii`,
`test_commit_creeaza_submissions_pe_cont`, `test_batch_alt_cont_inaccesibil`
- **Acceptance criteria**:
- [ ] Toate aparitiile `account_id = DEFAULT_ACCOUNT_ID` din rutele `/_import/*` + `POST /mapari`
web → `current_account(request)`.
- [ ] Un batch al contului A nu e accesibil din sesiunea contului B (preview/confirma/mapare → eroare).
- [ ] `submissions`/`import_batches` create primesc `account_id`-ul sesiunii.
- **Verificare E2E**: import complet prin browser ca user B → submissions au `account_id=B`;
`./start.sh test finalizate` (cu send) sau dashboard-ul lui B le arata, al lui A nu.
### US-007 (optional): Sectiune „Cheia mea API" + creds RAR in dashboard
**Ca** utilizator logat **vreau** sa-mi rotesc cheia si sa-mi setez creds RAR din UI **pentru ca**
sa fiu complet self-service fara CLI.
- **Depinde de**: US-002, US-005
- **Fisiere**: `app/web/routes.py`, `app/web/templates/_cont.html` (nou),
`tests/test_web_cont.py` (nou) (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_cont.py``test_roteste_cheie_afisata_o_data`,
`test_set_creds_rar_din_sesiune`
- **Acceptance criteria**:
- [ ] Buton „roteste cheia" → `rotate_api_key(conn, current_account)`, cheie noua afisata o data.
- [ ] Formular creds RAR → reuseaza logica `POST /v1/conturi/rar-creds` pe contul sesiunii.
- **Verificare E2E**: rotire din UI → cheia veche respinsa pe `/v1/*`, cea noua acceptata.
### US-008: Gate de trimitere pentru conturi „in asteptare"
**Ca** operator RAR **vreau** ca prezentarile unui cont neactivat sa **nu** ajunga la RAR **pentru ca**
self-signup nu trebuie sa permita trimiteri reale inainte de validarea de catre admin.
- **Depinde de**: 3.1/US-001 (`accounts.active`)
- **Fisiere**: `app/worker/__main__.py` (claim), `app/web/templates/_banner.html` (+ `routes.py`),
`tests/test_worker_active_gate.py` (nou) (~3 fisiere)
- **Test intai (RED)**: `tests/test_worker_active_gate.py``test_claim_sare_cont_inactiv`,
`test_claim_ia_cont_activ`, `test_activare_deblocheaza_trimiterea`
- **Acceptance criteria**:
- [ ] Claim-ul worker-ului **nu** ridica submission-uri al caror cont are `active=0` (raman `queued`,
fara retry/eroare — pur si simplu nealese pana la activare).
- [ ] Dupa `set_active(.., True)`, la urmatorul poll submission-urile devin eligibile, fara re-enqueue.
- [ ] Dashboard-ul contului in asteptare arata un banner „cont in asteptare — trimiterea e oprita".
- [ ] Conturile fara `active` explicit (legacy) sunt tratate ca active (NULL/absent → activ).
- **Verificare E2E**: signup → import + confirma → submission `queued` ramane netrimis cat timp contul
e inactiv; `tools/account.py activate` → worker il trimite → `FINALIZATA` la RAR test.
## 4. Riscuri
- **Signup deschis = abuz** — oricine isi face cont. Pentru un gateway B2B legat de RAR, asta poate
fi nedorit. Mitigare posibila: cod de invitatie / aprobare admin / rate-limit. **Decizie §5 — blocant.**
- **Suprafata de securitate noua** — canalul web trece de la „deschis" la „autentificat"; orice ruta
web nescoped ramasa = leak. Mitigare: US-005+US-006 acopera **toate** rutele; VERIFY enumera explicit
fiecare ruta `app/web/routes.py` si confirma scoping (grep `DEFAULT_ACCOUNT_ID` = 0 rezultate la final).
- **Secret de sesiune** — daca `AUTOPASS_session_secret` e efemer in prod, cookie-urile se invalideaza
la restart. Mitigare: documentat ca persistent in `.env`, ca `creds_key`.
- **Atomicitate US-006** — `routes.py` e mare; riscul e sa ramana un `DEFAULT_ACCOUNT_ID`. Mitigare:
test care face grep in sursa + verificare comportamentala per ruta.
- **Interactiune cu 3.2** — daca 3.2 nu e inca livrat, scoping-ul web (US-005/006) introduce pattern-ul
separat; e ok (web nu trece prin `resolve_account_id`), dar mentinem aceeasi regula NULL→cont 1.
## 5. Intrebari deschise
> Primele doua REZOLVATE la poarta de aprobare; restul = propuneri implicite (confirmabile la review).
1. **[REZOLVAT] Cine se poate inregistra?** Signup creeaza **cont „in asteptare" (`active=0`)** pe care
adminul il activeaza inainte de prima trimitere (optiunea (c)). Vezi US-003 + US-008.
2. **[REZOLVAT] Useri per cont?** Schema `users` cu `account_id` (permite multi-user viitor); fluxul
MVP = **un user creeaza un cont nou**, login intra pe contul lui.
3. **Politica de parola** — minim 10 caractere, fara alte reguli (lungime > complexitate). (implicit)
4. **Persistenta sesiunii** — cookie semnat (`SessionMiddleware`), suficient pentru MVP. (implicit)
5. **Contul default (id=1)** — ramane „contul dev fara login", neatins; userii reali primesc id≥2.
Dashboard fara sesiune in dev (`require_api_key=false`) → cont 1, ca azi. (implicit)
6. **US-007 in scope?** Optional; se poate taia pentru o livrabila mai mica. (implicit: il pastram)
## 6. Valuri de executie (graful de dependente)
```
Val 1: [US-001] ← schema + helper users (depinde de 3.1/US-001 livrat)
Val 2: [US-002] ← sesiune + guard (peste users)
Val 3: [US-003, US-004, US-005, US-006, US-008] ← deblocate de US-002 (US-008 doar de 3.1/US-001).
US-005 si US-006 ating ambele routes.py (FISIER COMUN) →
nu paralel intre ele. US-003/US-004 (auth_routes.py),
US-008 (worker) ating fisiere distincte → pot merge paralel.
Val 4: [US-007] ← optional, peste US-005
```
> **Nota de paralelizare:** US-005 + US-006 modifica acelasi `app/web/routes.py` → secvential sau
> worktree+merge (§5.5). Un grupaj sigur: teammate A = US-003+US-004 (`auth_routes.py`), teammate B =
> US-005 apoi US-006 (`routes.py`), serializat. Lead-ul ruleaza regresia dupa fiecare.
---
## Addendum review (autoplan, `[subagent-only]` — Codex indisponibil: usage limit)
> Doua voci Claude independente (Eng + Produs/Design/DX). Securitate notata 4/10 — cea mai slaba
> dimensiune, fiindca livrabila *introduce* suprafata cookie-auth. Doua blocante critice. Schimbari
> obligatorii la executie; trei decizii de scope ridicate la poarta.
### Blocante (rezolvabile inainte de executie)
**C1 [CRITICAL, Eng] — Dependinta 3.1 e fantoma in repo.** `app/accounts.py`, `tools/account.py`,
`create_account`, `set_active` **nu exista azi**; `accounts.active` e in `schema.sql` dar **NU in
`_migrate`** → orice DB veche da `OperationalError: no such column` la US-008. Fix: 3.1 e
prerechizita HARD; la poarta de executie a lui 3.3 verifica livrarea 3.1 (inclusiv migrarea
`active` in `_migrate`). Nu porni 3.3 fara 3.1 inchis.
**C2 [CRITICAL, ambele voci] — CSRF complet absent → STORY NOU US-009.** Dupa 3.3 auth web e
exclusiv cookie de sesiune; fiecare POST de stare (`/signup`, `/login`, `/logout`, `/_import/*`,
`/mapari`, rotire cheie) devine vulnerabil CSRF — regresie introdusa DE aceasta livrabila.
`SameSite=Lax` (US-002) NU acopera POST-uri top-level. US-009: token CSRF in sesiune + camp ascuns
in toate formularele + validare server-side; `SameSite=Strict` pe cookie-ul de sesiune ca aparare
in adancime. Depinde de US-002; blocheaza US-003/004/006b/007.
### Securitate (obligatoriu in stories existente)
**C3 [HIGH, Eng] — Fixare sesiune:** la login (US-004) `request.session.clear()` INAINTE de a seta
`account_id` (+ nonce nou). AC nou.
**C4 [MEDIUM, Eng] — Cookie `secure`/`https_only`:** US-002 adauga `https_only=True` (config-abil,
default on in prod; in spatele Cloudflare Tunnel e TLS). Plus `samesite` aliniat cu C2.
**C5 [MEDIUM, ambele — rate-limit signup] → in US-009.** `active=0` opreste trimiterea, NU crearea
nelimitata de conturi/useri/chei (DoS storage + zgomot admin). Adauga rate-limit pe IP la
`POST /signup` (in-proces, fara dependinta noua). Reformuleaza §5.1: nu "REZOLVAT" ci "DECIS:
cont in asteptare + activare admin; risc de recon acceptat pentru MVP + rate-limit".
### Atomicitate scoping (redefineste "done")
**C6 [CRITICAL, Eng] — Testul `grep DEFAULT_ACCOUNT_ID = 0` e fals-linistitor.** Rutele GET
periculoase NU contin acel literal: `_status_counts` (l.59), `fragment_submissions` (l.146-149,
`SELECT ... FROM submissions` fara `WHERE account_id`), `fragment_banner`. Grep=0 ar declara "done"
in timp ce dashboard-ul lui B arata coada lui A. Fix US-005: adauga `account_id` +
`WHERE account_id` (clauza scope din 3.2/B2) la `_status_counts`/`fragment_submissions`/
`fragment_banner`. "Done" = comportamental (test pe 2 conturi), grep ramane doar smoke.
**C7 [HIGH, Eng] — Sparge US-006** in **US-006a** (citiri: upload/preview/reset/mapare-coloane) +
**US-006b** (scrieri: `confirma` ~250 linii cu tranzactie/atestare + `POST /mapari`). Un
`account_id` ramas in `confirma` → submissions pe contul gresit, irecuperabil dupa send
(`FINALIZATA` terminal). 006b primeste E2E dedicat pe 2 conturi.
**C8 [HIGH, Eng — OV-2] — Propagare consecventa `current_account`.** `build_key` /
`_build_idempotency_key` / `_already_sent_lookup` / `_web_compute_preview` trebuie sa primeasca
ACELASI `account_id` (sesiunea). Test: un rand importat web sub cont N primeste aceeasi cheie ca
prin API sub cont N (altfel reapare OV-2). AC in US-006b.
### Corectitudine helper-e / model
**C9 [MEDIUM, ambele] — Parametri scrypt ficsi (US-001):** `hashlib.scrypt(password=parola,
salt=salt, n=2**14, r=8, p=1, maxmem=64*1024*1024, dklen=32)``salt`/`parola` argumente separate
(NU concatenate). Stocheaza si o eticheta de versiune a parametrilor (migrare cost viitoare). Plafon
lungime parola (ex. 128 char) — scrypt pe 1MB = DoS.
**C10 [HIGH, Eng] — Tranzactie signup atomica (US-003):** `BEGIN IMMEDIATE``create_account`
(active=False) → `create_user` (poate ridica pe `email UNIQUE` AICI) → `create_api_key` → COMMIT;
orice exceptie → ROLLBACK. Test `test_signup_email_duplicat` asereaza `COUNT(accounts)` neschimbat
(fara cont orfan). DB e autocommit → tranzactia trebuie explicita.
**C11 [MEDIUM, Eng] — `require_login` ca dependency care intoarce `RedirectResponse` NU
scurtcircuiteaza handler-ul.** Trebuie sa RIDICE o exceptie prinsa de un handler care
redirectioneaza (sau middleware pe prefixele web). Clarifica mecanismul in US-002.
**C12 [MEDIUM, Eng] — `require_login` vs cont-1-in-dev.** §5.5 zice "dev fara sesiune → cont 1" dar
US-005 cere `require_login`. Bypass cand `require_api_key=false` (sau flag `web_auth_required`),
cazand pe cont 1. Specifica conditia exacta. Pe scrieri web `current_account is None` → redirect
login, NICIODATA fallback `account_or_default` (leak silentios pe cont 1).
**C13 [HIGH, Eng] — US-007 vs `require_api_key`.** `POST /v1/conturi/rar-creds` cere cheie API; un
user web logat fara cheie in header → 401 in prod. US-007 are nevoie de **ruta web proprie** scoped
pe sesiune (nu reuseste endpoint-ul API), SAU un dependency hibrid "sesiune SAU cheie".
### Gate worker (US-008)
**C14 [MEDIUM, Eng] — Query claim cu `LEFT JOIN` + dublu-NULL.**
`LEFT JOIN accounts a ON a.id=s.account_id WHERE COALESCE(a.active,1)=1`. Atentie la DOUA NULL-uri:
(a) cont legacy fara `active``COALESCE`; (b) `submissions.account_id IS NULL` (FK
`ON DELETE SET NULL`) → ar fi blocat pe veci cu JOIN intern; `LEFT JOIN` + `s.account_id IS NULL` =
tratat ca default/activ. Specifica query-ul exact in AC.
### UX / produs (Produs voce)
**C15 [HIGH] — Pagina "cheia o data" (US-003)** subspecificata. AC noi: buton copy-to-clipboard +
confirmare; gate "am salvat cheia" inainte de CTA dashboard; comportament la refresh (POST→render,
cheia NU e in sesiune → refresh = pierdere; recuperare doar prin rotire US-007 / CLI admin) — scris
explicit in UI.
**C16 [HIGH] — Activarea admin nu are mecanism de descoperire/notificare.** Fara admin web, adminul
ar rula `tools/account.py list` din proprie initiativa. Fix: la signup, linie de log dedicata
(`SIGNUP cont=N email=...`) + `tools/account.py list --pending` (din addendum 3.1/A6). Dashboard-ul
contului arata starea explicit ("in asteptare de la {created_at}"). Documenteaza procesul+SLA de
activare in PRD (vezi user challenge UC-2 la poarta).
**C17 [MEDIUM] — Reframe pozitiv banner "in asteptare"** + stari HTMX: submit dezactivat + indicator
pe POST (signup face 3 operatii); erorile re-randeaza formularul cu valorile pastrate (Mai putin
parola — confirma ca nu re-pui parola in `value=`). Banner: "Contul e creat. Configureaza creds RAR
si pregateste importul ACUM; trimiterea catre RAR porneste automat dupa activare."
**C18 [MEDIUM] — Stari/erori lipsa:** login pe cont `active=0` intra (gate-ul e doar pe trimitere —
confirma explicit); cont activat intre timp → banner dispare (clarifica ca e alt banner decat cel de
submissions blocate); email normalizat (`trim` + `COLLATE NOCASE`); sesiune cu `account_id` pe cont
sters → redirect login, nu query gol tacut.
**C19 [MEDIUM, P2 ieftin — pregatire viitor]:** `users.email_verified INTEGER DEFAULT 0` de la
inceput (evita migrare dureroasa cand apare verificarea email). Pune si `user_id` in sesiune (nu
doar `account_id`) — leaga `import_attestations.confirmed_by` de user mai tarziu fara migrare.
### Decizii ridicate la poarta (taste + user challenges)
- **UC-1 [user challenge, ambele voci]** — NU lansa signup fara rate-limit + NU marca §5.1
"REZOLVAT". (Auto-adaugat in C5/US-009; confirma la poarta.)
- **UC-2 [user challenge, Produs]** — Defineste PROCESUL de activare admin (descoperire + SLA)
inainte de aprobare, nu ca detaliu de implementare. Acesta e arcul care va genera cele mai multe
tichete. Intrebare de produs pentru tine.
- **UC-3 [user challenge, Produs]** — **Promoveaza US-007 din "optional" in scope** (cel putin
rotire cheie + formular creds RAR). Fara el livrabila nu-si tine numele "self-onboarding": userul
e dependent de admin/CLI exact pentru pasii self-service. Decizie de scope pentru tine.
- **Taste:** scrypt stdlib vs argon2id (zero-dependinta vs purist securitate); gate-activare vs
auto-activ+monitorizare reactiva.
### Graf revizuit (stories adaugate/sparte)
```
Val 1: [US-001] (peste 3.1 livrat — C1)
Val 2: [US-002] → [US-009 CSRF+rate-limit, C2/C5]
Val 3: [US-003, US-004, US-005, US-006a, US-006b, US-008]
US-005 + US-006a/b ating routes.py (comun) → secvential; US-003/004 (auth_routes.py),
US-008 (worker), US-009 (cross-cutting) — fisiere distincte, paralelizabile cu grija.
Val 4: [US-007] ← promovat la poarta (UC-3): rotire cheie + creds RAR web (ruta proprie, C13)
```
## Decizii poarta (pliate — aprobat de utilizator)
> Acestea EXTIND scope-ul fata de draftul initial. Confirma graful revizuit la inceputul EXECUTE.
**G1 — US-007 PROMOVAT in MVP (nu mai e optional).** Rotire cheie (recuperare cheie pierduta) +
formular creds RAR pe **ruta web proprie** scoped pe sesiune (C13, nu reuseste endpoint-ul API care
cere cheie). Devine obligatoriu pentru ca livrabila sa fie cu adevarat self-service.
**G2 — INTERFATA WEB DE ADMIN ceruta explicit ("chiar vreau interfata admin").** Inverseaza
Non-Goal-ul "fara suprafata HTTP de admin" (3.1). Stories noi:
- **US-010: Rol admin + bootstrap.** `users.is_admin INTEGER DEFAULT 0` (migrare). Primul cont
(sau marcat via `tools/account.py set-admin --account N`) e admin. Guard `require_admin` (peste
`require_login` + CSRF). Test: non-admin pe ruta admin → 403/redirect.
- *Depinde de*: US-001, US-002, US-009 (CSRF).
- *Intrebare deschisa (vezi §5)*: cum se bootstrapeaza primul admin?
- **US-011: Panou admin web `/admin` — conturi in asteptare + activare.** Listeaza conturile
`active=0` (email, companie, CUI, created_at) + buton "activeaza" (`set_active(.., True)` din 3.1,
POST cu CSRF). Optional: dezactivare, vedere chei (fara hash). Template `admin.html`.
- *Depinde de*: US-010, 3.1/US-001 (`set_active`).
- *E2E*: signup (cont nou inactiv) → admin se logheaza → vede contul in `/admin` → activeaza →
worker il trimite → `FINALIZATA` la RAR test.
**US-012: Notificare email admin la signup.** La `POST /signup`, trimite email catre admin(i)
("cont nou N in asteptare"). **Adauga dependinta SMTP** (config nou `AUTOPASS_smtp_*`) — reverseaza
Non-Goal-ul "niciun SMTP". Esecul trimiterii email NU blocheaza signup-ul (best-effort + log).
- *Depinde de*: US-003, US-010 (sa stie cui trimite).
- *Intrebare deschisa*: provider SMTP / adresa expeditor / fallback daca SMTP pica.
> Mecanismul de descoperire e acum redundant-sigur: log `SIGNUP` (C16) + `list --pending` (CLI) +
> email (US-012) + panou `/admin` (US-011). C16 ramane ca baseline daca SMTP/panoul intarzie.
**Graf revizuit final:**
```
Val 1: [US-001]
Val 2: [US-002] → [US-009 CSRF+rate-limit]
Val 3: [US-003, US-004, US-005, US-006a, US-006b, US-008, US-010]
Val 4: [US-007, US-011 admin panou, US-012 email] (US-007/011/012 peste US-010/US-009)
```
> **Scope mai mare decat draftul** (7 → 12 stories). 3.3 e acum substantiala; daca devine prea grea
> pentru o singura faza EXECUTE, lead-ul o poate sparge in sub-livrabile la inceputul executiei
> (ex. 3.3a self-onboarding core, 3.3b admin web + email) — decizie de orchestrare, nu de scope.
## Intrebari deschise nou aparute (rezolva la inceputul EXECUTE)
- **Bootstrap admin (US-010):** primul cont devine automat admin, SAU adminul se marcheaza manual
via `tools/account.py set-admin --account N`? Propunere: manual via CLI (explicit, fara magie
"primul user"); contul default id=1 nu e admin automat.
- **SMTP (US-012):** ce provider/expeditor? Daca nu exista SMTP la momentul executiei, US-012 se
livreaza degradat (doar log + `/admin` + `list --pending`) si email-ul devine follow-up.
## Progres executie 3.3a (lead)
> Sub-livrabila 3.3a (self-onboarding core). Toate stories GREEN, regresie 355 pass (de la 313 baseline).
- [x] **US-001** — schema `users` (+ `email_verified`, `is_admin` pregatire 3.3b) + `app/users.py` (scrypt n=2^14, plafon 128 char, hmac.compare_digest). 6 teste.
- [x] **US-002**`SessionMiddleware` (same_site=strict, https_only config) + `app/web/session.py` (`current_account`/`current_user_id`/`web_account`/`require_login``LoginRequired`/`set_session` clear-inainte C3). 6 teste.
- [x] **US-009**`app/web/csrf.py` (token per-sesiune, `verify_csrf` gateat pe MOD: prod sau sesiune autentificata) + `app/web/ratelimit.py` (fereastra glisanta in-proces) + handler `CsrfError`→403. Gate-ul inchide login/signup CSRF in prod (C2). 8 teste.
- [x] **US-003**`GET/POST /signup` (`auth_routes.py` + `signup.html`): tranzactie atomica C10, log `SIGNUP` C16, cheie-o-data + gate C15, banner pozitiv C17. 4 teste.
- [x] **US-004**`GET/POST /login` + `POST /logout`: mesaj generic la esec, login pe `active=0` intra (C18), clear sesiune C3. 4 teste.
- [x] **US-005** — dashboard scoped pe sesiune (`_status_counts`/`fragment_submissions`/`fragment_banner` cu regula NULL→1, C6) + banner "cont in asteptare" (US-008 AC#3, reframe C17). nomenclator ramane global. 4 teste.
- [x] **US-006a** — citiri import (`upload`/`mapare-coloane`/`preview`) pe `web_account(request)`; batch cross-cont inaccesibil.
- [x] **US-006b** — scrieri (`confirma` + `/mapari`) pe sesiune; propagare consecventa `account_id` la `build_key` (C8/OV-2); `verify_csrf` + camp ascuns pe toate formularele; zero atribuiri `DEFAULT_ACCOUNT_ID` in handlere (C6). 5 teste.
- [x] **US-008** — gate worker `claim_one` cu `LEFT JOIN accounts ... COALESCE(active,1)=1` (dublu-NULL C14): cont inactiv → submission ramane `queued`; activare → eligibil fara re-enqueue. 5 teste.
3.3b (US-007 self-service cheie/creds + US-010/011/012 admin web + email) ramane livrabila separata.
## Raport VERIFY (3.3a)
> Doua runde de verificare independenta (subagent context curat, §5.6).
**Runda 1 — FAIL (1 criteriu):** suita 355 pass, dar sweep-ul anti-leak a gasit `GET /_fragments/mapari`
nescoped: `pending_unmapped(conn)` fara `account_id` + fara `require_login` → expune `cod_op_service`/
`denumire` cross-account (Risc #2/C6). Specul US-005 enumerase doar `_status_counts`/`fragment_submissions`/
`fragment_banner` si omisese acest fragment. → inapoi la EXECUTE (task fix).
**Fix (task #7):** `fragment_mapari``require_login(request)`; `_render_mapari(account_id)`
`pending_unmapped(conn, account_id)`; `post_mapare` paseaza consecvent contul sesiunii. 2 teste noi de
izolare pe 2 conturi (`tests/test_mapari_scope.py`).
**Runda 2 — PASS global (subagent NOU):**
- Suita: **357 passed**, 0 fail.
- Sweep anti-leak complet (toate rutele `routes.py` + `auth_routes.py`): fiecare ruta care atinge
`submissions`/`import_batches`/`column_mappings`/`operations_mapping` e sub `require_login` SI scoped pe
contul sesiunii. Publice intentionat: `/signup`, `/login`, `/logout`, `/_fragments/nomenclator` (global),
`/_import/reset` (template gol, fara DB). `fragment_mapari` fix confirmat.
- Criterii securitate critice re-verificate in cod: CSRF enforce in prod pe `/login`+`/signup` fara
`account_id` (US-009/C2); signup tranzactie atomica cu ROLLBACK pe email duplicat, fara cont orfan
(US-003/C10); `claim_one` `COALESCE(a.active,1)=1` cu LEFT JOIN, `account_id` NULL=activ (US-008/C14);
parola scrypt, niciodata in clar (US-001).
- E2E HTTP mod prod (`web_auth_required=true`): `GET /_fragments/mapari` fara cookie → 303 `/login`;
signup → cheie `rfak_` o data + cont `active=0` + log `SIGNUP cont=N`; cu sesiune → 200 doar contul propriu.
- Regresia de aur: `test_import_e2e.py` + `test_api.py` = 26 pass. Send live RAR neverificat (fara creds/retea
in mediul de VERIFY), dar acoperit de teste.
**Verdict: PASS.** Send live la RAR test ramane de confirmat manual la deploy (canal API + import → `FINALIZATA`).
### Code-review (CLOSE, /code-review high) — 3 findings reparate
- **[HIGH] `csrf_token` lipsa pe re-randarile de eroare** (`routes.py`): ramurile de eroare din
`web_upload_import`/`web_save_mapare_coloane`/`web_confirma_import` randau formularul fara `csrf_token`
→ in prod (user logat, CSRF enforce) campul ascuns gol → urmatorul submit 403 (lockout dupa orice eroare).
Fix: helper `_ctx(request, **extra)` care include mereu `csrf_token` + conversia tuturor ramurilor;
`require_login` reordonat inaintea `verify_csrf`. Test nou de regresie in mod prod.
- **[MEDIUM] `verify_password` ignora `scrypt_params` stocat** (`users.py`): folosea constantele curente,
anuland migrarea de cost (C9) — un bump viitor de `n` ar fi blocat toti userii existenti. Fix:
`_parse_scrypt_params` + verify cu parametrii din DB (eticheta corupta → `None`, fara crash). Test de migrare cost.
- **[MEDIUM] login fara rate-limit** (`auth_routes.py`): brute-force parole + DoS CPU (scrypt/cerere).
Fix: `check_rate_limit("login:"+ip, login_rate_max=10, ...)` → 429. (Extinde C5 dincolo de signup.)
Suita finala: **361 passed, 0 fail.** Findings low/by-design neactionate (documentate): dev-fallback cont 1
cand `web_auth_required=False` (C12, intentionat — atentie ops la deploy prod), 500 rar la DB-locked in signup,
`request.client is None` → bucket rate-limit 'unknown' partajat.
## Progres executie 3.3b (lead)
> Sub-livrabila 3.3b (self-service cheie/creds + admin web + email). Decizii confirmate la poarta:
> primul cont care se inregistreaza devine admin (bootstrap automat); US-012 livrare DEGRADATA fara
> SMTP (helper `app/email.py` best-effort no-op + log `SIGNUP` deja existent din 3.3a).
Valuri (fisiere disjuncte intre stories paralele):
- **Val 1:** US-010 (`users.py`/`session.py`/`tools/account.py`/`main.py` handler) ‖ US-007 (`routes.py`/`_cont.html`/`dashboard.html`)
- **Val 2:** US-011 (`admin_routes.py` nou/`admin.html`/`main.py` register) ‖ US-012 (`email.py` nou/`config.py`/`auth_routes.py`)
- [x] **US-010** — rol admin (`is_admin`) + helper-e (`count_admins`/`set_admin`/`is_account_admin`/`list_admin_emails`) + `require_admin``AdminRequired`→403 + CLI `set-admin`. 13 teste. Bootstrap (primul cont=admin) cablat in signup de US-012 (evita conflict pe auth_routes.py).
- [x] **US-007** — sectiune "Contul meu" (`/_fragments/cont`): rotire cheie (afisata o data) + creds RAR pe ruta web proprie scoped pe sesiune (`POST /cont/roteste-cheie`, `POST /cont/rar-creds`, C13, NU endpointul API). 5 teste.
- [x] **US-011** — panou `/admin` (`admin_routes.py`): conturi in asteptare/active + activare/dezactivare (require_admin + CSRF + PRG); contul dev id=1 fara butoane. Link "Panou admin" pe dashboard doar pentru admini + buton logout. 5 + 2 teste.
- [x] **US-012**`app/email.py` `notify_signup` best-effort (no-op fara `smtp_host`, prinde orice exceptie SMTP, timeout 5s) + config `smtp_*` + cablaj signup: bootstrap admin (primul cont = admin via `count_admins==0`) + notificare degradata dupa `set_session`. 5 teste.
- [x] **Fix migrare (din VERIFY r1):** `_migrate` adauga defensiv `users.is_admin`/`email_verified` pe DB cu tabela `users` fara ele (idempotent, guard pe existenta tabelei). 2 teste.
## Raport VERIFY (3.3b)
> Doua runde de verificare independenta (subagent context curat, §5.6).
**Runda 1 — FAIL (1 criteriu):** suita 391 pass, toate criteriile US-007/010/011/012 confirmate + sweep
securitate complet (toate rutele noi `/cont/*`, `/_fragments/cont`, `/admin*` sub `require_login`/`require_admin`
+ `verify_csrf` pe POST; `/cont/*` scoped strict pe sesiune, nu accepta `account_id` din form; `/admin` nu
expune hash/chei/creds in clar). DAR `_migrate` nu adauga defensiv `users.is_admin`/`email_verified`
o tabela `users` fara ele ar ceda cu `OperationalError` (acelasi tip de gap ca C1 pe `accounts.active`). → fix.
**Fix:** bloc `# Coloane users` in `app/db.py::_migrate` (guard pe existenta tabelei + ALTER idempotent). 2 teste.
**Runda 2 — PASS global (subagent NOU):**
- Suita: **393 passed**, 0 fail.
- Fix migrare confirmat (test pe `users` minima fara coloane → `_migrate` → coloane prezente; idempotent).
- E2E mod prod (`web_auth_required=true`): `GET /admin` fara cookie → 303 `/login`; non-admin logat → 403;
`POST /admin/activate` fara CSRF → 403. Rute `/cont/*` scoped pe sesiune, CSRF enforce, parola RAR niciodata in `value=`.
- US-010 bootstrap (primul signup → `is_admin=1`, al doilea → 0), CLI `set-admin`, `require_admin`→403 confirmate.
- US-012 `notify_signup` best-effort no-op fara SMTP + nu blocheaza signup + log `SIGNUP` pastrat.
- Regresia de aur: `test_import_e2e` + `test_api` + `test_worker_active_gate` = 31 pass.
**Verdict: PASS.** Send live RAR ramane de confirmat manual la deploy (fara creds/retea in mediul VERIFY).
La deploy prod: `AUTOPASS_session_secret` persistent, `AUTOPASS_WEB_AUTH_REQUIRED=true`, optional `AUTOPASS_smtp_*`.