gendesign/tradein-mvp/backend/app/core/request_audit.py
bot-backend 1f85ef7d4e
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Successful in 3m50s
CI Trade-In / changes (pull_request) Successful in 8s
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
fix(tradein/payments): тело нотификации не течёт в мониторинг и аудит, повторы банка не отбиваются лимитом
PR-D2 платёжного контура МЕРЫ — закрывает утечки до открытия публичных путей
(PR-D3/D4), сам ничего не открывает: _PUBLIC_PATHS (rbac.py), Caddyfile,
roles.yaml, auth_session.py не тронуты.

- sentry_scrub.py: новая scrub_payment_request_body — вырезает
  event.request.data целиком для /api/v1/trade-in/payments/* (sentry_sdk 2.64
  кладёт полное тело запроса в request.data, send_default_pii=False это НЕ
  гейтит — тот флаг управляет только куками). Плюс расширен _PII_KEYS:
  customer_email/customer_phone/pan/expdate/cardid/rebillid/token/terminalkey.
- main.py, scheduler_main.py, tgbot_main.py (все 3 точки инициализации
  sentry_sdk.init в проекте) — тот же обработчик проведён в ОБА канала,
  before_send и before_send_transaction. Мотивирующий инцидент: на соседнем
  продукте вчера закрыли только error-канал, transaction остался без
  обработчика вообще.
- ratelimit.py: точный путь notify — свой щедрый SlidingWindowLimiter
  (3000/60с per-IP, идиома support.py) вместо общего лимитера, но НЕ полное
  отключение — backstop против шторма запросов остаётся, подпись проверяется
  уже после разбора тела (PR-D3). Только notify, не checkout (тот с сессией).
- request_audit.py: notify — в audit skip-набор (defense-in-depth: middleware
  внешний относительно rbac_guard и читает сырой X-Authenticated-User —
  спуфнутый заголовок иначе писал бы фальшивые события с атрибуцией admin).
- smoke-mera-perimeter.sh: негативные проверки-канарейки — notify/checkout
  сейчас закрыты 404 (meraocenka.ru, Caddy не проксирует) и 401
  (gendsgn.ru, rbac ещё не открыл) с обеих сторон периметра.

Тесты: scrub на произвольной глубине + payment-path body-wipe, AST-разбор
(не substring — комментарии в этих же файлах сами упоминают
before_send_transaction) на проводку обоих каналов во всех точках
инициализации, 400 запросов notify без единого 429 + контроль что общий
лимитер по-прежнему активен на других путях, notify вне user_events даже со
спуфнутым X-Authenticated-User: admin.
2026-08-07 16:17:59 +03:00

152 lines
10 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.

"""RequestAuditMiddleware — пишет `api_request` / `admin_action` (+ дедуплицированный
`login`/`login_failed`) события в `user_events` для каждого аутентифицированного
`/api/*` запроса.
Foundation для Feature 2 (login/IP audit) и базы Feature 3 (behavior analytics).
Логирование выполняется ПОСЛЕ `call_next` (не задерживает и не ветвит реальный
ответ клиенту) и через fire-and-forget `schedule_event` — сбой аудита никогда
не влияет на HTTP-ответ.
Порядок middleware-стека (см. `app/main.py`: `rbac_guard` — `@app.middleware("http")`,
объявлен ДО `app.add_middleware(RequestAuditMiddleware)`) делает `RequestAudit`
ВНЕШНИМ по отношению к `rbac_guard` (Starlette строит стек в обратном порядке
регистрации — последний `add_middleware` оборачивает предыдущие). Поэтому к моменту,
когда код ниже читает `response.status_code`, в нём уже отражён исход rbac_guard
(401/403 short-circuit) ИЛИ реального хендлера — статус несёт реальный смысл
"успех/отказ", а не только "запрос дошёл до хендлера".
Заведомо неаутентифицированный трафик (сканеры, долбящиеся в /wp-login.php и т.п.
без валидного basic_auth) сюда вообще не попадает: Caddy гейтит basic_auth ПЕРЕД
проксированием, так что `X-Authenticated-User` в таких запросах нет — условие
`if username and ...` ниже их уже отсекает. Поэтому шум сканеров не нужно
дополнительно фильтровать в этом файле — тот класс проблемы («сигнал тонет в шуме
сканера») здесь структурно невозможен: событие может появиться только для
запроса, прошедшего Caddy basic_auth.
"""
from __future__ import annotations
import logging
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
from app.core.ratelimit import _client_ip
from app.services.user_events import schedule_event, should_log_login
logger = logging.getLogger(__name__)
# Зеркалит app.main._PUBLIC_PATHS. Не импортируем напрямую из app.main — оно
# импортирует этот модуль (регистрирует middleware), обратный импорт дал бы
# циклическую зависимость.
#
# PR-D2: `/api/v1/trade-in/payments/notify` — заранее в skip-набор (defense-in-
# depth), хотя rbac ещё закрывает этот путь до PR-D3. Причины две:
# 1) сам путь не должен попадать в аудит вообще — тело нотификации содержит
# `Token`/`Pan`/`ExpDate` (см. `app/main.py._before_send`, тот же мотив, что
# и вырезание тела из мониторинга); хоть это middleware само по себе тело
# запроса в payload не пишет (только status_code/path/method), путь не
# должен зависеть от того, что кто-то потом добавит поле "body" в событие;
# 2) НЕ авторизующая проверка: `RequestAuditMiddleware` внешний относительно
# `rbac_guard` и читает сырой `X-Authenticated-User` (см. `main.py` порядок
# middleware) — анонимный POST на notify с подделанным заголовком
# `X-Authenticated-User: admin` иначе писал бы фальшивые события в
# `user_events` с атрибуцией admin, при этом rbac при этом ничего не знает
# (сам гейт отдельно, 401 всё равно вернёт до PR-D3).
_PUBLIC_PATHS = frozenset(
{
"/health",
"/docs",
"/redoc",
"/openapi.json",
"/api/v1/trade-in/payments/notify",
}
)
# Методы, меняющие состояние — для /api/v1/admin/* именно они должны попадать в
# аудит с атрибуцией (кто именно загрузил куки / включил авто-логин / поправил
# прокси / изменил настройки скрапера / выполнил bulk-операцию). GET/HEAD/OPTIONS
# на /admin/* остаются вне аудита (см. комментарий ниже — это просмотр дашбордов,
# не действие).
_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
class RequestAuditMiddleware(BaseHTTPMiddleware):
"""Логирует активность аутентифицированных пользователей в `user_events`."""
async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def]
response: Response = await call_next(request)
try:
username = request.headers.get("x-authenticated-user")
path = request.url.path
if username and path.startswith("/api/") and path not in _PUBLIC_PATHS:
ip = _client_ip(request)
ua = request.headers.get("user-agent")
method = request.method
success = response.status_code < 400
is_admin_path = path.startswith("/api/v1/admin/")
# Общий behavior/activity-поток — каждый authenticated API-запрос.
# /api/v1/admin/* исключаем из `api_request`: это ops-действия
# (просмотр дашбордов аудита/аналитики), а не поведение пилота —
# иначе запросы дашборда зашумляют top_paths и счётчики активности.
if not is_admin_path:
schedule_event(
event_type="api_request",
username=username,
ip=ip,
user_agent=ua,
path=path,
method=method,
payload={"status_code": response.status_code},
)
elif method in _MUTATING_METHODS:
# Admin-аудит (security-audit fix): раньше ЛЮБОЙ запрос под
# /api/v1/admin/* (включая меняющие состояние — загрузка кук,
# авто-логин, правка прокси, настройки скраперов, bulk-операции)
# полностью исключался из `user_events` тем же условием, что и
# шумные GET-дашборды — установить, КТО совершил действие, было
# невозможно. Пишем факт действия + атрибуцию (username/ip/path/
# method/статус) — БЕЗ тела запроса (там куки/пароли/секреты
# правки прокси), это НЕ payload-лог, а событие "что произошло".
schedule_event(
event_type="admin_action",
username=username,
ip=ip,
user_agent=ua,
path=path,
method=method,
payload={"status_code": response.status_code, "success": success},
)
# Дедуплицированный login/IP-audit сигнал — максимум раз в день
# на (юзер, IP, устройство). Security-audit fix: раньше событие
# всегда писалось как `login` независимо от исхода запроса —
# отражённая RBAC-попытка (валидный Caddy basic_auth, но
# 401/403 от rbac_guard: протухший X-Internal-Auth-Secret,
# неизвестная роль, scope-блок) была неотличима от настоящего
# входа. Теперь тип события расходится по `response.status_code`:
# `login` — успех, `login_failed` — otказ. Дедуп-бакет (once per
# user+ip+ua+day) НЕ разбит отдельно на success/fail (это
# потребовало бы менять `should_log_login` в user_events.py —
# вне scope этого фикса): если в рамках одного дня с этого же
# устройства сначала случился отказ, а затем реальный успешный
# вход, второе событие в тот же день не запишется — тот же
# компромисс дедупа, что был и раньше, разница только в том, что
# теперь ЕДИНСТВЕННОЕ событие дня корректно отражает, чем оно было.
if should_log_login(username, ip, ua):
schedule_event(
event_type="login" if success else "login_failed",
username=username,
ip=ip,
user_agent=ua,
path=path,
method=method,
payload={"status_code": response.status_code},
)
except Exception:
logger.warning("RequestAuditMiddleware: failed to record event", exc_info=True)
return response