"""GenDesign FastAPI application entrypoint.""" import logging import os import re from collections.abc import AsyncIterator, 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.celery import CeleryIntegration 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_cadastre, admin_etl, admin_jobs, admin_leads, admin_scrape, admin_weight_profiles, analytics, concepts, custom_pois, insights, landing, locations, me, own_projects, parcels, photos, pilot, ping, trade_in, users, ) from app.core.audit_middleware import audit_log_middleware from app.core.auth import get_role from app.core.config import settings from app.observability.sentry_scrub import scrub_sensitive_query logger = logging.getLogger(__name__) # Инициализируем SDK до создания FastAPI app, чтобы все инструменты # (middleware, маршруты) видели активный client с самого старта процесса. # GlitchTip не поддерживает profiling — profiles_sample_rate=0.0. 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=settings.glitchtip_traces_sample_rate, profiles_sample_rate=0.0, send_default_pii=False, before_send_transaction=scrub_sensitive_query, integrations=[ StarletteIntegration(), FastApiIntegration(), CeleryIntegration(monitor_beat_tasks=True), SqlalchemyIntegration(), HttpxIntegration(), LoggingIntegration(level=logging.INFO, event_level=logging.ERROR), ], ) logger.info( "GlitchTip SDK initialised (env=%s, traces=%.2f)", settings.environment, settings.glitchtip_traces_sample_rate, ) @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: yield app = FastAPI(title="GenDesign API", version="0.1.0", lifespan=lifespan) # §19 audit log (#962, EPIC18): аудит sensitive actions (analyze/forecast/export). # Регистрируем ПЕРВЫМ → Starlette применяет HTTP-middleware в LIFO-порядке # (add_middleware insert(0); build_middleware_stack wraps reversed), поэтому # rbac_guard (зарегистрирован НИЖЕ) оказывается ВНЕШНИМ слоем, а audit — внутри: # request flow = rbac_guard → audit → router. Аудитим ТОЛЬКО запросы, которые # rbac_guard уже пропустил (его 401/403 short-circuit'ы не доходят до audit). # Best-effort: сбой записи не ломает запрос (см. app/core/audit_middleware.py). app.middleware("http")(audit_log_middleware) # 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 # (Caddy пробрасывает заголовок только для авторизованных юзеров; нет # заголовка = локальный curl мимо Caddy = доступ запрещён). # 2) Юзер должен быть в roles.yaml — иначе 403 («неизвестный юзер ничего # не видит»). Это закрывает кейс если Caddy basic_auth пропустил кого-то # из снятой записи, или прокси-подмена заголовка. # 3) /api/v1/admin/* — только 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", "/api/v1/ping", "/docs", "/redoc", "/openapi.json"}) @app.middleware("http") async def rbac_guard( request: Request, call_next: Callable[[Request], Awaitable[Response]], ) -> Response: # Test-mode bypass: pytest бьёт по app мимо Caddy → нет X-Authenticated-User. # СТРОГО gated на settings.testing (default False) — прод RBAC не затронут. # RBAC-логика покрыта отдельно в tests/test_rbac.py (своя копия middleware). if settings.testing: return await call_next(request) path = request.url.path if path in _PUBLIC_PATHS: return await call_next(request) username = request.headers.get("X-Authenticated-User") if not username: # Любой non-public path без auth-header → 401. Локальный curl мимо Caddy # или прокси-фронт без header_up. 401 точнее чем 403 — "сначала # аутентифицируйся". return JSONResponse( status_code=401, content={"detail": "no authenticated user (Caddy basic_auth required)"}, ) try: role = get_role(username) except KeyError: # Юзер в Caddy basic_auth, но не в roles.yaml → 403 на ВСЁ. # Decided 2026-05-25: «человек без ролей вообще ничего не видит». 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"}, ) return await call_next(request) app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.include_router(concepts.router, prefix="/api/v1/concepts", tags=["concepts"]) app.include_router(parcels.router, prefix="/api/v1/parcels", tags=["parcels"]) app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"]) app.include_router(admin_scrape.router, prefix="/api/v1/admin/scrape", tags=["admin"]) app.include_router(admin_jobs.router, prefix="/api/v1/admin/jobs", tags=["admin"]) app.include_router(admin_leads.router, prefix="/api/v1/admin/leads", tags=["admin"]) app.include_router( admin_weight_profiles.router, prefix="/api/v1/admin/site-finder/weight-profiles", tags=["admin", "site-finder"], ) app.include_router(photos.router, prefix="/api/v1/photos", tags=["photos"]) app.include_router(custom_pois.router, prefix="/api/v1/custom-pois", tags=["custom-pois"]) app.include_router(locations.router, prefix="/api/v1/locations", tags=["locations"]) app.include_router(insights.router, prefix="/api/v1/insights", tags=["insights"]) app.include_router(own_projects.router, prefix="/api/v1/own-projects", tags=["own-projects"]) app.include_router( admin_cadastre.router, prefix="/api/v1/admin/cadastre", tags=["admin", "cadastre"], ) app.include_router( admin_etl.router, prefix="/api/v1/admin/etl", tags=["admin", "etl"], ) app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"]) app.include_router(landing.router, prefix="/api/v1", tags=["landing"]) app.include_router(pilot.router, prefix="/api/v1/pilot", tags=["pilot"]) app.include_router(users.router, prefix="/api/v1", tags=["users"]) app.include_router(me.router, prefix="/api/v1", tags=["me"]) app.include_router(ping.router, prefix="/api/v1", tags=["ping"]) @app.get("/health") async def health() -> dict[str, str]: return { "status": "ok", "environment": settings.environment, "version": app.version, }