All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m6s
#3360. Периметр /trade-in/api/v1/admin/* снаружи не срезан (caddy/sites/apps.caddy: блок `handle /trade-in/api/*` стоит выше `import caddy/users.caddy.snippet`), и срезать его нельзя: admin-UI кабинета зовёт эти пути ИЗ БРАУЗЕРА (12 файлов tradein-mvp/frontend/src — scrapers/**, components/scrapers/**, admin-audit-api.ts, GuardedRoute). Значит внешний аноним доходит до rbac_guard, а после #3324 (несуществующий путь → 404 роутера) 401 на существующей ручке стал оракулом: перебором имён восстанавливался список admin-API. Все три ветки «личность не установлена» (нет X-Authenticated-User, auth_mode=db_only, подделанный заголовок без #2213-секрета) на admin-префиксе теперь отдают ровно то же, что роутер даёт на несуществующий путь. Аутентифицированные не тронуты: admin — 200, не-admin — 403 «admin only» (прятать наличие ручки от опознанного человека незачем). Смоук периметра: пара admin-путей — существующий /admin/proxies и несуществующий /admin/users — оба обязаны быть 404 снаружи.
448 lines
31 KiB
Python
448 lines
31 KiB
Python
"""RBAC guard middleware — extracted from ``app/main.py``.
|
||
|
||
Historically ``rbac_guard`` lived inline in ``app/main.py`` and the test suite
|
||
(``tests/test_rbac.py``, ``tests/test_internal_auth_secret.py``) kept a
|
||
hand-maintained *copy* of it, labelled "MIRROR of app.main — keep in sync
|
||
manually". The copy drifted: it was missing the #2213
|
||
``X-Internal-Auth-Secret`` defense-in-depth check that the real guard has,
|
||
so a regression in that check would NOT have failed CI.
|
||
|
||
This module holds the real guard. Historically it had "no DB/lifespan/scheduler
|
||
side effects" beyond ``app.core.auth``/``app.core.config`` (both side-effect-free
|
||
at import time). #2552 (dual-mode DB-session auth) adds a conditional per-request
|
||
DB round trip via ``app.services.identity_store.identity_session`` — но ТОЛЬКО
|
||
когда запрос реально несёт session-cookie
|
||
(``request.cookies.get(settings.session_cookie_name)``); без cookie (весь
|
||
существующий тестовый трафик, legacy Caddy trusted-header запросы) ветка не
|
||
выполняется — ноль новых DB-побочных эффектов для старых путей. ``app/main.py``
|
||
and the test apps both import THIS module, so tests exercise the exact
|
||
production code path instead of a copy that can silently fall out of sync.
|
||
|
||
Сессия открывается через ``identity_session()``, а не через
|
||
``app.core.db.SessionLocal`` напрямую: guard — middleware, FastAPI-DI здесь нет,
|
||
а реестр людей при ``IDENTITY_STORE=auth`` лежит в другой БД. В дефолтном режиме
|
||
``identity_session()`` открывает ровно ``app.core.db.SessionLocal()`` — тот же
|
||
коннект-пул и то же поведение, что до эпика «единый вход».
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import re
|
||
import secrets
|
||
from collections.abc import Awaitable, Callable
|
||
from typing import Any
|
||
|
||
from fastapi import Request
|
||
from fastapi.responses import JSONResponse, Response
|
||
from starlette.routing import Match
|
||
|
||
from app.core.auth import get_role, is_path_allowed
|
||
from app.core.config import settings
|
||
from app.services.auth_session import DB_ROLE_PATHS, get_db_role_scope, get_session_user
|
||
from app.services.identity_store import identity_session
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 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/")
|
||
# #2552: /api/v1/auth/login + /logout — по определению вызываются ДО того, как
|
||
# клиент аутентифицирован (login) или могут вызываться с уже протухшей/отсутствующей
|
||
# сессией (logout — должен уметь чистить stale cookie без валидной auth). Свой
|
||
# rate-limit у /login отдельный (app.api.v1.auth._LOGIN_LIMITER), RateLimitMiddleware
|
||
# на /api/* всё равно применяется — это ослабляет ТОЛЬКО rbac_guard'овский
|
||
# auth-required gate, не остальные защиты.
|
||
#
|
||
# Инцидент 2026-07-31: /api/v1/trade-in/support/anon/* — по той же логике. Единственным
|
||
# каналом в поддержку был чат ЗА логином, а типовая причина писать в поддержку —
|
||
# «не могу войти» (в тот день так и вышло: «Практика» билась в форму весь день и
|
||
# достучаться из продукта не могла). Ветка НЕ трогает авторизованные
|
||
# /api/v1/trade-in/support/* — те по-прежнему требуют identity; у анонимной свой,
|
||
# заведомо более узкий бюджет (per-token + per-IP, см. app.api.v1.support) и своя
|
||
# идентичность из httpOnly-куки, которая структурно не может совпасть с чьим-то
|
||
# логином.
|
||
_PUBLIC_PATHS = frozenset(
|
||
{
|
||
"/health",
|
||
# Публичен ЗДЕСЬ и не публичен снаружи — это два разных периметра.
|
||
# Снимает `/metrics` агент Alloy изнутри docker-сети, где заголовка
|
||
# `X-Authenticated-User` нет ни у кого; без записи в этом множестве
|
||
# скрейп получал бы 401 и метрик не было бы вовсе. Наружу путь не
|
||
# открывается: у `gendsgn.ru` бэкенду «Меры» отдаётся только
|
||
# `/trade-in/api/*`, у `meraocenka.ru` работает белый список, и в обоих
|
||
# блоках на `/metrics` стоит явный `respond 404`.
|
||
"/metrics",
|
||
"/docs",
|
||
"/redoc",
|
||
"/openapi.json",
|
||
"/api/v1/auth/login",
|
||
"/api/v1/auth/logout",
|
||
# NB: префикс — /api/v1/trade-in (app/main.py include_router), а Caddy
|
||
# срезает ВНЕШНИЙ /trade-in ещё раньше. Т.е. снаружи это
|
||
# /trade-in/api/v1/trade-in/support/anon/*, сюда приходит вот такое.
|
||
"/api/v1/trade-in/support/anon/messages",
|
||
"/api/v1/trade-in/support/anon/unread",
|
||
"/api/v1/trade-in/support/anon/read",
|
||
# Версионирование (VERSION-файл + build-args, см. app/core/version.py):
|
||
# не секрет, читает только process env — быстрая справка для клиента/
|
||
# поддержки/смоук-теста, не должна требовать сессию.
|
||
"/api/v1/trade-in/version",
|
||
# GlitchTip webhook → Telegram (app/api/v1/glitchtip.py): вызывается
|
||
# ИЗ glitchtip-worker (docker-сеть gendesign_shared), не может нести
|
||
# X-Authenticated-User/сессию. Путь фиксированный и не секрет — секрет
|
||
# это query-параметр `secret`, который проверяет сам хендлер
|
||
# (secrets.compare_digest против TRADEIN_INTERNAL_AUTH_SECRET).
|
||
"/api/v1/trade-in/ops/glitchtip-webhook",
|
||
# Публичный B2C-периметр МЕРЫ (meraocenka.ru): у посетителя лендинга
|
||
# идентичности нет и не будет — Caddy на этом домене вообще без
|
||
# basic_auth. Обе ручки только читают (SELECT/прокси автокомплита) и не
|
||
# пишут в БД адрес физлица, поэтому открываются ДО контура согласия
|
||
# 152-ФЗ (#2895), а платный расчёт — только вместе с ним. Разбор
|
||
# периметра и бюджетов — app/api/public/mera.py.
|
||
#
|
||
# Строки ТОЧНЫЕ, префиксной ветки здесь нет: множество проверяется как
|
||
# `path in _PUBLIC_PATHS`, и превращать его в префиксный матчер ради
|
||
# двух путей значит расширить механизм, от которого зависит auth-гейт
|
||
# всего бэкенда. Гарантия «под /api/public/ не лежит ничего закрытого»
|
||
# держится на структуре пакета app/api/public/, а не на матчере.
|
||
"/api/public/mera/suggest",
|
||
"/api/public/mera/coverage",
|
||
# Витринные числа лэндинга (landing_stats, миграция 275): агрегаты по
|
||
# проду без единой персональной строки — их и показывают анонимному
|
||
# посетителю, ради чего метрики и считаются.
|
||
"/api/public/mera/stats",
|
||
# Витрина реальных ДКП-сделок против прогноза (миграция 276): читает
|
||
# СВОЮ таблицу-витрину, где по построению нет ни адреса, ни владельца —
|
||
# район + характеристики квартиры + пара «прогноз/факт».
|
||
"/api/public/mera/showcase",
|
||
# Анонимный расчёт и повторное чтение его бесплатной части. Оба —
|
||
# POST с точным путём: у чтения токен едет ТЕЛОМ, а не в URI, иначе
|
||
# капабилити-ссылка легла бы в access-лог Caddy рядом с IP посетителя
|
||
# (тот же довод, что у /suggest — см. app/api/public/mera.py).
|
||
# Обе ручки дополнительно закрыты флагом settings.public_estimate_enabled.
|
||
"/api/public/mera/estimate",
|
||
"/api/public/mera/estimate/read",
|
||
# Вебхук Т-Банка (app/api/v1/payments.py::notify): сервер-к-серверу,
|
||
# X-Authenticated-User/сессии у банка нет и быть не может. Путь не
|
||
# секрет — аутентификацией здесь работает подпись `Token` тела,
|
||
# которую проверяет сам хендлер (services/payments/token.py), плюс
|
||
# отдельный узкий rate-limit (core/ratelimit.py). Всё остальное
|
||
# платёжное (checkout, статус заказа) остаётся ЗАКРЫТЫМ — открыт ровно
|
||
# тот путь, который иначе получил бы 401 у банка.
|
||
"/api/v1/trade-in/payments/notify",
|
||
}
|
||
)
|
||
# Capability-ссылка на оплаченный отчёт: /api/v1/trade-in/r/<token>. Точной
|
||
# строкой её в множество выше не положить — токен переменный, а множество
|
||
# проверяется как `path in`. Отдельный кортеж префиксов, а не превращение
|
||
# `_PUBLIC_PATHS` в префиксный матчер: тот механизм держит auth-гейт всего
|
||
# бэкенда, расширять его семантику ради одного роута нельзя.
|
||
#
|
||
# Открывается ИМЕННО подпуть /r/ и ничего выше: правом доступа служит сам
|
||
# непредсказуемый токен (32 байта энтропии), сверяемый по payment_entitlements
|
||
# в app/api/v1/payments.py. У покупателя-физлица на meraocenka.ru идентичности
|
||
# нет и не будет, поэтому иного способа отдать ему купленное не существует.
|
||
_PUBLIC_PATH_PREFIXES = ("/api/v1/trade-in/r/",)
|
||
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед
|
||
# tradein-backend, а globs в roles.yaml — ВНЕШНИЕ (/trade-in/api/v1/**). Для
|
||
# scope-проверки восстанавливаем внешний путь.
|
||
_EXTERNAL_PREFIX = "/trade-in"
|
||
# Bootstrap-пути, доступные ЛЮБОМУ известному юзеру независимо от роли: /me отдаёт
|
||
# роль (expired → trial-экран), /brand/* — брендинг login/trial-экрана. Без них
|
||
# expired (roles.yaml paths:[] deny:/**) не получил бы роль и не увидел trial-экран.
|
||
_RBAC_BOOTSTRAP_EXEMPT = ("/api/v1/me", "/api/v1/brand")
|
||
|
||
|
||
def _db_glob_match(pattern: str, path: str) -> bool:
|
||
"""Мини-матчер для фиксированного набора DB-role паттернов
|
||
(``app.services.auth_session.DB_ROLE_PATHS`` — только формы ``/**`` и
|
||
``<prefix>/**``, не нужна полная semantics ``app.core.auth._glob_to_regex``
|
||
— тот модуль private и MIRROR'ится вручную с основным бэкендом, лишний
|
||
импорт private-символа оттуда увеличивал бы drift-риск)."""
|
||
if pattern == "/**":
|
||
return True
|
||
if pattern.endswith("/**"):
|
||
prefix = pattern[: -len("/**")]
|
||
return path == prefix or path.startswith(prefix + "/")
|
||
return path == pattern
|
||
|
||
|
||
def _db_role_path_allowed(role: str, path: str) -> bool:
|
||
paths, deny = get_db_role_scope(role)
|
||
if any(_db_glob_match(p, path) for p in deny):
|
||
return False
|
||
return any(_db_glob_match(p, path) for p in paths)
|
||
|
||
|
||
def _path_is_routed(request: Request) -> bool:
|
||
"""Есть ли у пути хоть один маршрут в роутере приложения.
|
||
|
||
#3324: guard — HTTP-middleware, он отрабатывает ДО роутинга, поэтому раньше
|
||
отвечал 401 и на пути, которых в приложении нет вовсе. Анониму этого хватало,
|
||
чтобы бесплатно разведать периметр: мусор под «интересным» префиксом
|
||
(``/api/public/whatever``) давал 401 с rbac-текстом, а мусор под публичным
|
||
префиксом — 404 роутера. Выключенная/закрытая ручка отличалась от
|
||
несуществующей. Несуществующий путь обязан отвечать одинаково независимо от
|
||
префикса, поэтому такие запросы пропускаются дальше — 404 отдаёт роутер, тот
|
||
же самый, что и на любой другой мусор.
|
||
|
||
ЧТО ИМЕННО РАЗМЕНЯНО (это НЕ «ослабления нет»). Раньше аноним получал 401 на
|
||
ЛЮБОЙ non-public путь — то есть оракул был ПРЕФИКСНЫЙ: он говорил «префикс
|
||
закрыт», но перечислить по нему таблицу маршрутов было нельзя. Теперь 401 =
|
||
«такой маршрут есть», 404 = «нет», и это уже оракул СУЩЕСТВОВАНИЯ маршрута:
|
||
перебором аноним восстанавливает список всех ручек приложения, включая имена
|
||
под ``/api/v1/admin/*``. Доступа это не даёт (закрытая ручка по-прежнему
|
||
отвечает 401/403), но карту периметра — даёт.
|
||
|
||
Почему размен принят. Точечно: 404 на несуществующее — норма HTTP, а
|
||
подобранное ИМЯ ручки без креденшелов бесполезно; исчезает же реальный
|
||
признак «этот префикс что-то охраняет». Это защита в глубину, и её глубина
|
||
здесь честно меньше, чем была.
|
||
|
||
⚠️ Периметр admin-путей снаружи НЕ срезан — проверено по конфигу, а не по
|
||
предположению: ``caddy/sites/apps.caddy`` → блок ``handle /trade-in/api/*``
|
||
делает ``uri strip_prefix /trade-in`` + ``reverse_proxy tradein-backend:8000``
|
||
и стоит ЦЕЛИКОМ ВЫШЕ ``import caddy/users.caddy.snippet`` (basic_auth), т.е.
|
||
у trade-in своя авторизация и внешнего barrier'а нет. Значит внешний
|
||
``https://gendsgn.ru/trade-in/api/v1/admin/...`` доходит до этого guard'а
|
||
анонимно, и перебор имён admin-ручек выполним снаружи, не только изнутри
|
||
docker-сети. Хочется убрать — резать надо в Caddy (отдельный issue), guard
|
||
этого не сделает: он про роли, а не про сетевой периметр.
|
||
|
||
``Match.NONE`` по ВСЕМ маршрутам значит, что выполнять нечего — хендлера, до
|
||
которого можно было бы дотянуться, не существует. ``Match.PARTIAL`` (путь
|
||
есть, метод другой) считается маршрутом и идёт в guard как раньше — там путь
|
||
реально существует, скрывать нечего.
|
||
|
||
Трейлинг-слэш был вторым каналом того же оракула в обход guard'а:
|
||
``/api/v1/me/`` не матчит ни один маршрут (``Match.NONE``) → guard пропускает
|
||
→ Starlette-роутер отвечал 307 на ``/api/v1/me``, то есть «маршрут есть»
|
||
сообщал редирект, а не 401. Закрыто в ``app/main.py``:
|
||
``FastAPI(redirect_slashes=False)`` — теперь такой путь даёт тот же 404, что
|
||
и любой другой мусор.
|
||
|
||
Неизвестное приложение (``scope["app"]`` не выставлен) — ведём себя как
|
||
раньше, то есть отдаём запрос в guard.
|
||
"""
|
||
router = getattr(request.scope.get("app"), "router", None)
|
||
if router is None:
|
||
return True
|
||
return any(route.matches(request.scope)[0] != Match.NONE for route in router.routes)
|
||
|
||
|
||
def _unauthenticated(path: str, detail: str) -> JSONResponse:
|
||
"""Отказ анониму: 401 везде, но на admin-префиксе — 404 роутера.
|
||
|
||
#3360: периметр admin-путей снаружи НЕ срезан (``caddy/sites/apps.caddy``,
|
||
блок ``handle /trade-in/api/*`` стоит выше ``import
|
||
caddy/users.caddy.snippet``), и срезать его нельзя — admin-UI кабинета зовёт
|
||
``/api/v1/admin/*`` ИЗ БРАУЗЕРА (tradein-mvp/frontend/src/app/scrapers/**,
|
||
components/scrapers/**, lib/admin-audit-api.ts). Значит внешний аноним
|
||
доходит сюда, а после #3324 (несуществующий путь → 404 роутера) 401 на
|
||
существующей ручке работал оракулом: перебором имён восстанавливался список
|
||
admin-API. Отвечаем ТЕМ ЖЕ, что роутер отдаёт на несуществующий путь, —
|
||
существующая и несуществующая admin-ручки анониму неразличимы.
|
||
|
||
Скрываем ровно от НЕаутентифицированного. Аутентифицированный не-admin
|
||
по-прежнему получает 403 «admin only»: он уже прошёл идентификацию, прятать
|
||
от него наличие ручки незачем, а 404 вместо 403 маскировал бы отладку.
|
||
|
||
Тело — литерал ``{"detail": "Not Found"}``: это ответ дефолтного
|
||
http_exception_handler FastAPI, тот же, что придёт из роутера. Тест
|
||
сравнивает два ЖИВЫХ ответа между собой, а не с этой константой, — если
|
||
фреймворк сменит формулировку, покраснеет он, а не прод.
|
||
"""
|
||
if _ADMIN_API_RE.match(path):
|
||
logger.info("RBAC: anonymous probe of admin path %s — cloaked as 404", path)
|
||
return JSONResponse(status_code=404, content={"detail": "Not Found"})
|
||
return JSONResponse(status_code=401, content={"detail": detail})
|
||
|
||
|
||
def _propagate_authenticated_user(request: Request, username: str) -> None:
|
||
"""Инжектит ``X-Authenticated-User`` в ASGI scope — ПЕРЕЗАПИСЫВАЯ, а не
|
||
только добавляя при отсутствии, — чтобы ``RateLimitMiddleware``/
|
||
``RequestAuditMiddleware`` (оба читают сырой заголовок напрямую,
|
||
#2213/#2550) и downstream route-хендлеры (читающие его через FastAPI
|
||
``Header()``) видели РЕЗОЛВЛЕННОГО ИЗ СЕССИИ юзера — без правок в каждом
|
||
из этих мест по отдельности (минимально инвазивный способ).
|
||
|
||
#2552 post-review fix (CRITICAL): раньше это была skip-if-present
|
||
мутация (``if request.headers.get(...): return``) — сессия резолвилась
|
||
ПЕРВОЙ (см. rbac_guard), но клиент-контролируемый ``X-Authenticated-User``
|
||
(который Caddy шлёт на КАЖДЫЙ прод-запрос) выигрывал у неё для ВСЕГО
|
||
downstream-трафика: атакующий с валидной cookie юзера ``alice`` мог
|
||
подделать заголовок ``X-Authenticated-User: victim`` и получить доступ к
|
||
данным victim в ~15 роутах, читающих заголовок напрямую
|
||
(``_assert_estimate_access*``, ``account_quota``, ``/trade-in/history``,
|
||
``support.py``) — работало в ОБОИХ auth_mode (dual и db_only), т.к. эти
|
||
хендлеры не знают про rbac_guard'овский ``from_session`` флаг, только про
|
||
сырой заголовок. Session-identity ДОЛЖНА быть источником истины, если
|
||
сессия резолвлена — полная перезапись, не skip.
|
||
|
||
Механизм: ``request.scope`` — ОДИН и тот же dict-объект, прокинутый по
|
||
ссылке через весь ASGI call chain (Starlette не копирует scope между
|
||
слоями middleware). Мутация ``scope["headers"]`` ЗДЕСЬ видна:
|
||
- downstream call_next() цепочке (ExceptionMiddleware → Router →
|
||
endpoint) — т.к. rbac_guard мутирует scope ДО вызова call_next();
|
||
- ``RequestAuditMiddleware`` — он внешний относительно rbac_guard
|
||
(см. app/main.py: последний ``add_middleware`` оборачивает
|
||
предыдущие) и читает ``request.headers`` уже ПОСЛЕ ``call_next()``
|
||
отработал весь внутренний стек, включая эту мутацию.
|
||
|
||
ASGI header-имена — всегда lowercase bytes (см. ASGI spec), поэтому
|
||
фильтр по ``b"x-authenticated-user"`` ловит заголовок независимо от
|
||
регистра, в котором его прислал клиент (Starlette уже нормализует).
|
||
|
||
Известное ограничение: ``RateLimitMiddleware`` тоже внешний относительно
|
||
rbac_guard, но читает заголовок ДО вызова call_next() (до того, как этот
|
||
guard успевает отработать) — для ЭТОГО конкретного запроса сессионный
|
||
юзер лимитируется по IP, а не по username (per-user множитель не
|
||
применяется). Не регрессия (IP-лимит применялся бы и раньше — до
|
||
добавления session-auth такие запросы вообще были 401), просто более
|
||
строгий бюджет специфично для session-cookie-запросов; при необходимости
|
||
точного per-user квотинга для DB-юзеров — переносить резолв сессии выше
|
||
RateLimit в app/main.py отдельным issue.
|
||
"""
|
||
request.scope["headers"] = [
|
||
(k, v) for k, v in request.scope.get("headers", []) if k != b"x-authenticated-user"
|
||
] + [(b"x-authenticated-user", username.encode("latin-1", "replace"))]
|
||
|
||
|
||
async def rbac_guard(
|
||
request: Request,
|
||
call_next: Callable[[Request], Awaitable[Response]],
|
||
) -> Response:
|
||
path = request.url.path
|
||
if path in _PUBLIC_PATHS or path.startswith(_PUBLIC_PATH_PREFIXES):
|
||
return await call_next(request)
|
||
|
||
# #3324: путь, которого нет в роутере, отвечает как любой несуществующий
|
||
# путь (404 роутера) — иначе 401 работает оракулом существования ручки.
|
||
if not _path_is_routed(request):
|
||
return await call_next(request)
|
||
|
||
username: str | None = None
|
||
role: str | None = None
|
||
from_session = False
|
||
|
||
# #2552: session-cookie резолвится ПЕРВЫМ. Если cookie нет вообще —
|
||
# request.cookies.get() возвращает None без единого похода в БД (ноль
|
||
# side-effects для всего существующего трафика без cookie).
|
||
token = request.cookies.get(settings.session_cookie_name)
|
||
if token:
|
||
session_user: dict[str, Any] | None = None
|
||
try:
|
||
with identity_session() as db:
|
||
session_user = get_session_user(db, token)
|
||
except Exception:
|
||
# Сюда попадает и AuthDatabaseNotConfiguredError (IDENTITY_STORE=auth
|
||
# без AUTH_DATABASE_URL): резолв сессии не состоялся, дальше работает
|
||
# тот же путь, что и при любом сбое БД, — auth_mode решает, пускать ли
|
||
# legacy trusted-header.
|
||
#
|
||
# ⚠️ Этот except НЕ должен быть тем, что ловит сломанный DSN: молча
|
||
# деградировать в legacy trusted-header означало бы раздавать права
|
||
# из roles.yaml в обход реестра (включая аккаунты с access_state
|
||
# 'disabled'/'trial_expired'), причём сутками — продуктовая БД жива,
|
||
# приложение работоспособно, сигнал только в логах. Поэтому
|
||
# конфигурацию проверяет lifespan (app/main.py): при
|
||
# IDENTITY_STORE=auth пустой DSN роняет СТАРТ. Здесь остаётся второй
|
||
# рубеж — реестр, отвалившийся уже после успешного старта, не имеет
|
||
# права отдавать 500.
|
||
logger.exception("RBAC: session lookup failed for %s", path)
|
||
if session_user is not None:
|
||
username = session_user["username"]
|
||
role = session_user["role"]
|
||
from_session = True
|
||
_propagate_authenticated_user(request, username)
|
||
|
||
if not from_session:
|
||
# auth_mode == "db_only" — легаси trusted-header путь ПОЛНОСТЬЮ
|
||
# отключён, даже если валидный X-Authenticated-User присутствует.
|
||
if settings.auth_mode != "dual":
|
||
return _unauthenticated(path, "valid session required")
|
||
|
||
# ---- legacy trusted-header path — BIT-FOR-BIT как было до #2552 ----
|
||
username = request.headers.get("X-Authenticated-User")
|
||
if not username:
|
||
return _unauthenticated(path, "no authenticated user (valid session required)")
|
||
|
||
# #2213 defense-in-depth: если общий секрет задан — запрос с X-Authenticated-User
|
||
# ОБЯЗАН нести валидный X-Internal-Auth-Secret (его добавляет Caddy из env).
|
||
# Иначе это подделка заголовка мимо Caddy (напр. изнутри gendesign_shared) → 401.
|
||
# Constant-time compare против timing-атак. Пусто = защита не активна (fail-open).
|
||
secret = settings.tradein_internal_auth_secret
|
||
if secret:
|
||
provided = request.headers.get("X-Internal-Auth-Secret", "")
|
||
if not secrets.compare_digest(provided, secret):
|
||
logger.warning(
|
||
"RBAC: X-Authenticated-User=%r без валидного X-Internal-Auth-Secret "
|
||
"на %s — возможная подделка заголовка мимо Caddy",
|
||
username,
|
||
path,
|
||
)
|
||
return _unauthenticated(path, "invalid or missing internal auth secret")
|
||
|
||
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"},
|
||
)
|
||
|
||
assert username is not None
|
||
assert role is not None
|
||
|
||
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"},
|
||
)
|
||
|
||
# #R2-H3: энфорсим scope (paths/deny) для ВСЕХ non-admin путей, а не
|
||
# только /admin/*. Bootstrap-пути (/me, /brand) исключены — иначе revoked/
|
||
# scope-narrowed юзер не смог бы получить свою роль вовсе.
|
||
if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT):
|
||
external_path = _EXTERNAL_PREFIX + path
|
||
# Матчер выбирается по РОДУ роли, а не только по источнику (#3316):
|
||
# с DB-first резолвом legacy trusted-header путь тоже может отдать роль
|
||
# реестра (employee/manager), а её паттернов в roles.yaml нет — сверка
|
||
# с `is_path_allowed` дала бы 403 на всё.
|
||
if from_session or role in DB_ROLE_PATHS:
|
||
allowed = _db_role_path_allowed(role, external_path)
|
||
else:
|
||
try:
|
||
allowed = is_path_allowed(role, external_path)
|
||
except Exception:
|
||
logger.exception(
|
||
"RBAC scope-check raised for %s %s (ext=%s) — fail-open",
|
||
username,
|
||
path,
|
||
external_path,
|
||
)
|
||
allowed = True
|
||
if not allowed:
|
||
logger.info(
|
||
"RBAC: scope-blocked %s (role=%s) from %s (ext=%s)",
|
||
username,
|
||
role,
|
||
path,
|
||
external_path,
|
||
)
|
||
return JSONResponse(
|
||
status_code=403,
|
||
content={"detail": "forbidden for role"},
|
||
)
|
||
|
||
return await call_next(request)
|