"""Trade-In MVP — FastAPI entry point. Standalone версия, выделена из основного gendesign repo для локальной разработки. Только trade-in фича; никаких других роутеров (concepts/parcels/analytics/etc) нет. """ from __future__ import annotations import logging import os import re import secrets from collections.abc import AsyncGenerator, Awaitable, Callable from contextlib import asynccontextmanager import sentry_sdk from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, Response 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, brand, buildings, geocode, lead, me, search, trade_in from app.core.auth import get_role, is_path_allowed 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.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", ) # Мониторинг ошибок — 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: 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 в отчёты before_send=scrub_pii_event, # дочищаем consumer-PII из тела error events 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)" ) # 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. # Public paths без auth (/health, /docs, /openapi.json) пропускаем — # X-Authenticated-User там не приходит из Caddy. _ADMIN_API_RE = re.compile(r"^/api/v1/admin/") _PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"}) # #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед # tradein-backend, а globs в roles.yaml — ВНЕШНИЕ (/trade-in/api/v1/**). Для # scope-проверки восстанавливаем внешний путь. _EXTERNAL_PREFIX = "/trade-in" # Bootstrap-пути, доступные ЛЮБОМУ известному юзеру независимо от роли: /me отдаёт # роль (expired → trial-экран), /brand/* — брендинг login/trial-экрана. Без них # expired (roles.yaml paths:[] deny:/**) не получил бы роль и не увидел trial-экран. _RBAC_BOOTSTRAP_EXEMPT = ("/api/v1/me", "/api/v1/brand") @app.middleware("http") async def rbac_guard( request: Request, call_next: Callable[[Request], Awaitable[Response]], ) -> Response: path = request.url.path if path in _PUBLIC_PATHS: return await call_next(request) username = request.headers.get("X-Authenticated-User") if not username: return JSONResponse( status_code=401, content={"detail": "no authenticated user (Caddy basic_auth required)"}, ) # #2213 defense-in-depth: если общий секрет задан — запрос с X-Authenticated-User # ОБЯЗАН нести валидный X-Internal-Auth-Secret (его добавляет Caddy из env). # Иначе это подделка заголовка мимо Caddy (напр. изнутри gendesign_shared) → 401. # Constant-time compare против timing-атак. Пусто = защита не активна (fail-open). secret = settings.tradein_internal_auth_secret if secret: provided = request.headers.get("X-Internal-Auth-Secret", "") if not secrets.compare_digest(provided, secret): logger.warning( "RBAC: X-Authenticated-User=%r без валидного X-Internal-Auth-Secret " "на %s — возможная подделка заголовка мимо Caddy", username, path, ) return JSONResponse( status_code=401, content={"detail": "invalid or missing internal auth secret"}, ) try: role = get_role(username) except KeyError: logger.warning("RBAC: unknown user %r tried %s", username, path) return JSONResponse( status_code=403, content={"detail": "user not in roles config"}, ) if _ADMIN_API_RE.match(path) and role != "admin": logger.info("RBAC: blocked %s (role=%s) from %s", username, role, path) return JSONResponse( status_code=403, content={"detail": "admin only"}, ) # #R2-H3: энфорсим roles.yaml scope (paths/deny) для ВСЕХ non-admin путей, а не # только /admin/*. Иначе revoked (role=expired, paths:[] deny:/**) или узко- # скоупленный аккаунт достаёт non-admin API (напр. POST /api/v1/search — # экспорт листингов), который roles.yaml ему запрещает. Bootstrap-пути (/me, # /brand) исключены выше по списку. roles.yaml globs внешние → восстанавливаем # внешний путь (Caddy срезал /trade-in). На сбой парса — fail-open + громкий # лог: не лочим платящего pilot из-за конфиг-бага (admin-гейт выше остаётся). if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT): external_path = _EXTERNAL_PREFIX + path try: allowed = is_path_allowed(role, external_path) except Exception: logger.exception( "RBAC scope-check raised for %s %s (ext=%s) — fail-open", username, path, external_path, ) allowed = True if not allowed: logger.info( "RBAC: scope-blocked %s (role=%s) from %s (ext=%s)", username, role, path, external_path, ) return JSONResponse( status_code=403, content={"detail": "forbidden for role"}, ) return await call_next(request) 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(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(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"])