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

@@ -48,7 +48,7 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi
> PRD-uri (`docs/prd/prd-X.Y-*.md`), linkate in coloana Detalii. La fiecare livrabila terminata:
> schimba statusul + data + linkul PRD si actualizeaza "Ultima actualizare".
**Ultima actualizare**: 2026-06-17 — 3.2 LIVRAT (scope pe cont la toate GET-urile API `/v1/*` care ating `submissions`/`operations_mapping`: `account_scope_clause` cu regula NULL→cont 1, 404 cross-account byte-identic, allowlist campuri pe detaliu, index `(account_id,status)`, regula B8 in contract; nomenclator ramane global. 14 teste noi, 313 pass. VERIFY=PASS context curat). Urmeaza 3.3 (self-onboarding web). Deferat din 3.1 (P3): `rename`/`set-cui`, `--if-not-exists`; `set-password --account N` in 3.3 cu `app/users.py`.
**Ultima actualizare**: 2026-06-17 — 3.3a LIVRAT (self-onboarding web core: `app/users.py` parole scrypt cu eticheta de parametri onorata la verify; `SessionMiddleware` same_site=strict + `app/web/session.py` guard `require_login``LoginRequired`; CSRF per-sesiune enforce in prod inclusiv pe login/signup + rate-limit signup & login in-proces; signup `active=0` tranzactie atomica + cheie-o-data + log `SIGNUP`; login/logout; dashboard & import multi-tenant scoped pe sesiune cu regula NULL→cont 1 — toate rutele web care ating date sensibile sub `require_login` + scope; gate worker `claim_one` `LEFT JOIN ... COALESCE(active,1)=1`. 2 runde VERIFY context curat — runda 1 a prins un leak cross-account pe `/_fragments/mapari`, reparat; runda 2 PASS. `/code-review` high a prins 3 findings, reparate. 361 teste pass). Urmeaza 3.3b (self-service cheie/creds + admin web + email). Deferat din 3.1 (P3): `rename`/`set-cui`, `--if-not-exists`.
### Etapa 1 — Canal API ROAAUTO (Treapta 1)
@@ -74,7 +74,8 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi
|---|-----------|--------|------|---------|
| 3.1 | Creare cont nou (CLI dedicat) | DONE | 2026-06-17 | CLI `tools/account.py` (create/list[--pending]/activate/deactivate, `--with-key` atomic) + `accounts.active` + index unic CUI + `app/accounts.py`. 20 teste noi. PRD: [prd-3.1](prd/prd-3.1-creare-cont.md) |
| 3.2 | Filtrare pe cont a GET-urilor de listare | DONE | 2026-06-17 | scope cheie pe `/v1/prezentari(/{id})`, `/v1/mapari(/pending)`, `/v1/audit/export` (NULL→cont 1); nomenclator global; 404 cross-account identic (B3) + allowlist campuri detaliu (B4) + helper `account_scope_clause` (B2) + index (B5). 14 teste noi, 313 pass. PRD: [prd-3.2](prd/prd-3.2-filtrare-cont-get.md) |
| 3.3 | Self-onboarding web + interfata admin | TODO (PRD aprobat) | | signup/login/sesiuni + cont "in asteptare" + gate worker + CSRF + panou admin web + email. 12 stories. PRD: [prd-3.3](prd/prd-3.3-self-onboarding-web.md) |
| 3.3a | Self-onboarding web (core) | DONE | 2026-06-17 | `users` (scrypt) + sesiune (`SessionMiddleware`, same_site=strict) + CSRF (enforce prod, inclusiv login/signup) + rate-limit signup/login + signup/login/logout + dashboard & import scoped pe sesiune (NULL→1, anti-leak C6) + gate worker `active=0` (`COALESCE`). 2 runde VERIFY (leak `/_fragments/mapari` prins+reparat) + code-review (csrf erori, scrypt_params, login rate-limit). 361 teste. PRD: [prd-3.3](prd/prd-3.3-self-onboarding-web.md) |
| 3.3b | Self-service cheie/creds + admin web + email | TODO (PRD aprobat) | | US-007 (rotire cheie + creds RAR pe ruta web), US-010 rol admin (primul cont=admin), US-011 panou `/admin` activare, US-012 email signup (degradat: log+`/admin`+`list --pending`, SMTP follow-up). PRD: [prd-3.3](prd/prd-3.3-self-onboarding-web.md) |
### Etapa 4 — Viitor (Treapta 3)

View File

@@ -1,6 +1,16 @@
# PRD 3.3 — Self-onboarding web (login email+parola → emite cheie)
**Stare**: aprobat
**Stare**: verify-pass (3.3a) — 3.3b deschis
> **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).
@@ -407,7 +417,65 @@ Val 4: [US-007, US-011 admin panou, US-012 email] (US-007/011/012 peste US-010
- **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.
## Raport VERIFY
## Progres executie 3.3a (lead)
> Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6.
> PASS/FAIL per criteriu, cu dovezi. Lipseste pana la VERIFY.
> 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.