gendesign/tradein-mvp/backend/app/core/rbac.py
bot-backend 40fc94ee91
All checks were successful
CI / changes (pull_request) Successful in 12s
CI Trade-In / changes (pull_request) Successful in 12s
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 / frontend-checks (pull_request) Successful in 1m42s
CI Trade-In / backend-tests (pull_request) Successful in 2m31s
feat(tradein/support): чат поддержки без входа — экран логина и «доступа нет»
После cutover'а на свою авторизацию (#2558) единственным каналом в поддержку
остался чат ЗА логином, а самая частая причина писать в поддержку — как раз
«не могу войти». 2026-07-31 это выстрелило: «Практика» весь день билась в форму
входа (5 неудачных попыток с трёх разных IP, ни одной успешной) и сообщить об
этом из продукта не могла ничем — на /login не было ни чата, ни контакта.

Backend — 4 ручки /api/v1/trade-in/support/anon/* (public в rbac_guard):
- Идентичность анонима — opaque-токен в httpOnly+Secure куке; тред живёт в тех
  же web_support_threads под ключом `anon:<token>`. Двоеточие делает коллизию с
  реальным логином структурно невозможной (CHECK миграции 193 разрешает только
  `^[A-Za-z0-9._-]{3,64}$`) — аноним не может попасть в чужой тред.
- Изоляция та же, что у авторизованной ветки: thread_id снаружи не принимается
  ни в каком виде, тред резолвится ИСКЛЮЧИТЕЛЬНО из куки.
- Форма куки валидируется — мусор из браузера не становится ключом треда.
- В Telegram-топик уходит не токен (это bearer треда), а `anon-<6 hex sha256>`;
  зеркало помечено «[С САЙТА · БЕЗ ВХОДА]» — оператору важно, что аккаунта нет.
- Анти-абуз: два бюджета — per-token (12/мин) и per-IP (10/10мин). Второй ловит
  обход ротацией куки, без него публичная ручка записи в общий топик беззащитна.
- Кука и запись в БД — только после успешного sendMessage (порядок операций H1),
  неудачная отправка не закрепляет за посетителем пустой тред.

Frontend:
- `SupportScope = "auth" | "anon"` в useSupportChat: scope выбирает базовый путь
  и входит в ключ кэша (иначе после логина в панели висела бы переписка анонима).
  Дефолт "auth" — существующие места монтирования не меняются.
- `AnonSupportWidget` монтируется на /login и в NoAccessScreen — обе точки тупики,
  из которых пользователю больше некуда идти. На /login добавлена подсказка.

Ответы оператора маршрутизируются без изменений в bridge.py: реплай резолвится
по topic_message_id → thread_id, кто автор треда — там неважно.

Тесты: 13 новых на анонимную ветку + 2 на границу public/authed в rbac_guard.
62 passed (test_support + test_rbac).
2026-07-31 16:04:51 +03:00

277 lines
16 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.

"""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, не остальные защиты.
#
# Инцидент 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",
"/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",
}
)
# #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 _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:
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)