gendesign/tradein-mvp/backend/app/services/data_erasure.py
bot-backend 4ee4d4b8e2
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m9s
fix(tradein/privacy): нормализация телефона к каноническому РФ-виду при erasure (#2547)
Follow-up к прошлому фиксу (regexp_replace \D): чистое удаление
форматирования не закрывало разрыв, который сам ревьюер привёл в примере --
"+7 999 123-45-67" и "89991234567" после digit-stripping дают РАЗНЫЕ строки
(79991234567 vs 89991234567, différent на первой цифре) -- классическая для
РФ путаница 8/+7 trunk-префикса.

_ru_phone_norm_sql(expr) добавляет второй шаг: если после digit-stripping
получилось РОВНО 11 цифр с ведущей '8' -- заменить её на '7'. Точное
тождество для российской нумерации, не эвристика (обсуждали: усечение до
"последних 10 цифр" риск-скориальнее -- склеивает номера разных стран,
удаление чужих данных хуже неудаления своих). Оба вызова
(_PHONE_COLUMN_NORM_SQL / _PHONE_PARAM_NORM_SQL) строят SQL-структуру из
статичных фрагментов (имя колонки / CAST(:phone AS text)) -- ни один
телефон не попадает в текст запроса напрямую.

Живая проверка (throwaway Postgres 16 в docker): лид "89991234567" находится
и удаляется по запросу "+7 999 123-45-67" -- ровно кейс из ревью. Встроенный
counterfactual в самом тесте доказывает, что чистый digit-strip (прошлая
версия фикса) для этой пары находит 0 строк. Negative control: номер,
отличающийся одной значащей цифрой, НЕ удаляется (защита от ложного
совпадения = удаления чужих данных).
2026-08-06 19:59:34 +03:00

240 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Right-to-erasure mechanism (152-ФЗ) — ЭТАП 4 B2C launch, part C.
WHY:
trade_in has no self-service "delete my data" endpoint at all. Both B2B
pilots (identified by `created_by` username) and future anonymous B2C
users need SOME way to have their personal data physically removed on
request, not just after their retention TTL expires
(app/tasks/purge_expired_trade_in_data.py handles the TTL path, this
module handles the on-demand path).
WHO CAN BE IDENTIFIED, HONESTLY:
- B2B pilot (has a `username`): trivially -- `created_by = username` scopes
every estimate they created; leads/support threads follow from there.
- Anonymous person: has NO username. This function can ONLY act on
identifiers the requester can actually supply:
* `estimate_ids` -- if they still have the link/PDF from their estimate
(the UUID in the URL/QR-code IS their proof of "this is mine").
* `phone` -- if they left a contact-request lead with that phone.
Matched by CANONICAL RU DIGITS on both sides (see
`_ru_phone_norm_sql` below), not an exact string: lead.py stores
`payload.phone` exactly as typed (no E.164 normalization, by
design), so "+7 999 123-45-67", "8 (999) 123-45-67" and
"89991234567" must all find the same row. Covers ONLY the
RU 8-vs-7 trunk-prefix case (exact digit-count identity, no
heuristic truncation) -- see the helper's docstring for why.
* `tg_chat_id` -- if they messaged @MERAsupport_bot directly (their own
Telegram chat id -- not guessable/spoofable by a third party the way
a name or IP would be).
If an anonymous person has NONE of these (e.g. they only remember the
street address, or ran an estimate but never saved anything and didn't
log support contact) -- THIS IS HONESTLY UNRESOLVABLE without additional
identification. There is no username, no stable session, nothing in the
DB schema today that lets a support operator find "the one estimate this
specific stranger made three days ago" among many. Do not paper over
this: an operator facing that case must say so, not silently pick "the
closest match".
⚠️ TELEGRAM CAVEAT (152-ФЗ, honestly, do not omit):
Every tg_support_messages row was, at send time, ALSO mirrored by the bot
into the support-group Telegram topic (see app/services/tgbot/bridge.py,
186_tg_support.sql). Deleting `tg_support_users` here only removes the
copy IN THIS DATABASE. The mirrored copy lives in the Telegram supergroup,
outside this function's reach, and is NOT deleted by anything in this
codebase. A complete erasure across the whole chain requires a SEPARATE
manual step (Telegram Bot API `deleteMessage` per `topic_message_id` in
the supergroup) that is out of scope here. Do not cite this function's
return value as proof of full erasure of the Telegram-side copy.
WHAT ELSE IS *NOT* TOUCHED (known gap, flagged, not silently dropped):
`user_events` (184_user_events.sql) logs `estimate_request` events with a
JSONB payload that includes `address`/`area_m2`/`rooms` and is keyed by
`username` (empty string for anonymous callers today) + `ip_address`, with
NO FK to trade_in_estimates (decoupled/append-only by explicit design --
see that migration's comment). This function does NOT purge user_events:
it is an audit/analytics log, not an estimate/lead/support record, and
deciding whether "audit trail" is a legitimate 152-ФЗ retention basis that
overrides an erasure request is a legal call, not an engineering one. Flag
it to whoever handles the request; do not assume it is already covered.
"""
from __future__ import annotations
import logging
from collections.abc import Sequence
from uuid import UUID
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
def _ru_phone_norm_sql(expr: str) -> str:
"""SQL-фрагмент: нормализация телефона к каноническому РФ-виду (11 цифр,
ведущая '7'), для сравнения "разного форматирования одного и того же номера"
(deep-review 2026-08-06, MEDIUM + follow-up).
Два шага: 1) убрать всё, кроме цифр; 2) если получилось РОВНО 11 цифр с
ведущей '8' -- заменить её на '7'. Это ТОЧНОЕ тождество для российской
нумерации (8 и +7 -- один и тот же trunk-префикс), не эвристика: длина
проверяется явно (=11), заменяется РОВНО одна ведущая цифра. Специально
НЕ "последние 10 цифр" -- усечение убрало бы риск ложных совпадений
неточно: оно склеивает номера РАЗНЫХ стран с теми же 10 хвостовыми
цифрами, а удаление ЧУЖИХ данных по erasure-запросу хуже, чем
неудаление своих. Номера другой длины/страны просто не совпадут ни на
этом шаге, ни дальше -- безопасный отказ, не false positive.
`expr` -- ВСЕГДА статичный SQL-фрагмент (имя колонки или
`CAST(:bind AS type)`), НИКОГДА значение параметра: эта функция строит
структуру запроса из литералов, вызывающих её мест ровно два (см.
_PHONE_COLUMN_NORM_SQL / _PHONE_PARAM_NORM_SQL ниже) -- ни один телефон
не попадает в текст SQL напрямую, только через bind-параметр `:phone`.
"""
stripped = f"regexp_replace({expr}, '\\D', '', 'g')"
return (
f"(CASE WHEN length({stripped}) = 11 AND left({stripped}, 1) = '8' "
f"THEN '7' || substring({stripped} FROM 2) ELSE {stripped} END)"
)
# Предвычисленные один раз -- обе стороны сравнения телефона в erase_person_data
# (колонка trade_in_leads.phone / входной CAST(:phone AS text)).
_PHONE_COLUMN_NORM_SQL = _ru_phone_norm_sql("phone")
_PHONE_PARAM_NORM_SQL = _ru_phone_norm_sql("CAST(:phone AS text)")
def erase_person_data(
db: Session,
*,
username: str | None = None,
estimate_ids: Sequence[UUID] | None = None,
phone: str | None = None,
tg_chat_id: int | None = None,
) -> dict[str, int]:
"""Physically delete a person's data across trade_in tables.
At least one identifier is required (raises ValueError otherwise -- callers
MUST pass an explicit identifier, never "erase everything" by omission).
Order of operations matters: leads are captured/deleted BEFORE estimates,
because trade_in_leads.estimate_id is ON DELETE SET NULL (172) -- once the
estimate row is gone, the join key to find "leads that came from this
person's estimate" is gone too.
Returns per-table deleted-row counters. Callers own committing the ambient
Session lifecycle in whatever way their layer does (this function DOES
commit itself, mirroring app/tasks/*.py conventions, since this is a
one-shot admin operation, not a request-scoped unit of work shared with
other writes).
"""
if not any([username, estimate_ids, phone, tg_chat_id]):
raise ValueError(
"erase_person_data requires at least one identifier: "
"username / estimate_ids / phone / tg_chat_id"
)
counters: dict[str, int] = {
"trade_in_estimates_deleted": 0,
"trade_in_leads_deleted": 0,
"web_support_deleted": 0,
"tg_support_deleted": 0,
}
# 1. Собрать ПОЛНЫЙ набор estimate_id ДО удаления оценок: явные estimate_ids
# (анонимный путь -- человек прислал ссылку/PDF) + все id с
# created_by=username (B2B-путь). Нужно захватить это СЕЙЧАС -- после
# DELETE FROM trade_in_estimates связанные trade_in_leads.estimate_id
# уйдут в NULL (ON DELETE SET NULL, 172), join станет невозможен.
all_estimate_ids: set[UUID] = set(estimate_ids or [])
if username:
owned = (
db.execute(
text("SELECT id FROM trade_in_estimates WHERE created_by = :username"),
{"username": username},
)
.scalars()
.all()
)
all_estimate_ids.update(owned)
# 2. Лиды -- пока estimate_id ещё живой FK (см. п.1), плюс отдельно по
# телефону (лид мог быть оставлен без attach к оценке вовсе).
#
# ⚠️ Телефон сравнивается по КАНОНИЧЕСКОМУ РФ-виду, не литералом
# (deep-review 2026-08-06, MEDIUM + follow-up). app/api/v1/lead.py
# сохраняет payload.phone КАК ПРИСЛАЛИ (намеренно -- полная
# E.164-нормализация вне scope MVP, см. lead.py::_PHONE_PATTERN),
# т.е. одна и та же строка может лежать в БД как "+7 999 123-45-67"
# ИЛИ "89991234567" ИЛИ "8 (999) 123-45-67". Точное `phone = :phone`
# находит строку только если запрашивающий пришлёт БУКВАЛЬНО ТОТ ЖЕ
# формат, каким когда-то ввёл номер -- почти никогда так. Раньше это
# молча удаляло 0 строк и всё равно возвращало 200 "данные удалены":
# для 152-ФЗ ложное подтверждение удаления хуже честной ошибки.
# _PHONE_COLUMN_NORM_SQL / _PHONE_PARAM_NORM_SQL (см. _ru_phone_norm_sql
# выше) снимают форматирование С ОБЕИХ сторон И схлопывают ведущую
# '8' в '7' при 11 цифрах -- покрывает РОВНО RU 8-vs-7 trunk-префикс,
# без усечения до "последних 10 цифр" (риск ложного совпадения с
# номером другой страны -- см. докстринг helper'а). Номера иных
# форматов/длин сравниваются как есть (просто не совпадут). Параметр --
# CAST(:phone AS text), НЕ конкатенация значения (psycopg v3 / SQL
# injection convention, .claude/rules/backend.md); сам SQL-текст
# собран из СТАТИЧНЫХ фрагментов (_PHONE_*_NORM_SQL), в которых нет
# ни одного значения параметра.
ids_param = [str(i) for i in all_estimate_ids]
result = db.execute(
text(
f"""
DELETE FROM trade_in_leads
WHERE estimate_id = ANY(CAST(:ids AS uuid[]))
OR (
CAST(:phone AS text) IS NOT NULL
AND {_PHONE_COLUMN_NORM_SQL} = {_PHONE_PARAM_NORM_SQL}
)
"""
),
{"ids": ids_param, "phone": phone},
)
counters["trade_in_leads_deleted"] = result.rowcount or 0
# 3. Оценки (CASCADE подчищает estimate_photos + avito_imv_evaluations).
if all_estimate_ids:
result = db.execute(
text("DELETE FROM trade_in_estimates WHERE id = ANY(CAST(:ids AS uuid[]))"),
{"ids": ids_param},
)
counters["trade_in_estimates_deleted"] = result.rowcount or 0
# 4. Веб-чат поддержки -- ключ username (сайт закрыт Caddy basic_auth, у
# анонима username нет и быть не может, см. 187_web_support_chat.sql).
if username:
result = db.execute(
text("DELETE FROM web_support_threads WHERE username = :username"),
{"username": username},
)
counters["web_support_deleted"] = result.rowcount or 0
# 5. Telegram-поддержка -- ключ chat_id, ЕДИНСТВЕННЫЙ путь, реально
# доступный анониму без username (см. module docstring). ⚠️ Чистит
# ТОЛЬКО эту БД -- Telegram-топик со своей копией переписки НЕ
# затрагивается, см. ВАЖНЫЙ ФАКТ в docstring выше.
if tg_chat_id is not None:
result = db.execute(
text("DELETE FROM tg_support_users WHERE chat_id = CAST(:chat_id AS bigint)"),
{"chat_id": tg_chat_id},
)
counters["tg_support_deleted"] = result.rowcount or 0
db.commit()
logger.info(
"erase_person_data: username=%r estimate_ids=%d phone=%s tg_chat_id=%s -> %s",
username,
len(all_estimate_ids),
"<redacted>" if phone else None,
tg_chat_id,
counters,
)
return counters
__all__: list[str] = ["erase_person_data"]