# 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.
