gendesign/backend/app/main.py
Light1YT 3651c85f9b feat(rbac): enforce role-based access via X-Authenticated-User middleware
Backend-side enforcement поверх Caddy basic_auth — defense-in-depth для
12-юзеров на gendsgn.ru (admin + kopylov + 10 пилотных слотов).

- `auth/roles.yaml` — single source of truth: 2 роли (admin, pilot)
  + 12 user→role mappings. Bind-mounted в обоих backend контейнерах.
- `app/core/auth.py` (main + tradein-mvp mirror) — YAML loader через
  `pyyaml`, `get_role`/`get_user_scope`/`is_path_allowed` с fnmatch globs.
  Custom `_glob_to_regex` чтобы `/**` ≠ `/*` (matches multi-segment paths).
- `GET /api/v1/me` (main + tradein) — фронт читает scope при login,
  фильтрует nav по `allowed_paths`/`deny_paths`.
- `rbac_guard` middleware (main + tradein):
  * любой non-public path требует `X-Authenticated-User` → иначе 401
  * юзер должен быть в roles.yaml → иначе 403 («человек без ролей
    вообще ничего не видит» — decided 2026-05-25)
  * /api/v1/admin/* — только role=admin, иначе 403
- `Caddyfile` — `header_up X-Authenticated-User {http.auth.user.id}` в
  6 reverse_proxy блоках (gendsgn.ru main + trade-in + git.gendsgn.ru).
- Tests: 20 unit + integration в обоих стэках. Покрытие:
  no-header / unknown user / pilot / admin × admin-path / non-admin path.
  `_build_test_app()` копирует middleware inline, чтобы обойти heavy
  imports (weasyprint dlopen падает на macOS dev).

Public paths (/health, /docs, /redoc, /openapi.json) пропускаются —
X-Authenticated-User там просто не приходит из Caddy.

Frontend nav-filter + 403 fullscreen — отдельный PR B.
2026-05-26 11:14:47 +05:00

171 lines
6.6 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,
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", "/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.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok", "environment": settings.environment}