gendesign/tradein-mvp/backend/app/main.py
bot-backend eccb895db1
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
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 / frontend-checks (pull_request) Successful in 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 2m40s
feat(tradein): переключаемый реестр людей — подготовка переезда «Меры» в БД auth [PR-2b/6]
Дефолт не меняет ничего: IDENTITY_STORE="tradein" — это сегодняшний прод,
tradein_users/tradein_sessions, соединение с БД auth не открывается вообще.
Переключение делается одной переменной окружения ПОСЛЕ того, как на проде
появится пароль auth_app и будут скопированы данные. Так сделано намеренно:
мерж, который зависит от невыполненного ручного шага, — это мерж, который
ломает прод в момент невнимательности.

Ядро. app/services/identity_store.py — единственное место, знающее, в какой БД
и в каких таблицах живёт реестр. Имена таблиц берутся из фиксированного словаря
по значению флага, не конкатенацией с вводом. app/core/auth_db.py — ЛЕНИВЫЙ
engine БД auth (core/db.py создаёт свой на импорте; такое же для auth роняло бы
старт без DSN).

Одно понятие состояния доступа вместо двух. В tradein_users состояние — булев
is_active, в auth.users — access_state из трёх значений. Конверсия живёт в одной
функции to_access_state(): True→active, False→disabled, а неизвестная строка,
NULL или чужой тип → disabled с WARNING. Fail-closed выбран сознательно: если
следующая миграция добавит четвёртое состояние, оно по умолчанию НЕ будет
пускать. Проверка доступа — свойство can_sign_in, а не сравнение со строкой.

Логин в режиме auth. Пароль проверяется ВСЕГДА и ДО ветвления по состоянию —
иначе появляется timing-oracle и перечисление логинов. Верный пароль +
trial_expired → 403 с машиночитаемым code="access_expired", сессия НЕ создаётся.
Верный пароль + disabled → тот же generic 401, что и при неверном пароле.
Резолв уже выданной сессии пропускает только active — блокировка обрывает
сессию немедленно, а не по истечении sliding-refresh.

Старт падает явно, если IDENTITY_STORE=auth, а DSN не задан. Без этого ошибка
конфигурации не похожа на аварию: продуктовая БД жива, приложение работает, а
rbac_guard ловит исключение резолва вместе с любым другим сбоем и падает в
legacy trusted-header ветку — то есть сутками раздаёт права из roles.yaml мимо
реестра, включая аккаунты с disabled.

Форма входа понимает новый код ответа. Ветвление по detail.code, а не по тексту:
текст бэк вправе менять, код — нет.

Гранты соблюдены, а не обойдены: auth_app не имеет UPDATE на role/manager_id и
не имеет DELETE на users (миграция 004, column-level).

Тесты: 2996 passed (+59). Единственный красный — test_search_cache_hit —
предсуществующий: проверен контрольным полным прогоном на чистом main
(2937 passed, тот же красный).
2026-08-01 02:50:14 +03:00

209 lines
12 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
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.v1 import (
admin,
audit,
auth,
brand,
buildings,
geocode,
lead,
me,
search,
support,
team,
trade_in,
)
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.ratelimit import RateLimitMiddleware
from app.core.rbac import rbac_guard
from app.core.request_audit import RequestAuditMiddleware
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)
# Мониторинг ошибок — 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
def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None:
"""Композиция PII-scrub + Telegram bot-токен redaction (#tgsupport-web) —
см. app/tgbot_main.py._before_send (идентичная композиция, тот же риск:
теперь этот процесс тоже держит TelegramClient в стек-фреймах при ошибке
sendMessage, а include_local_variables=False ниже — первый рубеж защиты)."""
scrubbed = scrub_pii_event(event, hint) # type: ignore[arg-type]
if scrubbed is None:
return None
return redact_telegram_bot_token(scrubbed, 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,
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`) сконфигурирован")
# 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,
)
# 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)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok", "environment": settings.environment}
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(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(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(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"])