gendesign/tradein-mvp/backend/app/main.py
bot-backend bab4b4f8ff
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m28s
Deploy Trade-In / test (push) Has been cancelled
Merge pull request 'fix(tradein/rbac): 401-оракул закрыт — несуществующий путь под публичным префиксом отвечает так же, как любой другой мусор' (#3352) from fix/3324-rbac-401-oracle into main
2026-09-05 18:19:27 +00:00

325 lines
21 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.

"""Trade-In MVP — FastAPI entry point.
Standalone версия, выделена из основного gendesign repo для локальной разработки.
Только trade-in фича; никаких других роутеров (concepts/parcels/analytics/etc) нет.
"""
from __future__ import annotations
import logging
import os
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
import sentry_sdk
from fastapi import FastAPI, Response
from fastapi.middleware.cors import CORSMiddleware
from sentry_sdk.integrations.fastapi import FastApiIntegration
from sentry_sdk.integrations.httpx import HttpxIntegration
from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from sentry_sdk.integrations.starlette import StarletteIntegration
from app.api.public import mera as public_mera
from app.api.v1 import (
admin,
audit,
auth,
brand,
buildings,
geocode,
glitchtip,
lead,
me,
payments,
privacy_admin,
search,
support,
team,
trade_in,
version,
)
from app.core.auth_db import get_auth_engine
from app.core.config import settings
from app.core.db import SessionLocal
from app.core.fdw import ensure_fdw_user_mapping
from app.core.http_errors import install_validation_error_handler
from app.core.log_scrub import install_query_secret_filter
from app.core.ratelimit import RateLimitMiddleware
from app.core.rbac import rbac_guard
from app.core.request_audit import RequestAuditMiddleware
from app.observability import metrics as app_metrics
from app.observability.sentry_scrub import scrub_pii_event
logger = logging.getLogger(__name__)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
# #tgsupport-web: этот процесс теперь тоже зовёт Telegram Bot API напрямую
# (app/api/v1/support.py — sendMessage при отправке веб-сообщения в топик), не
# только изолированный tgbot_main.py. httpx-INFO логирует ПОЛНЫЙ request URL,
# включая токен в пути (https://api.telegram.org/bot<TOKEN>/...) — то же самое
# закрытие, что уже стоит в tgbot_main.py (см. его комментарий), нужно и здесь.
logging.getLogger("httpx").setLevel(logging.WARNING)
# #3154: uvicorn access-log печатает полный путь С QUERY, а лог уезжает в Loki —
# так секрет вебхука GlitchTip (`?secret=…`) оказался в хранилище открытым.
# Маскируем значения чувствительных query-параметров ДО записи строки.
install_query_secret_filter()
# Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396).
# DSN из env GLITCHTIP_DSN; пусто (dev/текущий prod) → init не вызывается, NO-OP.
# Integrations: Starlette/FastAPI (request errors), SQLAlchemy/Httpx (breadcrumbs),
# Logging (logger.error → events). БЕЗ CeleryIntegration — prod не гоняет celery
# worker (in-app scheduler зовёт task-функции напрямую; compose = postgres/backend/
# frontend), отдельного broker нет → мониторить нечего.
if settings.glitchtip_dsn:
from app.observability.sentry_scrub import (
redact_telegram_bot_token,
scrub_payment_request_body,
scrub_public_address,
stabilize_retry_error_fingerprint,
)
def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None:
"""Композиция платёжный body-wipe + PII-scrub + Telegram bot-токен redaction +
RetryError fingerprint-стабилизация (#tgsupport-web, PR-D2, glitchtip-noise) —
см. app/tgbot_main.py._before_send (идентичная композиция без последнего шага,
тот бот geocoder не зовёт). Тот же риск: теперь этот процесс тоже держит
TelegramClient в стек-фреймах при ошибке sendMessage, а
include_local_variables=False ниже — первый рубеж защиты.
PR-D2: платёжный body-wipe идёт ПЕРВЫМ шагом, а не заменяет остальные —
режет `request.data` целиком только для `/payments/*`, остальные пути
(extra/contexts/traceback) по-прежнему проходят ключ-based scrub и
token-redaction. Тот же обработчик передан ОБОИМ каналам ниже
(before_send и before_send_transaction) — вчерашний баг в Птице закрыл
только error-канал, transaction-канал остался вообще без обработчика.
RetryError-стабилизация — этот процесс обслуживает /api/v1/geocode/*
(suggest/lookup/reverse), которые ретраят Nominatim через tenacity; см.
sentry_scrub.stabilize_retry_error_fingerprint."""
scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type]
if scrubbed is None:
return None
# Публичный периметр МЕРЫ: тело запроса — это ровно введённый адрес, а
# брэдкрамб исходящего вызова геокодера несёт его же в query. Публичная
# страница обещает, что адрес нигде не сохраняется; GlitchTip — внешний
# сервис, значит обещание распространяется и на него.
scrubbed = scrub_public_address(scrubbed, hint) # type: ignore[arg-type]
if scrubbed is None:
return None
scrubbed = scrub_pii_event(scrubbed, hint) # type: ignore[arg-type]
if scrubbed is None:
return None
detokened = redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type]
if detokened is None:
return None
return stabilize_retry_error_fingerprint(detokened, hint) # type: ignore[arg-type,return-value]
sentry_sdk.init(
dsn=settings.glitchtip_dsn,
environment=settings.environment,
release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
traces_sample_rate=0.0, # только ошибки, без performance-трейсов
send_default_pii=False, # не шлём client_name / client_phone в отчёты
include_local_variables=False, # #tgsupport-web: TelegramClient._request
# держит base URL с токеном в локальных переменных стек-фрейма — default
# sentry_sdk (True) приложил бы их к traceback открытым текстом.
before_send=_before_send,
# PR-D2: тот же обработчик на transaction-канал — traces_sample_rate=0.0
# сегодня не шлёт трейсы вообще, но это belt-and-suspenders на случай,
# если трейсинг когда-нибудь включат (см. docstring _before_send выше).
before_send_transaction=_before_send,
integrations=[
StarletteIntegration(),
FastApiIntegration(),
SqlalchemyIntegration(),
HttpxIntegration(),
LoggingIntegration(level=logging.INFO, event_level=logging.ERROR),
],
)
logging.getLogger("app.main").info("GlitchTip monitoring enabled")
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# #2213 defense-in-depth: если общий секрет не задан — trusted-header auth
# уязвим к подделке X-Authenticated-User изнутри docker-сети gendesign_shared.
# Один явный WARNING на старте, чтобы это не осталось незамеченным в проде.
if not settings.tradein_internal_auth_secret:
logger.warning(
"defense-in-depth НЕ активен: X-Authenticated-User принимается без "
"проверки внутреннего секрета — задай TRADEIN_INTERNAL_AUTH_SECRET в "
".env.runtime ОБОИХ стеков (Caddy главного стека + tradein-backend)"
)
# #2552: session_secret зарезервирован на будущее (напр. подписанные токены) —
# opaque session-токены (secrets.token_urlsafe, см. app.services.auth_session)
# НЕ требуют подписи, их валидность проверяется исключительно наличием строки
# в tradein_sessions + expires_at/is_active. Пустой session_secret НЕ должен
# ронять старт контейнера (не startup-fail) — только громкий WARNING, чтобы
# прод не остался без него незамеченно до момента, когда он реально понадобится.
if not settings.session_secret:
logger.warning(
"SESSION_SECRET пуст — не блокирует старт (opaque session-токены не "
"требуют подписи), но задай его в .env.runtime до появления фич, "
"которым подпись реально нужна"
)
# Эпик «единый вход»: при IDENTITY_STORE=auth реестр людей обязан быть
# СКОНФИГУРИРОВАН — иначе стартуем сломанными. Ошибка DSN не похожа на «БД
# недоступна»: продуктовая БД жива, приложение полностью работоспособно и
# может так работать сутками, а rbac_guard ловит AuthDatabaseNotConfiguredError
# вместе с любым другим сбоем резолва сессии и падает в legacy
# trusted-header ветку (auth_mode='dual'). То есть любой, кого пропустил
# Caddy basic_auth, молча получал бы права из roles.yaml — даже аккаунт с
# access_state='disabled'/'trial_expired' в реестре. Пусть лучше сломанный
# деплой не поднимется вообще, чем сутки раздаёт доступ мимо реестра.
#
# На ДЕФОЛТНЫЙ режим не влияет: при identity_store="tradein" (прод сегодня)
# ветка не выполняется, engine БД `auth` не создаётся, пустой
# AUTH_DATABASE_URL по-прежнему не ошибка.
if settings.identity_store == "auth":
# Наружу летит AuthDatabaseNotConfiguredError с внятным текстом
# (app.core.auth_db); create_engine к серверу не ходит, так что это
# проверка КОНФИГУРАЦИИ, а не доступности БД — недоступный сервер
# по-прежнему не мешает старту.
get_auth_engine()
logger.info("identity_store=auth: DSN общего реестра людей (БД `auth`) сконфигурирован")
# Платёжный контур МЕРЫ (Т-Банк эквайринг, PR-B — схема-only). Роутер/сервис
# ещё не существуют (PR-D), но kill-switch и конфиг уже здесь: если кто-то
# включит PAYMENTS_ENABLED=true в .env.runtime раньше, чем заведёт
# TBANK_TERMINAL_KEY/TBANK_PASSWORD, лучше не поднимать контейнер вообще,
# чем молча остаться без терминала/пароля на боевом трафике оплат.
if settings.payments_enabled and (
not settings.tbank_terminal_key or not settings.tbank_password.get_secret_value()
):
raise RuntimeError(
"PAYMENTS_ENABLED=true, но TBANK_TERMINAL_KEY/TBANK_PASSWORD пусты — "
"задай оба в .env.runtime до включения платёжного контура"
)
# FDW bootstrap: create/refresh USER MAPPING for gendesign_remote postgres_fdw server.
# Best-effort: failure does not abort startup, just logs.
try:
with SessionLocal() as db:
ensure_fdw_user_mapping(db)
except Exception:
logger.exception("FDW user mapping bootstrap failed — cadastral queries may fail")
# #2397 Part C: legacy in-app scheduler launch removed — scheduling lives exclusively
# in the tradein-scraper container (`python -m app.scheduler_main`, kit scheduler).
# Prod backend has always run with SCHEDULER_ENABLE=false (see docker-compose.prod.yml);
# this API process never actually launched scheduler_loop() in production.
yield
app = FastAPI(
title="Trade-In MVP API",
description="Оценка вторичного жилья (выкупная стоимость) — копия trade-in feature из gendesign", # noqa: E501
version="0.1.0",
lifespan=lifespan,
# #3324: трейлинг-слэш обходил rbac_guard как канал разведки периметра.
# `/api/v1/me/` не матчит ни один маршрут → guard пропускает (см.
# rbac._path_is_routed) → роутер отвечал 307 на `/api/v1/me`, т.е. «маршрут
# существует» сообщал редирект вместо 401. Выключение проверено на предмет
# поломок: ни один route не объявлен с трейлинг-слэшем (нет `@router.get("/")`
# и пустых путей), ни один из 173 вызовов `api/v1` во фронте
# (tradein-mvp/frontend/src) не заканчивается слэшем, mount/StaticFiles нет.
# Deny-правила на слэш тоже не зависят от редиректа — они переведены на
# глоб-форму специально ради этого (см. auth_session.DB_ROLE_PATHS).
redirect_slashes=False,
)
# 422 вместо 500 на Infinity/NaN во входе — разбор в app/core/http_errors.py.
install_validation_error_handler(app)
# RBAC: defense-in-depth поверх Caddy basic_auth + X-Authenticated-User
# (см. app/core/auth.py + auth/roles.yaml). Правила:
# 1) Любой non-public path требует X-Authenticated-User — иначе 401.
# 2) Юзер должен быть в roles.yaml — иначе 403 («неизвестный юзер ничего
# не видит» — decided 2026-05-25).
# 3) /api/v1/admin/* (= внешний /trade-in/api/v1/admin/* после Caddy
# `uri strip_prefix /trade-in`) — только role=admin, иначе 403.
# Guard body живёт в app/core/rbac.py (без DB/lifespan side effects), чтобы
# тесты могли импортировать РЕАЛЬНЫЙ guard вместо hand-maintained копии.
app.middleware("http")(rbac_guard)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Rate-limit публичного API (per-user / per-IP sliding window) — защита от абуза.
app.add_middleware(RateLimitMiddleware)
# Request-audit: пишет api_request/login события в user_events (Feature 2/3 foundation).
app.add_middleware(RequestAuditMiddleware)
# Метрики — ПОСЛЕДНИМ и потому самым внешним слоем: `add_middleware` вставляет в
# начало списка. Порядок здесь несущий. Изнутри не видно ни 401 от гварда, ни 429
# от ограничителя частоты — их отдают сами эти слои и до нас запрос бы не дошёл;
# а всплеск отказов авторизации и срабатывания лимитера это ровно тот сигнал,
# ради которого метрики и заводятся.
app.add_middleware(app_metrics.MetricsMiddleware)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok", "environment": settings.environment}
# FastAPI/Starlette НЕ добавляет HEAD автоматически к @app.get() (в отличие от
# raw Starlette Route с methods=["GET"]) — без явного handler'а HEAD /health
# отдаёт 405. NB: наружу через Caddy этот /health НЕ проксируется (только
# /trade-in/api/* → strip_prefix → tradein-backend:8000/api/v1/*), и никакой
# docker healthcheck на него сейчас тоже не настроен (grep по compose-файлам —
# только pg_isready для postgres) — маршрут пока используется лишь тестами.
# Внешний прод-симптом `HEAD gendsgn.ru/health -> 405` чинится в Site Finder
# (backend/app/main.py, за Caddyfile `handle /health`), не здесь.
# media_type="application/json" — Content-Type совпадает с GET; Content-Length
# сознательно НЕ вычисляем под байт GET-ответа (дублировало бы сборку payload)
# — RFC 9110 §9.3.2 разрешает опускать payload-заголовки (Content-Length) для
# HEAD, требует совпадения только заголовков представления (Content-Type).
# include_in_schema=False — по той же причине, что и у Site Finder: HEAD-проба это
# инфраструктура, а не контракт API. Здесь codegen-джоба пока нет, флаг ставим
# симметрично, чтобы схема двух бэкендов не разъезжалась.
@app.head("/health", include_in_schema=False)
def health_head() -> Response:
return Response(status_code=200, media_type="application/json")
app.include_router(app_metrics.router, tags=["observability"])
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"])
app.include_router(privacy_admin.router, prefix="/api/v1/admin", tags=["admin-privacy"])
app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"])
app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"])
app.include_router(version.router, prefix="/api/v1/trade-in", tags=["trade-in-version"])
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])
app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-support"])
app.include_router(glitchtip.router, prefix="/api/v1/trade-in", tags=["trade-in-ops"])
# Платёжный контур — весь за settings.payments_enabled (дефолт False): роутер
# подключён всегда, но каждая его ручка отвечает 503, пока контур выключен.
# Подключать по флагу было бы хуже: путь /payments/notify обязан существовать
# и отвечать предсказуемо, а не менять форму ответа вместе с конфигом.
app.include_router(payments.router, prefix="/api/v1/trade-in", tags=["trade-in-payments"])
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
app.include_router(search.router, prefix="/api/v1", tags=["search"])
app.include_router(me.router, prefix="/api/v1", tags=["me"])
app.include_router(team.router, prefix="/api/v1/team", tags=["team"])
# Публичный B2C-периметр МЕРЫ (meraocenka.ru). Префикс НЕ /api/v1/* намеренно —
# на публичном домене он проксируется целиком, поэтому под ним по определению
# не должно лежать ничего закрытого; разбор — в app/api/public/mera.py.
# Обе ручки перечислены в rbac._PUBLIC_PATHS точными строками.
app.include_router(public_mera.router, prefix="/api/public/mera", tags=["public-mera"])