Inchide scurgerea cross-account pe GET /v1/prezentari(/{id}),
/v1/mapari(/pending) si /v1/audit/export. Toate primesc
Depends(resolve_account_id) + account_scope_clause (regula NULL->cont 1,
OV-2). Nomenclatorul ramane global (referinta partajata, fara PII).
- B3: 404 cross-account byte-identic cu 404 inexistent (fara oracol enumerare)
- B4: get_prezentare cu allowlist de campuri (nu mai expune rar_creds_enc/
payload_json/idempotency_key/rar_error)
- B1: pending_unmapped filtreaza in SQL; default None = global doar pentru web
- B2: helper account_scope_clause (DRY, doar pe submissions nullable)
- B5: index idx_submissions_account_status
- B8: regula de scope documentata in api-rar-contract.md
- TD-3.2: ?account_id != contul cheii -> 400
14 teste noi (cross-account, legacy NULL, B3, B4); suita 313 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
12 KiB
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_rarsunt cache de referinta RAR partajat (faraaccount_id, fara PII) — nu are sens sa-l filtram. Decizie explicita, nu omisiune. - Fara auth pe rutele web. Rutele
app/web/routes.pyraman hardcodate pe contul default (id=1) pana la 3.3. 3.2 priveste doar GET-urile API/v1/*. (pending_unmappedse 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_idNULL (OV-2) se trateaza ca apartinand contului default (id=1). Filtrarea folosesteaccount_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:
- Ambele rute primesc
account_id: int = Depends(resolve_account_id). GET /v1/prezentariadaugaWHEREpe cont (NULL→cont 1) la ambele ramuri (cu/farastatus).GET /v1/prezentari/{id}al altui cont → 404 (nu 403 — nu confirmam existenta).- Cheie A nu vede submission-uri ale contului B (lista si detaliu).
require_api_key=falsefara cheie → vede contul 1 (back-compat dev).
- Ambele rute primesc
- Verificare E2E: doua chei (conturi distincte, via 3.1) →
POSTpe fiecare →GET /v1/prezentaricu 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 lapending_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:
GET /v1/maparifolosesteDepends(resolve_account_id); parametrulaccount_iddin query este eliminat (un cont nu poate citi maparile altuia trecand un id arbitrar).pending_unmapped(conn, account_id=None)capata param optional:None= global (web, back-compat), valoare = filtrare pe cont.GET /v1/mapari/pendingpaseaza contul cheii.- Apelul web
pending_unmapped(conn)dinroutes.pyramane neatins (global) — confirmat detest_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:
audit_exportprimesteDepends(resolve_account_id);_audit_rowsfiltreaza pe cont (NULL→cont 1) pe langa filtrele de data/status existente.status=allramane scoped pe cont (nu exporta global).- Randurile contului B nu apar in CSV-ul cerut cu cheia A.
- Verificare E2E:
POSTpe doua conturi →GET /v1/audit/export(cheie A) → CSV fara VIN-urile B.
4. Riscuri
- Back-compat dev — cu
require_api_key=falsesi 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_unmappede partajat API/web; semnatura cu defaultNoneprevine 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 parametrulaccount_iddin 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) sauisolation: worktree+ merge de lead. Recomandare: secvential, un singur teammate — schimbarile sunt mici si inrudite (acelasi patternDepends(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.