gendesign/backend/app/main.py
Light1YT 4847a2eb01
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / backend-tests (pull_request) Successful in 6m23s
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m25s
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m43s
Deploy / deploy (push) Successful in 1m38s
Deploy / build-worker (push) Successful in 7m39s
feat(chat): deterministic §22 chat foundation — POST /api/v1/chat/ask (no LLM)
Step 1 of #957. Answers parcel questions by reading the already-persisted
§22 SiteFinderReport (latest_run_for, schema "1.0", read-only) and returning
templated RU answers with engine numbers verbatim (§16, never fabricated).
Intent routing (explicit or RU keyword match) -> per-section renderers;
graceful on partial/missing sections and pending (no run / DB error) without
500s. Works with llm_enabled=False (llm_used always False); LLM composition
is Step 2. Mounted off /api/v1/chat so rbac_guard auto-requires an authed
known user.

Refs #957
2026-06-08 17:21:51 +05:00

203 lines
8.4 KiB
Python
Raw Permalink 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.

"""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,
chat,
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(chat.router, prefix="/api/v1/chat", tags=["chat"])
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,
}