The whole backend api/v1 test suite hits `app` via TestClient mimo Caddy, so rbac_guard (app/main.py) 401'd every authed request (no X-Authenticated-User) — 112 tests red, the suite effectively dead (which is how #994's district 500 shipped uncaught). Add `settings.testing` (default False — prod RBAC untouched) + a strictly-gated bypass at the top of rbac_guard; tests/conftest.py enables it (env TESTING=1 + settings.testing=True before app import). Security: bypass fires ONLY when settings.testing is True, which is set NOWHERE in prod (default False; only tests/conftest.py flips it). RBAC's 401/403 logic stays covered by tests/test_rbac.py (its own middleware copy, ignores the flag). Effect: 112 → 42 failed, 1590 → 1660 passed. Remaining 42 (+5 mv_layout env errors) are stale-mock/assertion drift + env-required tests — fixed in CI-rehab 2/3. Foundation for a real Forgejo pytest gate (3/3). Refs #944.
185 lines
7.2 KiB
Python
185 lines
7.2 KiB
Python
"""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,
|
||
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:
|
||
# 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(
|
||
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,
|
||
}
|