"""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.core.db.SessionLocal`` — но ТОЛЬКО когда запрос реально несёт 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. """ 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 app.core.auth import get_role, is_path_allowed from app.core.config import settings from app.core.db import SessionLocal from app.services.auth_session import get_db_role_scope, get_session_user 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, не остальные защиты. _PUBLIC_PATHS = frozenset( { "/health", "/docs", "/redoc", "/openapi.json", "/api/v1/auth/login", "/api/v1/auth/logout", } ) # #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`` — только формы ``/**`` и ``/**``, не нужна полная 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 _propagate_authenticated_user(request: Request, username: str) -> None: """Инжектит ``X-Authenticated-User`` в ASGI scope (если его там ещё нет), чтобы ``RateLimitMiddleware``/``RequestAuditMiddleware`` (оба читают сырой заголовок напрямую, #2213/#2550) и downstream route-хендлеры (читающие его через FastAPI ``Header()``) видели сессионного DB-юзера так же, как Caddy trusted-header юзера — без правок в каждом из этих мест по отдельности (минимально инвазивный способ). Механизм: ``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()`` отработал весь внутренний стек, включая эту мутацию. Известное ограничение: ``RateLimitMiddleware`` тоже внешний относительно rbac_guard, но читает заголовок ДО вызова call_next() (до того, как этот guard успевает отработать) — для ЭТОГО конкретного запроса сессионный юзер лимитируется по IP, а не по username (per-user множитель не применяется). Не регрессия (IP-лимит применялся бы и раньше — до добавления session-auth такие запросы вообще были 401), просто более строгий бюджет специфично для session-cookie-запросов; при необходимости точного per-user квотинга для DB-юзеров — переносить резолв сессии выше RateLimit в app/main.py отдельным issue. """ if request.headers.get("X-Authenticated-User"): return request.scope["headers"] = [ *request.scope.get("headers", []), (b"x-authenticated-user", username.encode("latin-1")), ] 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: 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 SessionLocal() as db: session_user = get_session_user(db, token) except Exception: 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 JSONResponse( status_code=401, content={"detail": "valid session required"}, ) # ---- legacy trusted-header path — BIT-FOR-BIT как было до #2552 ---- username = request.headers.get("X-Authenticated-User") if not username: return JSONResponse( status_code=401, content={"detail": "no authenticated user (Caddy basic_auth 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 JSONResponse( status_code=401, content={"detail": "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 if from_session: 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)