feat(tradein/lead): persist 152-ФЗ proof-of-consent (LOW audit R2 #9) (#2511)
All checks were successful
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / test (push) Successful in 58s
Deploy Trade-In / build-backend (push) Successful in 1m30s
Deploy Trade-In / build-frontend (push) Successful in 32s
Deploy Trade-In / deploy (push) Successful in 1m20s
All checks were successful
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / test (push) Successful in 58s
Deploy Trade-In / build-backend (push) Successful in 1m30s
Deploy Trade-In / build-frontend (push) Successful in 32s
Deploy Trade-In / deploy (push) Successful in 1m20s
This commit is contained in:
parent
3bd0873230
commit
60c21ae2a5
3 changed files with 97 additions and 14 deletions
|
|
@ -35,12 +35,21 @@ _PHONE_PATTERN = r"^[+]?[\d\s().-]{5,32}$"
|
||||||
_PHONE_MIN_DIGITS = 10
|
_PHONE_MIN_DIGITS = 10
|
||||||
_PHONE_MAX_DIGITS = 15
|
_PHONE_MAX_DIGITS = 15
|
||||||
|
|
||||||
# Версия политики обработки ПДн (152-ФЗ), под которую собрано согласие. Пока пишется
|
# Версия политики обработки ПДн (152-ФЗ), под которую собрано согласие. Персистится
|
||||||
# только в audit-лог: в trade_in_leads НЕТ колонок под policy_version / client_ip /
|
# per-row в trade_in_leads.consent_policy_version (migration 182) — до неё писалась
|
||||||
# снимок текста согласия (проверено live: \d trade_in_leads, 2026-07-12). Полноценный
|
# только в audit-лог (#2497 TODO, теперь закрыт).
|
||||||
# proof-of-consent требует follow-up миграции — см. TODO у INSERT ниже.
|
|
||||||
_CONSENT_POLICY_VERSION = "2026-07"
|
_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):
|
class TradeInLeadInput(BaseModel):
|
||||||
phone: str = Field(min_length=5, max_length=32, pattern=_PHONE_PATTERN)
|
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")
|
user_agent = request.headers.get("user-agent")
|
||||||
# 152-ФЗ audit trail: реальный клиентский IP из X-Forwarded-For (его ставит
|
# 152-ФЗ audit trail: реальный клиентский IP из X-Forwarded-For (его ставит
|
||||||
# фронтящий Caddy), fallback — прямой peer. Пишем только в лог: durable-колонки
|
# фронтящий Caddy), fallback — прямой peer. Персистится per-row в
|
||||||
# под IP в trade_in_leads нет (см. TODO у INSERT).
|
# trade_in_leads.client_ip (migration 182), а не только в лог.
|
||||||
client_ip = request.headers.get("x-forwarded-for")
|
client_ip = request.headers.get("x-forwarded-for")
|
||||||
if client_ip:
|
if client_ip:
|
||||||
client_ip = client_ip.split(",")[0].strip()
|
client_ip = client_ip.split(",")[0].strip()
|
||||||
elif request.client is not None:
|
elif request.client is not None:
|
||||||
client_ip = request.client.host
|
client_ip = request.client.host
|
||||||
|
|
||||||
# TODO(152-ФЗ proof-of-consent): trade_in_leads хранит согласие как голый boolean
|
# 152-ФЗ proof-of-consent: client_ip / consent_policy_version /
|
||||||
# + created_at + user_agent. Для полноценного доказательства согласия нужна
|
# consent_text_snapshot теперь durable-колонки на trade_in_leads (migration 182,
|
||||||
# follow-up миграция с колонками client_ip (inet), consent_policy_version (text),
|
# ранее — только audit-лог, #2497 TODO). client_ip может быть None (нет
|
||||||
# consent_text_snapshot (text) — тогда client_ip/_CONSENT_POLICY_VERSION пойдут в
|
# X-Forwarded-For и request.client) — колонка nullable, CAST(NULL AS inet) валиден.
|
||||||
# строку, а не только в лог. Live-схема на 2026-07-12 таких колонок НЕ имеет
|
|
||||||
# (out of scope этого PR).
|
|
||||||
row = (
|
row = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
INSERT INTO trade_in_leads (estimate_id, phone, consent, source, user_agent)
|
INSERT INTO trade_in_leads (
|
||||||
VALUES (CAST(:estimate_id AS uuid), :phone, :consent, :source, :user_agent)
|
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
|
RETURNING CAST(id AS text), created_at
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
|
|
@ -107,6 +120,9 @@ async def create_trade_in_lead(
|
||||||
"consent": payload.consent,
|
"consent": payload.consent,
|
||||||
"source": payload.source,
|
"source": payload.source,
|
||||||
"user_agent": user_agent,
|
"user_agent": user_agent,
|
||||||
|
"client_ip": client_ip,
|
||||||
|
"consent_policy_version": _CONSENT_POLICY_VERSION,
|
||||||
|
"consent_text_snapshot": _CONSENT_TEXT_SNAPSHOT,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -55,12 +55,15 @@ def _insert_result(lead_id: str) -> MagicMock:
|
||||||
|
|
||||||
|
|
||||||
def test_lead_happy_path(client: TestClient, db: MagicMock) -> None:
|
def test_lead_happy_path(client: TestClient, db: MagicMock) -> None:
|
||||||
|
from app.api.v1 import lead as lead_module
|
||||||
|
|
||||||
lead_id = str(uuid4())
|
lead_id = str(uuid4())
|
||||||
db.execute.return_value = _insert_result(lead_id)
|
db.execute.return_value = _insert_result(lead_id)
|
||||||
|
|
||||||
r = client.post(
|
r = client.post(
|
||||||
"/api/v1/trade-in/lead",
|
"/api/v1/trade-in/lead",
|
||||||
json={"phone": "+7 (912) 345-67-89", "consent": True},
|
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
|
assert r.status_code == 200, r.text
|
||||||
body = r.json()
|
body = r.json()
|
||||||
|
|
@ -73,6 +76,27 @@ def test_lead_happy_path(client: TestClient, db: MagicMock) -> None:
|
||||||
assert params["consent"] is True
|
assert params["consent"] is True
|
||||||
assert params["source"] == "result"
|
assert params["source"] == "result"
|
||||||
assert params["estimate_id"] is None
|
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:
|
def test_lead_consent_false_422(client: TestClient, db: MagicMock) -> None:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue