From 60c21ae2a5586c6ee25de7c3188d4c0b437581b0 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 12 Jul 2026 22:13:36 +0000 Subject: [PATCH] =?UTF-8?q?feat(tradein/lead):=20persist=20152-=D0=A4?= =?UTF-8?q?=D0=97=20proof-of-consent=20(LOW=20audit=20R2=20#9)=20(#2511)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/api/v1/lead.py | 44 +++++++++++++------ .../sql/182_trade_in_leads_consent_proof.sql | 43 ++++++++++++++++++ .../backend/tests/test_trade_in_lead.py | 24 ++++++++++ 3 files changed, 97 insertions(+), 14 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/182_trade_in_leads_consent_proof.sql diff --git a/tradein-mvp/backend/app/api/v1/lead.py b/tradein-mvp/backend/app/api/v1/lead.py index 58274a4f..5ab93bf7 100644 --- a/tradein-mvp/backend/app/api/v1/lead.py +++ b/tradein-mvp/backend/app/api/v1/lead.py @@ -35,12 +35,21 @@ _PHONE_PATTERN = r"^[+]?[\d\s().-]{5,32}$" _PHONE_MIN_DIGITS = 10 _PHONE_MAX_DIGITS = 15 -# Версия политики обработки ПДн (152-ФЗ), под которую собрано согласие. Пока пишется -# только в audit-лог: в trade_in_leads НЕТ колонок под policy_version / client_ip / -# снимок текста согласия (проверено live: \d trade_in_leads, 2026-07-12). Полноценный -# proof-of-consent требует follow-up миграции — см. TODO у INSERT ниже. +# Версия политики обработки ПДн (152-ФЗ), под которую собрано согласие. Персистится +# per-row в trade_in_leads.consent_policy_version (migration 182) — до неё писалась +# только в audit-лог (#2497 TODO, теперь закрыт). _CONSENT_POLICY_VERSION = "2026-07" +# Снимок точного текста согласия, который видит пользователь при отправке лида. +# Должен ДОСЛОВНО совпадать с чекбоксом в LeadForm.tsx (frontend/src/components/ +# trade-in/v2/LeadForm.tsx) — если текст политики меняется, здесь нужно поднять +# _CONSENT_POLICY_VERSION И обновить этот снимок в одном PR, иначе новые строки +# будут нести устаревший snapshot под новой version-меткой. +_CONSENT_TEXT_SNAPSHOT = ( + "Согласен(-на) на обработку персональных данных в соответствии с " + "Федеральным законом «О персональных данных» № 152-ФЗ" +) + class TradeInLeadInput(BaseModel): phone: str = Field(min_length=5, max_length=32, pattern=_PHONE_PATTERN) @@ -78,26 +87,30 @@ async def create_trade_in_lead( user_agent = request.headers.get("user-agent") # 152-ФЗ audit trail: реальный клиентский IP из X-Forwarded-For (его ставит - # фронтящий Caddy), fallback — прямой peer. Пишем только в лог: durable-колонки - # под IP в trade_in_leads нет (см. TODO у INSERT). + # фронтящий Caddy), fallback — прямой peer. Персистится per-row в + # trade_in_leads.client_ip (migration 182), а не только в лог. client_ip = request.headers.get("x-forwarded-for") if client_ip: client_ip = client_ip.split(",")[0].strip() elif request.client is not None: client_ip = request.client.host - # TODO(152-ФЗ proof-of-consent): trade_in_leads хранит согласие как голый boolean - # + created_at + user_agent. Для полноценного доказательства согласия нужна - # follow-up миграция с колонками client_ip (inet), consent_policy_version (text), - # consent_text_snapshot (text) — тогда client_ip/_CONSENT_POLICY_VERSION пойдут в - # строку, а не только в лог. Live-схема на 2026-07-12 таких колонок НЕ имеет - # (out of scope этого PR). + # 152-ФЗ proof-of-consent: client_ip / consent_policy_version / + # consent_text_snapshot теперь durable-колонки на trade_in_leads (migration 182, + # ранее — только audit-лог, #2497 TODO). client_ip может быть None (нет + # X-Forwarded-For и request.client) — колонка nullable, CAST(NULL AS inet) валиден. row = ( db.execute( text( """ - INSERT INTO trade_in_leads (estimate_id, phone, consent, source, user_agent) - VALUES (CAST(:estimate_id AS uuid), :phone, :consent, :source, :user_agent) + INSERT INTO trade_in_leads ( + estimate_id, phone, consent, source, user_agent, + client_ip, consent_policy_version, consent_text_snapshot + ) + VALUES ( + CAST(:estimate_id AS uuid), :phone, :consent, :source, :user_agent, + CAST(:client_ip AS inet), :consent_policy_version, :consent_text_snapshot + ) RETURNING CAST(id AS text), created_at """ ), @@ -107,6 +120,9 @@ async def create_trade_in_lead( "consent": payload.consent, "source": payload.source, "user_agent": user_agent, + "client_ip": client_ip, + "consent_policy_version": _CONSENT_POLICY_VERSION, + "consent_text_snapshot": _CONSENT_TEXT_SNAPSHOT, }, ) .mappings() diff --git a/tradein-mvp/backend/data/sql/182_trade_in_leads_consent_proof.sql b/tradein-mvp/backend/data/sql/182_trade_in_leads_consent_proof.sql new file mode 100644 index 00000000..4148dae2 --- /dev/null +++ b/tradein-mvp/backend/data/sql/182_trade_in_leads_consent_proof.sql @@ -0,0 +1,43 @@ +-- 182_trade_in_leads_consent_proof.sql +-- LOW audit R2 (#9): durable 152-ФЗ proof-of-consent for trade_in_leads. +-- +-- WHY: +-- #2497 already computes client_ip (X-Forwarded-For, fallback direct peer) +-- and pins _CONSENT_POLICY_VERSION = "2026-07" in app/api/v1/lead.py, but +-- both were written ONLY to the audit log — trade_in_leads had no columns +-- to hold them (explicit TODO at the INSERT in lead.py). A bare boolean +-- `consent` column plus a log line is not durable proof: logs rotate/expire, +-- the row itself carries no evidence of what was agreed to, under what +-- policy version, or from what IP. +-- +-- WHAT: +-- Three nullable columns on trade_in_leads: +-- - client_ip (inet) — same value already computed in +-- create_trade_in_lead, now persisted +-- per-row instead of log-only. +-- - consent_policy_version (text) — snapshot of _CONSENT_POLICY_VERSION +-- at the time this lead was captured. +-- - consent_text_snapshot (text) — snapshot of the exact consent sentence +-- shown to the user (_CONSENT_TEXT_SNAPSHOT +-- in lead.py, same PR). +-- +-- IDEMPOTENCY / SAFETY: +-- ADD COLUMN IF NOT EXISTS x3 — safe re-run. All nullable, no DEFAULT, no +-- backfill: existing rows keep NULL (no consent proof was captured for them +-- pre-migration — that's an honest reflection of what actually happened, +-- not a schema gap). Purely additive: no existing reads/writes break. +-- +-- Dependencies: 172_trade_in_leads.sql (trade_in_leads table). + +BEGIN; + +ALTER TABLE trade_in_leads + ADD COLUMN IF NOT EXISTS client_ip inet, + ADD COLUMN IF NOT EXISTS consent_policy_version text, + ADD COLUMN IF NOT EXISTS consent_text_snapshot text; + +COMMENT ON COLUMN trade_in_leads.client_ip IS '152-ФЗ proof-of-consent: клиентский IP на момент согласия (ранее только в audit-логе, #2497 TODO).'; +COMMENT ON COLUMN trade_in_leads.consent_policy_version IS '152-ФЗ proof-of-consent: снимок _CONSENT_POLICY_VERSION на момент согласия.'; +COMMENT ON COLUMN trade_in_leads.consent_text_snapshot IS '152-ФЗ proof-of-consent: снимок текста согласия, показанного пользователю (_CONSENT_TEXT_SNAPSHOT).'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_trade_in_lead.py b/tradein-mvp/backend/tests/test_trade_in_lead.py index fa64fab3..8f2f9859 100644 --- a/tradein-mvp/backend/tests/test_trade_in_lead.py +++ b/tradein-mvp/backend/tests/test_trade_in_lead.py @@ -55,12 +55,15 @@ def _insert_result(lead_id: str) -> MagicMock: def test_lead_happy_path(client: TestClient, db: MagicMock) -> None: + from app.api.v1 import lead as lead_module + lead_id = str(uuid4()) db.execute.return_value = _insert_result(lead_id) r = client.post( "/api/v1/trade-in/lead", json={"phone": "+7 (912) 345-67-89", "consent": True}, + headers={"x-forwarded-for": "203.0.113.7, 10.0.0.1"}, ) assert r.status_code == 200, r.text body = r.json() @@ -73,6 +76,27 @@ def test_lead_happy_path(client: TestClient, db: MagicMock) -> None: assert params["consent"] is True assert params["source"] == "result" assert params["estimate_id"] is None + # 152-ФЗ proof-of-consent (migration 182): client_ip / policy version / text + # snapshot must reach the INSERT params, not just the audit log (#2497 TODO). + assert params["client_ip"] == "203.0.113.7" # first hop of X-Forwarded-For + assert params["consent_policy_version"] == lead_module._CONSENT_POLICY_VERSION + assert params["consent_text_snapshot"] == lead_module._CONSENT_TEXT_SNAPSHOT + + +def test_lead_client_ip_falls_back_to_peer_when_no_xff(client: TestClient, db: MagicMock) -> None: + # No X-Forwarded-For header -> falls back to request.client.host (TestClient + # reports "testclient"), still persisted rather than silently None. + lead_id = str(uuid4()) + db.execute.return_value = _insert_result(lead_id) + + r = client.post( + "/api/v1/trade-in/lead", + json={"phone": "+79123456789", "consent": True}, + ) + assert r.status_code == 200, r.text + + params = db.execute.call_args.args[1] + assert params["client_ip"] == "testclient" def test_lead_consent_false_422(client: TestClient, db: MagicMock) -> None: