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