All checks were successful
CI Trade-In / changes (pull_request) Successful in 17s
CI / changes (pull_request) Successful in 16s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m53s
CI Trade-In / backend-tests (pull_request) Successful in 5m16s
CI / backend-tests (pull_request) Successful in 17m1s
Джоба openapi-codegen-check покраснела на этой ветке: она дампит app.openapi(), регенерирует frontend/src/types/api-types.ts и падает на расхождении. Добавленный HEAD /health попал в схему и потребовал правки сгенерированного файла. Регенерировать типы ради маршрута, который фронт никогда не вызывает, — лишний шум в generated-коде. HEAD-проба это инфраструктура для uptime-монитора, а не часть контракта, по которому фронт строит типы, поэтому include_in_schema=False здесь и по смыслу верно, а не только удобно. Флаг ставим в обоих бэкендах симметрично: у trade-in codegen-джобы пока нет, но расхождение схем между двумя бэкендами потом само станет источником вопросов.
531 lines
36 KiB
Python
531 lines
36 KiB
Python
"""GenDesign FastAPI application entrypoint."""
|
||
|
||
import logging
|
||
import os
|
||
import re
|
||
import threading
|
||
import time
|
||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||
from contextlib import asynccontextmanager
|
||
|
||
import sentry_sdk
|
||
from fastapi import FastAPI, Request
|
||
from fastapi.concurrency import run_in_threadpool
|
||
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,
|
||
market,
|
||
me,
|
||
own_projects,
|
||
parcels,
|
||
photos,
|
||
pilot,
|
||
ping,
|
||
trade_in,
|
||
users,
|
||
)
|
||
from app.core import auth_db
|
||
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_event
|
||
from app.services.auth_session import resolve_session_token
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# App-level логи в stdout контейнера (#1926 observability). По умолчанию у app.*
|
||
# логгеров нет StreamHandler → их INFO теряется (в `docker logs` видны только
|
||
# uvicorn-логгеры), поэтому полезные строки (напр. "OSRM road-distance applied",
|
||
# RBAC, analyze) невидимы при отладке на VPS. Вешаем один StreamHandler на корневой
|
||
# "app" логгер на уровне APP_LOG_LEVEL (default INFO). propagate оставляем True →
|
||
# Sentry/GlitchTip LoggingIntegration (хендлер на root) продолжает получать
|
||
# breadcrumbs/events. Двойного stdout нет: у root своего stdout-хендлера нет.
|
||
# Идемпотентно (маркер _gd_app_stream — guard против дубля при reload/повторном
|
||
# импорте). Только API-процесс (main.py); worker (celery_app.py) НЕ трогаем —
|
||
# там loader'ы чатятся per-sync, INFO бы зашумил.
|
||
_app_logger = logging.getLogger("app")
|
||
_app_logger.setLevel(getattr(logging, os.getenv("APP_LOG_LEVEL", "INFO").upper(), logging.INFO))
|
||
if not any(getattr(_h, "_gd_app_stream", False) for _h in _app_logger.handlers):
|
||
_stream = logging.StreamHandler()
|
||
_stream.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
|
||
_stream._gd_app_stream = True # type: ignore[attr-defined] # idempotency marker
|
||
_app_logger.addHandler(_stream)
|
||
|
||
# Инициализируем SDK до создания FastAPI app, чтобы все инструменты
|
||
# (middleware, маршруты) видели активный client с самого старта процесса.
|
||
# GlitchTip не поддерживает profiling — profiles_sample_rate=0.0.
|
||
if settings.glitchtip_dsn:
|
||
# before_send И before_send_transaction — ОБА на scrub_event (#2457-review):
|
||
# Starlette-интеграция кладёт request.data на transaction-scope так же, как
|
||
# на error-scope, поэтому голый scrub_sensitive_query (только URL) на
|
||
# before_send_transaction оставлял бы PII-канал открытым при любом
|
||
# glitchtip_traces_sample_rate > 0 (см. sentry_scrub.py module docstring).
|
||
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,
|
||
# Локальные переменные кадров стека НЕ уходят в мониторинг (#2753).
|
||
# Дефолт SDK — True: при любом исключении кадр несёт значения аргументов
|
||
# (телефон заявки, адрес, токен) под ПРОИЗВОЛЬНЫМИ именами, а scrub_event
|
||
# сверяет ИМЕНА ключей — такое он не ловит по построению. То есть это не
|
||
# дополнительная мера, а условие, без которого скраб не полон.
|
||
include_local_variables=False,
|
||
before_send=scrub_event,
|
||
before_send_transaction=scrub_event,
|
||
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]:
|
||
# Эпик «единый вход», fail-fast: AUTH_MODE=dual|db_only обязан иметь РАБОЧИЙ
|
||
# реестр — проверяется не только разбор DSN, но и живое соединение (`SELECT 1`,
|
||
# app/core/auth_db.py). Не соединились → контейнер НЕ стартует. Режим `legacy`
|
||
# (ДЕФОЛТ) → no-op: ни проверки DSN, ни создания engine, ни коннекта.
|
||
#
|
||
# Почему именно на старте, а не «разберёмся в рантайме»: неверный пароль, опечатка
|
||
# в хосте, не созданная БД `auth` иначе ловились бы `except`'ом вокруг резолва
|
||
# сессии в rbac_guard, и сломанная конфигурация выглядела бы как «ни у кого нет
|
||
# сессии» — СУТКАМИ, потому что продуктовая БД жива, приложение отвечает 200, а
|
||
# сигнал остаётся только в логах. Дешевле не стартовать: деплой падает сразу и
|
||
# громко.
|
||
auth_db.require_auth_db_configured()
|
||
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.
|
||
#
|
||
# Эпик «единый вход»: к правилу 1 добавляется ПЕРВЫЙ источник личности —
|
||
# сессионная кука общего реестра (БД `auth`). Выдаёт её единственная форма входа, у
|
||
# «Меры» (/trade-in/login); «Птица» сессии только читает. Кука host-only на
|
||
# gendsgn.ru с path="/" → браузер шлёт её и сюда. Порядок: кука → легаси-заголовок.
|
||
# Дальше — ВСЁ как раньше: роль из auth/roles.yaml, admin-гейт по _ADMIN_API_RE.
|
||
# Реестр отвечает на вопрос «кто ты», roles.yaml — «что тебе можно»; продуктовые
|
||
# роли реестра (auth.users.role) в «Птицу» намеренно не протаскиваются.
|
||
#
|
||
# ⚠️ AUTH_MODE=legacy ПО УМОЛЧАНИЮ — popup Caddy basic_auth ещё стоит и снимается
|
||
# ПОСЛЕДНИМ PR эпика. Пока режим legacy, этот файл ведёт себя бит-в-бит как до эпика:
|
||
# кука не читается, БД `auth` не открывается. `dual` — переходный режим (кука, при её
|
||
# отсутствии/сбое реестра фолбэк на заголовок), `db_only` — фолбэка нет вовсе.
|
||
#
|
||
# ⚠️ ДОЛГ, КОТОРЫЙ ОБЯЗАН БЫТЬ ЗАКРЫТ ДО СНЯТИЯ POPUP'А (не решается этим PR).
|
||
# Guard проверяет ровно две вещи: есть ли username в auth/roles.yaml (get_role) и
|
||
# admin-гейт по _ADMIN_API_RE. Списки `paths`/`deny` из roles.yaml на бэкенде НЕ
|
||
# применяются — это зафиксировано в самом auth/roles.yaml:33-35 («path-level
|
||
# enforcement делает frontend RouteGuard»). Следствие: в момент включения режима
|
||
# «Птицу» получает КАЖДЫЙ аккаунт реестра, чей username совпадает с записью в
|
||
# roles.yaml, — включая роль `expired` (user2: paths: [], deny: "/**"), которую
|
||
# сегодня останавливает только фронт. Это не регрессия (те же люди сегодня в
|
||
# caddy/users.caddy.snippet и добираются туда же через basic_auth), но эпик делает
|
||
# её несущей: (а) до снятия popup'а отзыв доступа имеет ДВА рубильника —
|
||
# caddy-snippet и access_state в реестре, их надо держать синхронными; (б) после
|
||
# снятия roles.yaml остаётся ЕДИНСТВЕННЫМ гейтом, и `expired` в нём станет чисто
|
||
# фронтовой фикцией. Перед включением: сверить `auth.users.username` на проде с
|
||
# `users:` в roles.yaml и решить — применять `paths`/`deny` на бэкенде или убрать
|
||
# `expired` как вводящий в заблуждение.
|
||
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
|
||
_PUBLIC_PATHS = frozenset({"/health", "/api/v1/ping", "/docs", "/redoc", "/openapi.json"})
|
||
|
||
|
||
def _propagate_authenticated_user(request: Request, username: str) -> None:
|
||
"""Инжектит `X-Authenticated-User` в ASGI-scope — ПЕРЕЗАПИСЫВАЯ, а не дополняя.
|
||
|
||
🔴 Перезапись, а не «поставить, если отсутствует» — это требование безопасности,
|
||
а не стилистика. В бэкенде «Птицы» ОДИННАДЦАТЬ мест читают этот заголовок НАПРЯМУЮ,
|
||
мимо guard'а, и решают по нему, кто автор/кому принадлежат данные:
|
||
• app/core/audit_middleware.py:169 — атрибуция строки аудита;
|
||
• app/api/v1/me.py:30 — чей scope отдать (роль + фильтры);
|
||
• app/api/v1/insights.py:74/124/138 — created_by + _require_user (POST/PUT/DELETE);
|
||
• app/api/v1/own_projects.py:69/115/131 — created_by + _require_user (POST/PUT/DELETE);
|
||
• app/api/v1/parcels.py:1481 — GET /{cad_num}/forecast;
|
||
• app/api/v1/parcels.py:1902 — POST /{cad_num}/analyze (created_by рана,
|
||
parcels.py:4212, и 3-й аргумент forecast_site_finder_report.delay, :4226);
|
||
• сам rbac_guard ниже — легаси-ветка.
|
||
Ни одно из них не знает про сессию: для них истина — сырой заголовок. Оставь мы
|
||
skip-if-present — клиент с ВАЛИДНОЙ кукой прошёл бы guard как он сам, а во все эти
|
||
места уехал бы его собственный подставленный `X-Authenticated-User: <кто угодно>`
|
||
(Caddy шлёт этот заголовок на каждый прод-запрос, так что «просто добавить» его
|
||
было бы некуда). Ровно этот баг ловили у «Меры» — #2552 post-review, CRITICAL.
|
||
Резолвнутая сессия ОБЯЗАНА быть единственным источником личности.
|
||
|
||
Механизм: `request.scope` — один и тот же dict, прокинутый ПО ССЫЛКЕ через весь
|
||
ASGI-стек (Starlette не копирует scope между слоями). Мутация здесь видна:
|
||
• всей downstream-цепочке — мы мутируем ДО вызова call_next();
|
||
• audit-middleware — он ВНУТРЕННИЙ относительно rbac_guard (см. комментарий у
|
||
app.middleware("http")(audit_log_middleware) выше: LIFO-регистрация даёт
|
||
порядок rbac_guard → audit → router), т.е. его Request строится уже после
|
||
мутации. У «Меры» этот слой, наоборот, внешний, и там мутация до него
|
||
доезжает только потому, что читается ПОСЛЕ call_next.
|
||
|
||
Имена заголовков в ASGI — по спеке всегда lowercase bytes, и uvicorn/TestClient
|
||
её соблюдают. Фильтр всё равно нормализует ключ сам (`k.lower()`), а не полагается
|
||
на спеку: попади в scope запись `b"X-Authenticated-User"` (другой ASGI-сервер,
|
||
самодельный слой, тест-харнесс) — точное сравнение оставило бы её в списке рядом с
|
||
нашей. Читатели при этом видели бы правильное значение (`Headers.get` лоуэркейсит
|
||
искомый ключ, но не хранимый, так что смешанный регистр не матчится никогда), то
|
||
есть дыры нет — но состояние «две записи с одним именем» в scope не должно
|
||
существовать: оно ложное по построению и ломает любой обход списка глазами.
|
||
`errors="replace"` в encode: латиницей логины реестра не ограничены, а падать
|
||
UnicodeEncodeError в auth-пути нельзя.
|
||
|
||
NB: `request.headers` САМОГО этого Request уже закеширован (мы читали cookies) и
|
||
останется старым. Это не мешает: в session-ветке guard больше не читает заголовок,
|
||
а нижележащие слои строят свой Request поверх обновлённого scope.
|
||
"""
|
||
request.scope["headers"] = [
|
||
(k, v) for k, v in request.scope.get("headers", []) if k.lower() != b"x-authenticated-user"
|
||
] + [(b"x-authenticated-user", username.encode("latin-1", "replace"))]
|
||
|
||
|
||
# Троттлинг алерта «реестр не отвечает». Резолв сессии идёт на КАЖДОМ non-public
|
||
# запросе с кукой, а `logger.exception` уровня ERROR уезжает событием в GlitchTip
|
||
# (LoggingIntegration event_level=ERROR, см. sentry_sdk.init выше) — то есть лежащий
|
||
# реестр давал бы поток событий, пропорциональный трафику: квота/rate-limit выгорают
|
||
# за минуты, и настоящие ошибки этого же периода теряются. Полный traceback печатаем
|
||
# не чаще раза в минуту (с числом подавленных за окно), остальное — WARNING без
|
||
# exc_info, чтобы факт продолжающегося сбоя всё равно был виден в логах.
|
||
# Лок нужен по-настоящему: функция исполняется в threadpool'е, то есть параллельно.
|
||
_REGISTRY_FAILURE_ALERT_INTERVAL_S = 60.0
|
||
_REGISTRY_FAILURE_LOCK = threading.Lock()
|
||
_registry_failure_last_alert = 0.0
|
||
_registry_failure_suppressed = 0
|
||
|
||
|
||
def _reset_registry_failure_throttle() -> None:
|
||
"""Сбрасывает окно троттлинга. Для тестов: состояние модульное и живёт между ними."""
|
||
global _registry_failure_last_alert, _registry_failure_suppressed
|
||
with _REGISTRY_FAILURE_LOCK:
|
||
_registry_failure_last_alert = 0.0
|
||
_registry_failure_suppressed = 0
|
||
|
||
|
||
def _log_registry_failure(path: str) -> None:
|
||
"""Логирует сбой резолва: раз в окно — ERROR с traceback, иначе WARNING.
|
||
|
||
Зовётся ТОЛЬКО из `except`-блока: `logger.exception` берёт traceback из текущего
|
||
sys.exc_info().
|
||
"""
|
||
global _registry_failure_last_alert, _registry_failure_suppressed
|
||
now = time.monotonic()
|
||
with _REGISTRY_FAILURE_LOCK:
|
||
alert = (now - _registry_failure_last_alert) >= _REGISTRY_FAILURE_ALERT_INTERVAL_S
|
||
if alert:
|
||
suppressed = _registry_failure_suppressed
|
||
_registry_failure_last_alert = now
|
||
_registry_failure_suppressed = 0
|
||
else:
|
||
suppressed = 0
|
||
_registry_failure_suppressed += 1
|
||
if alert:
|
||
logger.exception(
|
||
"RBAC: резолв сессии не удался на %s — эти запросы обслуживаются по "
|
||
"легаси-пути (Caddy basic_auth + X-Authenticated-User); подавлено таких же "
|
||
"за предыдущее окно: %d",
|
||
path,
|
||
suppressed,
|
||
)
|
||
else:
|
||
logger.warning(
|
||
"RBAC: резолв сессии не удался на %s (traceback подавлен троттлингом, "
|
||
"следующий — не раньше чем через %.0f с)",
|
||
path,
|
||
_REGISTRY_FAILURE_ALERT_INTERVAL_S,
|
||
)
|
||
|
||
|
||
def _resolve_session_username(token: str | None, path: str) -> str | None:
|
||
"""Логин из сессионной куки, либо None, если личность по куке не установлена.
|
||
|
||
🔴 СИНХРОННАЯ и вызывается ТОЛЬКО через `run_in_threadpool` (см. rbac_guard):
|
||
внутри — psycopg-I/O (checkout из пула + SELECT, раз в 5 минут ещё UPDATE и
|
||
commit). Позови её напрямую из корутины guard'а — и весь API «Птицы»
|
||
сериализуется за один round-trip к БД `auth` на каждый запрос, а недоступный
|
||
реестр (или исчерпанный пул) заморозит event loop целиком, включая /health. Ровно
|
||
этот инцидент уже был на соседнем middleware — #1202, см. комментарий в
|
||
app/core/audit_middleware.py:175-181, там он и починен через `run_in_threadpool`.
|
||
Токен принимается ГОТОВЫМ (а не `Request`) именно поэтому: разбор Cookie-заголовка
|
||
дёшев и делается на loop'е, в поток уезжает только строка.
|
||
|
||
None означает ровно одно — «личность по куке не установлена», и вызывающий обязан
|
||
трактовать это одинаково во всех трёх случаях: куки нет, кука невалидна (нет
|
||
строки / истекла / access_state не active), резолв УПАЛ.
|
||
|
||
Поведение при сбое БД `auth` (осознанный выбор, а не «поймали и забыли»): логируем
|
||
ERROR с traceback — он уезжает событием в GlitchTip (LoggingIntegration
|
||
event_level=ERROR, см. sentry_sdk.init выше), т.е. это алерт, а не строчка, которую
|
||
никто не увидит (частота ограничена окном, `_log_registry_failure`), — и в режиме
|
||
`dual` деградируем к легаси-ветке, то есть к сегодняшнему поведению: Caddy
|
||
basic_auth + X-Authenticated-User. В режиме `db_only` деградации нет: guard
|
||
отвечает 401.
|
||
|
||
Почему НЕ 503/500. Пока идёт переходный период, popup basic_auth стоит перед
|
||
бэкендом, и легаси-ветка защищена ровно тем же, чем защищён весь продукт сегодня, —
|
||
множество людей, способных вообще достучаться, не расширяется. Отдавать же 503
|
||
значит класть «Птицу» целиком из-за проблемы, которую basic_auth уже покрывает
|
||
(отозванный пароль роли auth_app, пересозданная БД `auth`, исчерпанный пул её
|
||
engine — всё это не мешает продуктовой БД gendesign работать).
|
||
|
||
Почему это не «тихий фолбэк на легаси». Опасный сценарий — не «реестр упал», а
|
||
«реестр не сконфигурирован»: тогда права раздавались бы из roles.yaml в обход
|
||
реестра (включая аккаунты с access_state disabled/trial_expired) бессрочно и молча.
|
||
Этот сценарий сюда НЕ доходит: конфигурацию проверяет lifespan, причём НЕ на глазок —
|
||
`require_auth_db_configured` открывает соединение и делает `SELECT 1`, так что мимо
|
||
него не проходят ни пустой/битый DSN, ни неверный пароль, ни опечатка в хосте, ни
|
||
отозванная роль (app/core/auth_db.py). Здесь остаётся только второй рубеж — реестр,
|
||
отвалившийся ПОСЛЕ успешного старта.
|
||
|
||
⚠️ Отдельно про отзыв доступа: пароли Caddy basic_auth (caddy/users.caddy.snippet)
|
||
и `auth.users.access_state` — РАЗНЫЕ списки. Человек, которому в реестре поставили
|
||
disabled/trial_expired, свой basic_auth-пароль не теряет, поэтому на время
|
||
недоступности реестра деградация возвращает его в строй. То есть отзыв тут не
|
||
«строже сегодняшнего», а откатывается к состоянию ДО отзыва — при включении режима
|
||
caddy-snippet надо прополоть под список активных аккаунтов реестра.
|
||
|
||
⚠️ Когда последний PR эпика снимет popup, эта деградация обязана уйти вместе с ним:
|
||
без basic_auth впереди фолбэк на легаси-заголовок превращается в дыру — заголовок
|
||
станет полностью клиентским. Механика перехода уже готова: `AUTH_MODE=db_only`
|
||
(см. app/core/config.py), в нём легаси-ветка недостижима и этот возврат None
|
||
означает 401, а не «попробуем заголовок».
|
||
"""
|
||
if not token:
|
||
# Нет куки — ни одного обращения к БД `auth`. Это весь сегодняшний трафик.
|
||
return None
|
||
try:
|
||
session_user = resolve_session_token(token)
|
||
except Exception:
|
||
_log_registry_failure(path)
|
||
return None
|
||
if session_user is None:
|
||
return None
|
||
return session_user.username
|
||
|
||
|
||
@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).
|
||
#
|
||
# ⚠️ Он ОТКЛЮЧАЕТ ВЕСЬ guard целиком, включая session-ветку ниже, — и это сказано
|
||
# здесь явно, чтобы не выглядело недосмотром. Следствие для тестов: сессионный путь
|
||
# НЕЛЬЗЯ проверять запросом к настоящему `app` через TestClient (conftest ставит
|
||
# settings.testing=True глобально, guard просто не отработает, тест «прошёл бы» ни о
|
||
# чём). Он и проверяется иначе: tests/test_auth_session_guard.py зовёт ЭТУ САМУЮ
|
||
# функцию напрямую, сняв settings.testing через monkeypatch, — то есть прод-код, а
|
||
# не копию. Копия guard'а в tests/test_rbac.py про куку намеренно НЕ знает и
|
||
# покрывает только режим legacy (там об этом написано). Сдвигать session-ветку ВЫШЕ
|
||
# bypass'а нельзя: получился бы полуработающий guard (личность резолвится, а 401/403
|
||
# не применяются) — состояние, которого нет ни в одном настоящем режиме.
|
||
if settings.testing:
|
||
return await call_next(request)
|
||
|
||
path = request.url.path
|
||
if path in _PUBLIC_PATHS:
|
||
return await call_next(request)
|
||
|
||
# Внешний `if` по режиму — не дубль проверки внутри resolve_session_token(), а
|
||
# гарантия инварианта «legacy = поведение не меняется ни на байт»: в нём не
|
||
# трогается даже request.cookies (разбор Cookie-заголовка).
|
||
token = (
|
||
request.cookies.get(settings.session_cookie_name) if settings.auth_session_enabled else None
|
||
)
|
||
|
||
# 🔴 Резолв — В THREADPOOL. Внутри синхронный psycopg-I/O, а мы в корутине: прямой
|
||
# вызов блокировал бы event loop на каждом запросе с кукой (инцидент #1202, тот же
|
||
# класс, что чинили в app/core/audit_middleware.py:175-183). `if token` перед
|
||
# хопом — не микрооптимизация: без куки резолвить нечего, и весь сегодняшний
|
||
# трафик не платит ни за поток, ни за коннект.
|
||
session_username = (
|
||
await run_in_threadpool(_resolve_session_username, token, path) if token else None
|
||
)
|
||
|
||
if session_username is not None:
|
||
username = session_username
|
||
# 🔴 До call_next и до всего остального: личность из сессии обязана вытеснить
|
||
# клиентский заголовок для одиннадцати прямых читателей (см. функцию).
|
||
_propagate_authenticated_user(request, username)
|
||
elif settings.auth_mode == "db_only":
|
||
# Легаси-ветка ОТКЛЮЧЕНА: нет валидной сессии → отказ, даже если
|
||
# X-Authenticated-User присутствует. Это конечное состояние эпика — режим
|
||
# включается тем же PR, который снимает `basic_auth` + `header_up` из Caddy и
|
||
# тем самым делает заголовок полностью клиентским. Отдельный текст ответа:
|
||
# «no authenticated user» ниже говорит про basic_auth, которого в этот момент
|
||
# уже нет.
|
||
return JSONResponse(
|
||
status_code=401,
|
||
content={"detail": "valid session required"},
|
||
)
|
||
else:
|
||
# ---- легаси trusted-header путь — БИТ-В-БИТ как до эпика ----
|
||
header_user = request.headers.get("X-Authenticated-User")
|
||
if not header_user:
|
||
# Любой 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)"},
|
||
)
|
||
username = header_user
|
||
|
||
try:
|
||
role = get_role(username)
|
||
except KeyError:
|
||
# Юзер в Caddy basic_auth, но не в roles.yaml → 403 на ВСЁ.
|
||
# Decided 2026-05-25: «человек без ролей вообще ничего не видит».
|
||
if session_username is not None:
|
||
# Тот же отказ, но отдельным сообщением: «есть в реестре, нет в roles.yaml» —
|
||
# это рассинхрон двух списков (типовой при заведении нового аккаунта), а не
|
||
# подделка заголовка, и чинится он в другом месте.
|
||
logger.warning(
|
||
"RBAC: сессия резолвлена в %r, но юзера нет в auth/roles.yaml — отказ на %s",
|
||
username,
|
||
path,
|
||
)
|
||
else:
|
||
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(market.router, prefix="/api/v1/market", tags=["market"])
|
||
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,
|
||
}
|
||
|
||
|
||
# FastAPI/Starlette НЕ добавляет HEAD автоматически к @app.get() (в отличие от
|
||
# raw Starlette Route с methods=["GET"]) — без явного handler'а HEAD /health
|
||
# отдаёт 405. Это боевой прод-эндпоинт: Caddyfile:60 `handle /health {
|
||
# reverse_proxy backend:8000 }` — именно ЭТОТ хендлер отвечает на
|
||
# `HEAD https://gendsgn.ru/health`, которым бьёт внешний uptime-monitor
|
||
# (GlitchTip PING-тип шлёт HEAD, не GET) и не мог отличить "жив" от "мёртв" по
|
||
# статусу. media_type="application/json" — Content-Type совпадает с GET;
|
||
# Content-Length сознательно НЕ вычисляем под байт GET-ответа (пришлось бы
|
||
# дублировать сборку payload) — RFC 9110 §9.3.2 разрешает опускать payload-
|
||
# заголовки (Content-Length) для HEAD, требует совпадения только заголовков
|
||
# представления (Content-Type).
|
||
# include_in_schema=False: HEAD-проба — инфраструктура (uptime-monitor), а не часть
|
||
# контракта, по которому фронт генерирует типы. Без этого флага операция попадает в
|
||
# app.openapi(), и job `openapi-codegen-check` краснеет, требуя перегенерации
|
||
# frontend/src/types/api-types.ts — правки в сгенерированном файле ради маршрута,
|
||
# который фронт никогда не вызывает.
|
||
@app.head("/health", include_in_schema=False)
|
||
async def health_head() -> Response:
|
||
return Response(status_code=200, media_type="application/json")
|