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.
145 lines
5.4 KiB
Python
145 lines
5.4 KiB
Python
"""Trade-In MVP — FastAPI entry point.
|
|
|
|
Standalone версия, выделена из основного gendesign repo для локальной разработки.
|
|
Только trade-in фича; никаких других роутеров (concepts/parcels/analytics/etc) нет.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import re
|
|
from collections.abc import AsyncGenerator, 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 app.api.v1 import admin, brand, geocode, me, search, trade_in
|
|
from app.core.auth import get_role
|
|
from app.core.config import settings
|
|
from app.core.db import SessionLocal
|
|
from app.core.fdw import ensure_fdw_user_mapping
|
|
from app.core.ratelimit import RateLimitMiddleware
|
|
from app.services.scheduler import scheduler_loop
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
)
|
|
|
|
# Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396).
|
|
# DSN из env GLITCHTIP_DSN; пусто (dev) → мониторинг не инициализируется.
|
|
if settings.glitchtip_dsn:
|
|
sentry_sdk.init(
|
|
dsn=settings.glitchtip_dsn,
|
|
environment=settings.environment,
|
|
traces_sample_rate=0.0, # только ошибки, без performance-трейсов
|
|
send_default_pii=False, # не шлём client_name / client_phone в отчёты
|
|
)
|
|
logging.getLogger("app.main").info("GlitchTip monitoring enabled")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
# FDW bootstrap: create/refresh USER MAPPING for gendesign_remote postgres_fdw server.
|
|
# Best-effort: failure does not abort startup, just logs.
|
|
try:
|
|
with SessionLocal() as db:
|
|
ensure_fdw_user_mapping(db)
|
|
except Exception:
|
|
logger.exception("FDW user mapping bootstrap failed — cadastral queries may fail")
|
|
|
|
# Startup: launch scheduler background task
|
|
scheduler_task = asyncio.create_task(scheduler_loop())
|
|
logger.info("FastAPI lifespan: scheduler task spawned")
|
|
yield
|
|
# Shutdown: cancel scheduler
|
|
scheduler_task.cancel()
|
|
try:
|
|
await scheduler_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
logger.info("FastAPI lifespan: scheduler cancelled cleanly")
|
|
|
|
|
|
app = FastAPI(
|
|
title="Trade-In MVP API",
|
|
description="Оценка вторичного жилья (выкупная стоимость) — копия trade-in feature из gendesign", # noqa: E501
|
|
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.
|
|
# 2) Юзер должен быть в roles.yaml — иначе 403 («неизвестный юзер ничего
|
|
# не видит» — decided 2026-05-25).
|
|
# 3) /api/v1/admin/* (= внешний /trade-in/api/v1/admin/* после Caddy
|
|
# `uri strip_prefix /trade-in`) — только 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:
|
|
return JSONResponse(
|
|
status_code=401,
|
|
content={"detail": "no authenticated user (Caddy basic_auth required)"},
|
|
)
|
|
|
|
try:
|
|
role = get_role(username)
|
|
except KeyError:
|
|
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=["*"],
|
|
)
|
|
# Rate-limit публичного API (per-IP sliding window) — защита от абуза.
|
|
app.add_middleware(RateLimitMiddleware)
|
|
|
|
|
|
@app.get("/health")
|
|
def health() -> dict[str, str]:
|
|
return {"status": "ok", "environment": settings.environment}
|
|
|
|
|
|
app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
|
|
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
|
|
app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"])
|
|
app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
|
app.include_router(search.router, prefix="/api/v1", tags=["search"])
|
|
app.include_router(me.router, prefix="/api/v1", tags=["me"])
|