# PRD 3.2 — Filtrare pe cont a GET-urilor de listare **Stare**: inchis > 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). ## 1. Obiectiv Inchide scurgerea de date intre conturi pe **canalul API**: azi `GET /v1/prezentari`, `/v1/prezentari/{id}`, `/v1/mapari(/pending)` si `/v1/audit/export` sunt **globale** — orice cheie valida vede coada, maparile si auditul **tuturor** conturilor. POST-urile sunt deja account-scoped (`resolve_account_id`); aducem GET-urile la aceeasi disciplina. Pre-cerinta de securitate inainte ca 3.3 sa aduca useri web reali. ## 2. Non-Goals (anti scope-creep) - **Nomenclatorul ramane global.** `GET /v1/nomenclator` + `nomenclator_rar` sunt cache de referinta RAR partajat (fara `account_id`, fara PII) — nu are sens sa-l filtram. Decizie explicita, nu omisiune. - **Fara auth pe rutele web.** Rutele `app/web/routes.py` raman hardcodate pe contul default (id=1) pana la 3.3. 3.2 priveste **doar** GET-urile API `/v1/*`. (`pending_unmapped` se parametrizeaza compatibil — vezi US-002 — ca web-ul sa ramana neschimbat.) - **Fara modificari la POST-uri** — sunt deja scoped. - **Fara paginare/filtre noi** — doar adaugam dimensiunea cont la ce exista. ## 3. Stories atomice > Invariant transversal: randuri **legacy cu `account_id` NULL** (OV-2) se trateaza ca apartinand > contului default (id=1). Filtrarea foloseste `account_or_default` + match pe NULL doar pentru id=1, > ca sa nu dispara prezentarile vechi din vederea contului 1. ### US-001: Scope cont pe `GET /v1/prezentari` + `GET /v1/prezentari/{id}` **Ca** detinator de cheie API **vreau** ca listarea/detaliul prezentarilor sa-mi arate **doar** contul meu **pentru ca** un client nu trebuie sa vada coada altui client. - **Depinde de**: — - **Fisiere**: `app/api/v1/router.py`, `tests/test_get_scope_prezentari.py` (nou) (~2 fisiere) - **Test intai (RED)**: `tests/test_get_scope_prezentari.py` — `test_lista_doar_contul_cheii`, `test_detaliu_cross_account_404`, `test_legacy_null_vizibil_pentru_cont_1`, `test_fara_cheie_flag_off_vede_contul_1` - **Acceptance criteria**: - [x] Ambele rute primesc `account_id: int = Depends(resolve_account_id)`. - [x] `GET /v1/prezentari` adauga `WHERE` pe cont (NULL→cont 1) la ambele ramuri (cu/fara `status`). - [x] `GET /v1/prezentari/{id}` al altui cont → **404** (nu 403 — nu confirmam existenta). - [x] Cheie A nu vede submission-uri ale contului B (lista si detaliu). - [x] `require_api_key=false` fara cheie → vede contul 1 (back-compat dev). - **Verificare E2E**: doua chei (conturi distincte, via 3.1) → `POST` pe fiecare → `GET /v1/prezentari` cu cheia A nu contine id-urile contului B. ### US-002: Scope cont pe `GET /v1/mapari` + `GET /v1/mapari/pending` **Ca** detinator de cheie API **vreau** ca maparile si pending-ul sa fie ale contului meu **pentru ca** azi `/v1/mapari?account_id=` accepta `account_id` din query (spoofabil) si `/pending` e global. - **Depinde de**: — - **Fisiere**: `app/api/v1/router.py`, `app/mapping.py` (parametru optional la `pending_unmapped`), `tests/test_get_scope_mapari.py` (nou) (~3 fisiere) - **Test intai (RED)**: `tests/test_get_scope_mapari.py` — `test_mapari_ignora_query_account_id`, `test_mapari_doar_contul_cheii`, `test_pending_doar_contul_cheii`, `test_pending_web_global_neschimbat` - **Acceptance criteria**: - [x] `GET /v1/mapari` foloseste `Depends(resolve_account_id)`; parametrul `account_id` din query este **eliminat** (un cont nu poate citi maparile altuia trecand un id arbitrar). - [x] `pending_unmapped(conn, account_id=None)` capata param optional: `None` = global (web, back-compat), valoare = filtrare pe cont. `GET /v1/mapari/pending` paseaza contul cheii. - [x] Apelul web `pending_unmapped(conn)` din `routes.py` ramane neatins (global) — confirmat de `test_pending_web_global_neschimbat`. - **Verificare E2E**: cheie A cu o mapare; cheie B → `GET /v1/mapari` (B) nu contine maparea lui A. ### US-003: Scope cont pe `GET /v1/audit/export` **Ca** detinator de cheie API **vreau** ca exportul de audit sa contina doar prezentarile mele **pentru ca** CSV-ul de audit expune VIN/nr. inmatriculare (PII) si nu trebuie sa traverseze conturi. - **Depinde de**: — - **Fisiere**: `app/api/v1/router.py`, `tests/test_get_scope_audit.py` (nou) (~2 fisiere) - **Test intai (RED)**: `tests/test_get_scope_audit.py` — `test_export_doar_contul_cheii`, `test_export_legacy_null_pentru_cont_1`, `test_export_status_all_tot_scoped` - **Acceptance criteria**: - [x] `audit_export` primeste `Depends(resolve_account_id)`; `_audit_rows` filtreaza pe cont (NULL→cont 1) pe langa filtrele de data/status existente. - [x] `status=all` ramane scoped pe cont (nu exporta global). - [x] Randurile contului B nu apar in CSV-ul cerut cu cheia A. - **Verificare E2E**: `POST` pe doua conturi → `GET /v1/audit/export` (cheie A) → CSV fara VIN-urile B. ## 4. Riscuri - **Back-compat dev** — cu `require_api_key=false` si fara cheie, totul colapseaza la contul 1; filtrarea trebuie sa ramana transparenta acolo (testat explicit). Mitigare: `account_or_default`. - **Randuri legacy NULL** — daca le excludem din vederea contului 1, prezentarile vechi „dispar" din dashboard/audit. Mitigare: regula NULL→cont 1 in fiecare `WHERE`, cu test dedicat. - **Regresie pe web** — `pending_unmapped` e partajat API/web; semnatura cu default `None` previne ruperea editorului web (acoperit de test). ## 5. Intrebari deschise > Se rezolva cu utilizatorul ÎNAINTE de executie (poarta de aprobare PRD). - **Nomenclatorul ramane global?** Propunere: da (referinta partajata, fara PII). Confirmi? - **`GET /v1/mapari` — pastram parametrul `account_id` din query, ignorat, sau il scoatem complet?** Propunere: il scoatem (semnal clar ca nu mai e configurabil din afara). Risca sa strice clienti care il trimiteau? (Azi nu exista clienti API multi-cont — risc practic zero.) ## 6. Valuri de executie (graful de dependente) ``` Val 1: [US-001, US-002, US-003] ← toate ating router.py (FISIER COMUN) → NU paralel pe acelasi fisier ``` > **Nota de paralelizare:** cele trei stories ating `app/api/v1/router.py` → conform §5.5 nu ruleaza > in paralel (fisier comun). Optiuni: secvential (un teammate, US-001→002→003) sau `isolation: > worktree` + merge de lead. Recomandare: **secvential, un singur teammate** — schimbarile sunt mici > si inrudite (acelasi pattern `Depends(resolve_account_id)` + `WHERE account_id`). --- ## Addendum review (autoplan, `[subagent-only]` — Codex indisponibil: usage limit) > Doua voci Claude independente (Eng + Produs/DX-API). Schimbari obligatorii la executie + decizii > ridicate la poarta. **B1 [MAJOR, ambele voci] — `pending_unmapped` filtreaza in SQL, nu post-hoc in Python.** Azi filtreaza in dict dupa `r["account_id"]`. Cu scope pe cont, filtrarea trebuie in query: `WHERE status='needs_mapping' AND (account_id=? OR (account_id IS NULL AND ?=1))`. Pastreaza default `account_id=None`=global DAR documenteaza-l ca **intentionat pentru web** (footgun altfel: un apelant viitor care uita parametrul scurge cross-account — exact bug-ul de fata). AC nou: `test_pending_filtreaza_in_sql_cu_regula_null`. **B2 [MEDIUM→DRY, ambele voci] — Helper unic pentru clauza de scope.** Regula NULL→cont 1 apare in 5+ query-uri (`list_prezentari` ×2, `get_prezentare`, `_audit_rows`, `pending_unmapped`). Extrage `account_scope_clause(account_id) -> (sql_fragment, params)` care produce `(account_id = ? OR (account_id IS NULL AND ? = 1))`. **Clarificare critica:** se aplica DOAR tabelelor cu `account_id` nullable (`submissions`). `get_mapari` pe `operations_mapping` (`account_id NOT NULL`) foloseste simplu `WHERE account_id=?` — fara ramura NULL. Un singur loc de testat. **B3 [MAJOR, Produs] — 404 cross-account byte-identic cu 404 inexistent.** Altfel `detail` diferit re-deschide oracolul de enumerare pe care 404 trebuia sa-l inchida. AC nou pe `get_prezentare`: acelasi status + acelasi `detail` pentru "inexistent" si "exista dar nu e al contului tau". **B4 [MAJOR, ambele — leak adiacent] — `get_prezentare` foloseste `SELECT *` → expune `rar_creds_enc`, `idempotency_key`, `rar_error` (poate contine VIN-uri).** Scope-ul rezolva *cine* vede randul, nu *ce campuri*. Adauga allowlist de campuri in `get_prezentare` (exclude `rar_creds_enc`, `payload_json`; pastreaza doar campurile de monitorizare). Ieftin, inchide o suprafata fragila ("orice coloana noua scapa by default"). AC: `test_detaliu_nu_expune_creds`. **B5 [MEDIUM, Eng] — Index pe `account_id`.** Adauga `CREATE INDEX IF NOT EXISTS idx_submissions_account_status ON submissions(account_id, status)` (schema + `_migrate`). O linie; previne scan dupa ce fiecare query capata predicat pe coloana neindexata. (Volum mic azi, dar e ieftin — P2.) **B6 [MAJOR, ambele — reformulare onesta] — Web-ul ramane GLOBAL, nu "pe cont 1".** `fragment_submissions`, `_status_counts`, `fragment_banner`, `_render_mapari` afiseaza TOATE conturile in dashboard. Non-goal-ul actual ("web hardcodat cont 1") e inselator. Reformulare: "dashboard-ul web e tool INTERN (neexpus clientilor in Treapta 1/2); riscul rezidual global e acceptat constient; 3.3/US-005 il inchide comportamental." Daca web-ul devine expus clientilor inainte de 3.3, 3.2 e incomplet ca pre-cerinta de securitate. **B7 [MEDIUM, Produs] — Dependenta de VERIFY fata de 3.1.** Implementarea nu depinde de 3.1, dar E2E cere un al doilea cont. Noteaza: "E2E foloseste cont #2 creat prin 3.1 (`tools/account.py`) sau INSERT manual daca 3.1 nu e inca livrat." **B8 [MEDIUM, Produs — structural] — Inverseaza default-ul mental.** Cauza bug-ului: scope-ul e opt-in via `Depends`, nu default. Adauga in `docs/api-rar-contract.md` (sau CLAUDE.md) regula: "orice GET nou pe `/v1/*` care atinge `submissions`/`operations_mapping` PORNESTE cu `Depends(resolve_account_id)` + clauza de scope; globalul e exceptie justificata (ca nomenclatorul)." Trateaza cauza, nu doar cele 4 simptome. **TD-3.2 [REZOLVAT la poarta] — `?account_id=` pe `/v1/mapari`:** contul vine MEREU din cheia API (nespoofabil). Param-ul din query se **ignora** ca selector; daca e prezent SI difera de contul cheii → **400** explicit (nu schimbare tacita). AC US-002 actualizat: `test_mapari_query_account_id_diferit_400`, `test_mapari_query_account_id_egal_ok`. ## Raport VERIFY > Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6. **Verdict global: PASS** (verificator independent, context curat, 2026-06-17). - **Suita**: `python3 -m pytest -q` → **313 passed**, 0 fail. Teste noi 3.2: 14 passed. - **Regresia de aur**: 313 verzi — POST `/v1/prezentari`, import, worker neatinse; calea de send nu e modificata. | Criteriu | Verdict | Dovada | |----------|---------|--------| | US-001: `list_prezentari` scoped pe cont (ambele ramuri) | PASS | `router.py` + `test_lista_doar_contul_cheii` | | US-001: `GET /{id}` alt cont → 404 | PASS | `test_detaliu_cross_account_404` | | US-001: back-compat dev (fara cheie → cont 1) | PASS | `test_fara_cheie_flag_off_vede_contul_1` | | US-002: `GET /mapari` scoped; `?account_id` difera → 400 (TD-3.2) | PASS | `test_mapari_query_account_id_diferit_400` / `_egal_ok` | | US-002: web `pending_unmapped(conn)` ramane global | PASS | `routes.py:160` neatins + `test_pending_web_global_neschimbat` | | US-003: `audit/export` + `status=all` scoped | PASS | 3 teste `test_get_scope_audit` | | B1: `pending_unmapped` filtreaza IN SQL (nu Python) | PASS | `test_pending_filtreaza_in_sql_cu_regula_null` | | B2: `account_scope_clause` DOAR pe submissions; `get_mapari` `WHERE account_id=?` simplu | PASS | `mapping.py` + `router.py` | | B3: 404 cross-account byte-identic cu 404 inexistent | PASS | un singur `detail`; test explicit | | B4: `get_prezentare` allowlist (exclude creds/payload/idempotency/error) | PASS | `_PREZENTARE_FIELDS` + `test_detaliu_nu_expune_creds` | | B5: index `idx_submissions_account_status` in schema.sql + `_migrate` | PASS | `schema.sql` + `db.py` | | B8: regula scope documentata in `api-rar-contract.md` | PASS | sectiune "Regula de scope pe cont (B8, PRD 3.2)" | **Rezerva (acceptata):** trimiterea LIVE la RAR test (FINALIZATA) nu a rulat — lipsa `.env`/credentiale RAR in mediu. Schimbarile 3.2 ating EXCLUSIV GET-uri de citire (POST/worker/send neatinse), deci regresia E2E e acoperita integral de suita automata. De re-confirmat la urmatorul deploy cu creds.