gendesign/backend/app/main.py
bot-backend dc8f3f2019 feat(backend): add /api/v1/ping liveness endpoint
Noop liveness probe returning {"pong": true}. Registered as a public
path (no Caddy/RBAC auth) like /health so external monitoring can reach
it. Adds unit test via FastAPI TestClient.

Closes #633
2026-05-29 00:25:15 +03:00

173 lines
6.7 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.

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,
landing,
me,
parcels,
photos,
pilot,
ping,
trade_in,
users,
)
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)
# 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:
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(
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}