gendesign/backend/app/main.py
bot-backend 7be07efe70
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-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 1m59s
CI / backend-tests (pull_request) Successful in 15m13s
feat(sitefinder): «Птица» принимает сессию общего реестра [PR-2c/6]
Дефолт AUTH_MODE=legacy — сегодняшнее поведение байт-в-байт: соединение с БД
auth не открывается, кука не читается, отсутствие настроек не роняет старт.
Popup Caddy стоит и снимается последним PR эпика — инвариант «гейт уходит
последним» не нарушен.

У Site Finder не было авторизации вообще: rbac_guard доверял заголовку
X-Authenticated-User от Caddy. Теперь он умеет резолвить сессионную куку
общего реестра. ВЫДАВАТЬ сессии «Птица» не будет — логин один, у «Меры», а
кука host-only на gendsgn.ru с path=/ и так долетает до обоих продуктов.
Меньше кода и меньше мест, где можно ошибиться.

Режим трёхзначный, а не булев: legacy | dual | db_only. Это прямое следствие
ревью. При булевом флаге фолбэк «сессия не нашлась → верим заголовку» после
снятия popup превращался бы в полный обход аутентификации, и ничто в коде не
заставило бы про него вспомнить. В db_only легаси-ветка недостижима: 401.

Резолв уехал в threadpool. Три независимых ревьюера нашли одно и то же:
sync-запрос к БД в async-guard блокирует event loop на каждом non-public
запросе — ровно инцидент #1202, который в этом же файле уже лечили. Кука
разбирается на loop'е, в поток уезжает только строка токена; запрос без куки
не платит ни за поток, ни за коннект.

Срок годности сессии считают часы БД, а не приложения. Раньше проверка шла в
Python, а sliding-refresh переписывал строку через now() базы — при
расхождении часов истёкшая сессия не просто проходила, а продлевалась заново,
то есть воскресала навсегда. Теперь `expires_at > now()` в самом SELECT;
питоновская проверка оставлена вторым поясом.

Fail-fast на старте проверяет не синтаксис DSN, а живое соединение: SELECT 1.
Иначе неверный пароль или хост выглядели бы как «ни у кого нет сессии» —
сутками, потому что ошибку ловил бы except в guard'е.

Ещё из ревью: connect_timeout и statement_timeout по 3с (недоступный хост
вешал коннект на минуты); throttling логов сбоя реестра (иначе шторм в
GlitchTip на каждый запрос); тела SQL закреплены ассертами формы — мутация
любого фрагмента теперь красит тесты, до этого не красила ничего.

Дефолт хоста БД — postgres, и это зеркально «Мере». У неё gendesign-postgres,
потому что внутри её стека `postgres` — чужой контейнер; здесь стек главный, и
`postgres` из корневого compose и есть нужный сервер. Алиас gendesign-postgres
дефолтом был бы багом: контейнер beat состоит только в сети default и это имя
из него не разрезолвится.

Сверка реестра с ролевой картой сделана на живом проде: все 13 юзеров
auth.users присутствуют в auth/roles.yaml, ни один не получит 403 на всё.
Четыре QA-фикстуры (admintest, pilottest, analysttest, expiredtest) есть в
yaml, но не в реестре — после снятия popup войти ими через браузер будет
нельзя, только внутрисетевым заголовком.

Осознанный долг, вписан ⚠️-блоком перед guard'ом: paths/deny из roles.yaml
бэкендом не применяются (их энфорсит фронтовый RouteGuard), guard проверяет
только известность username и admin-пути. Это предсуществующее поведение;
менять его здесь значило бы изменить и легаси-ветку, то есть нарушить
«дефолт = сегодня».

Тесты: 4594 passed, 0 failed. Главный — подделка: валидная кука плюс
присланный клиентом X-Authenticated-User другого пользователя, выигрывает
кука, и роут, читающий заголовок напрямую, видит владельца куки. У «Птицы»
таких прямых читателей одиннадцать, поэтому перезапись ASGI-scope обязана быть
полной, а не skip-if-present (CRITICAL #2552).
2026-08-02 17:00:54 +03:00

499 lines
33 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.

"""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_sensitive_query
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:
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=scrub_sensitive_query,
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]:
# Эпик «единый вход», 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,
}