gendesign/tradein-mvp/backend/app/services/auth_session.py
bot-backend abb9398f3f
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 2m19s
CI Trade-In / backend-tests (pull_request) Successful in 3m13s
CI / openapi-codegen-check (pull_request) Successful in 3m34s
CI / backend-tests (pull_request) Successful in 16m7s
fix(tradein/rbac): скрыть «Доля в продаже» и «Кэш» от клиентских аккаунтов
Аккаунт praktika (DB-роль manager) видел оба пункта в топбаре на /trade-in/team.
Это внутренние инструменты — аналитика рынка и состояние кэшей/скраперов, —
клиентские аккаунты их видеть не должны (решение владельца продукта).

Гейт один — deny-список роли, потому что все три места сверяются с ним через
общий матчер: пункт меню (Topbar по scopePath из /me), страница (RouteGuard) и
серверные ручки (rbac_guard). Правка только фронта спрятала бы пункт, оставив
прямой URL и API открытыми.

Закрыто для employee/manager (DB_ROLE_PATHS) и для legacy pilot (roles.yaml):
  /trade-in/sale-share/**
  /trade-in/cache/**
  /trade-in/api/v1/buildings/**
  /trade-in/api/v1/trade-in/cache-stats/**

У cache-stats ГЛОБ, а не точный путь: точный паттерн — строгое равенство, его
обходит трейлинг-слэш ('…/cache-stats/' → allowed=True), и защита держалась бы
на Starlette redirect_slashes, а не на RBAC. Замерено после правки: все варианты
(слэш, %2f, ./, ../) дают 403, утечек нет.

Основной продукт не задет: buildings.py обслуживает ТОЛЬКО sale-share, секция
«Продажи в доме» на экране оценки питается estimate-хендлерами. admin и analyst
сознательно вне deny — запиннено тестом, иначе «синхронизация» списков закрыла
бы их молча.

Заодно починен КРАСНЫЙ pre-existing тест главного бэкенда:
backend/tests/test_rbac.py::test_get_role_known_users ждал pilot у всех
user1..user10, но user2 («Брусника») стал expired 2026-07-30. CI это пропустил —
auth/roles.yaml не входит в paths-filter backend/**, из-за чего сьют не бежал.

Тесты: 153 passed (tradein) + 24 passed (site-finder, было 23+1 failed).
Новые — e2e через реальный rbac_guard по session-ветке (именно ею ходит
praktika), пин deny_paths в выдаче /me, границы глоба и regression-guard'ы.
Проверены снятием deny: 7 тестов краснеют, т.е. не тавтологии.
2026-07-31 18:20:03 +03:00

289 lines
14 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.

"""Session-сервис для DB-backed auth (#2552, эпик #2549 — auth-core).
Схема: `tradein_users` + `tradein_sessions` (migration `192_tradein_users_auth.sql`).
Опаковые (`secrets.token_urlsafe`) токены-сессии — не JWT, не подписаны: валидность
проверяется исключительно наличием + `expires_at`/`is_active` строкой в БД, поэтому
`SESSION_SECRET` НЕ обязателен для работы этого модуля (зарезервирован на будущее,
см. `app.core.config.Settings.session_secret` docstring).
Все функции здесь принимают уже открытую `db: Session` — сами НЕ открывают
`SessionLocal()` (вызывающая сторона решает время жизни транзакции: `rbac_guard`
и `app.core.db.get_db()`-роуты открывают её по-разному). Это делает модуль
тривиально unit-тестируемым без патчинга `SessionLocal` — тесты просто передают
fake/real `Session`.
Ни одна функция не должна ронять вызывающий HTTP-запрос: DB-ошибки логируются
через `logger` вызывающей стороной (см. `app.core.rbac.rbac_guard`,
`app.api.v1.me`), сам сервис поднимает исключения как есть (это НЕ fire-and-forget
аудит-лог вроде `app.services.user_events`, а часть auth-decision — сбой обязан
быть виден вызывающему, чтобы тот мог fail-closed).
"""
from __future__ import annotations
import logging
import secrets
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
logger = logging.getLogger(__name__)
# Sliding-window refresh: last_seen_at/expires_at продлеваются НЕ чаще раза в
# 5 минут — иначе каждый API-запрос авторизованного юзера бил бы в БД лишним
# UPDATE (RBAC гоняет get_session_user на КАЖДЫЙ non-public запрос).
_SLIDING_REFRESH_INTERVAL = timedelta(minutes=5)
_TOKEN_BYTES = 32 # secrets.token_urlsafe(32) — 256 бит энтропии, ~43 символа
def create_session(
db: Session,
user_id: int,
ip: str | None = None,
user_agent: str | None = None,
) -> str:
"""Создаёт новую сессию для *user_id* и возвращает opaque-токен.
`expires_at = now() + settings.session_ttl_hours`. Коммитит сам (self-contained,
как `app.services.user_events.record_event`).
"""
token = secrets.token_urlsafe(_TOKEN_BYTES)
db.execute(
text(
"""
INSERT INTO tradein_sessions (token, user_id, expires_at, ip_address, user_agent)
VALUES (
:token, :user_id,
now() + make_interval(hours => CAST(:ttl_hours AS integer)),
CAST(:ip AS inet), :user_agent
)
"""
),
{
"token": token,
"user_id": user_id,
"ttl_hours": settings.session_ttl_hours,
"ip": ip,
"user_agent": user_agent,
},
)
db.commit()
return token
def get_session_user(db: Session, token: str) -> dict[str, Any] | None:
"""Резолвит сессионный токен в данные юзера, или None если сессия
невалидна (не найдена / истекла / юзер деактивирован).
Sliding refresh: если с последнего `last_seen_at` прошло >=5 минут —
продлевает `expires_at`/`last_seen_at` ОДНИМ UPDATE. Сбой refresh
(напр. read-replica) логируется и НЕ мешает вернуть валидного юзера —
это best-effort продление, а не часть решения "валидна ли сессия".
"""
if not token:
return None
row = db.execute(
text(
"""
SELECT s.user_id, s.expires_at, s.last_seen_at,
u.username, u.role, u.display_name, u.org_name, u.email, u.is_active
FROM tradein_sessions s
JOIN tradein_users u ON u.id = s.user_id
WHERE s.token = :token
"""
),
{"token": token},
).fetchone()
if row is None:
return None
now = datetime.now(UTC)
if row.expires_at is None or row.expires_at <= now:
return None
if not row.is_active:
return None
if row.last_seen_at is None or (now - row.last_seen_at) >= _SLIDING_REFRESH_INTERVAL:
try:
db.execute(
text(
"""
UPDATE tradein_sessions
SET last_seen_at = now(),
expires_at = now() + make_interval(hours => CAST(:ttl_hours AS integer))
WHERE token = :token
"""
),
{"ttl_hours": settings.session_ttl_hours, "token": token},
)
db.commit()
except Exception:
logger.warning(
"auth_session: sliding refresh failed for user_id=%r", row.user_id, exc_info=True
)
db.rollback()
return {
"user_id": row.user_id,
"username": row.username,
"role": row.role,
"display_name": row.display_name,
"org_name": row.org_name,
"email": row.email,
"is_active": row.is_active,
}
def get_user_by_username(db: Session, username: str) -> dict[str, Any] | None:
"""Возвращает строку `tradein_users` по username, или None если не найден.
Используется login-флоу (`app.api.v1.auth.login`) для password-проверки.
Отдаёт `password_hash` как есть (может быть NULL — переходный период,
см. migration 192 docstring) — вызывающая сторона решает, что с ним делать.
"""
row = db.execute(
text(
"""
SELECT id, username, password_hash, role, is_active,
display_name, org_name, email
FROM tradein_users
WHERE username = :username
"""
),
{"username": username},
).fetchone()
if row is None:
return None
return {
"user_id": row.id,
"username": row.username,
"password_hash": row.password_hash,
"role": row.role,
"is_active": row.is_active,
"display_name": row.display_name,
"org_name": row.org_name,
"email": row.email,
}
def revoke_session(db: Session, token: str) -> None:
"""Удаляет одну сессию по токену (logout). No-op если токен не найден."""
db.execute(text("DELETE FROM tradein_sessions WHERE token = :token"), {"token": token})
db.commit()
def revoke_user_sessions(db: Session, user_id: int) -> None:
"""Удаляет ВСЕ сессии юзера (напр. смена пароля / принудительный logout всех
устройств — не используется этим PR напрямую, задел для будущих admin-действий)."""
db.execute(text("DELETE FROM tradein_sessions WHERE user_id = :user_id"), {"user_id": user_id})
db.commit()
# ---------------------------------------------------------------------------
# DB-role → RBAC scope (paths/deny) — #2552 dual-mode.
# ---------------------------------------------------------------------------
#
# tradein_users.role ('admin'|'manager'|'employee', CHECK-констрейнт migration 192)
# НЕ являются ключами auth/roles.yaml (тот файл — legacy Caddy trusted-header путь,
# который этот эпик намеренно не трогает). Маппинг ниже даёт DB-ролям тот же
# paths/deny-смысл, что и legacy-ролям, БЕЗ правки roles.yaml:
# employee -> клиентский доступ: весь /trade-in/** МИНУС внутренние разделы
# (см. deny ниже — раньше было «ровно как legacy pilot»).
# manager -> employee + /api/v1/team/** (дашборд команды, #2556).
# admin -> полный доступ, как legacy admin.
#
# Почему «Доля в продаже» и «Кэш» в deny у ОБЕИХ клиентских ролей (2026-07-31,
# решение владельца продукта): это внутренние инструменты, а не продукт клиента.
# «Доля в продаже» — аналитика рынка (сколько квартир дома выставлено, срез по
# домам/ЖК), «Кэш» — состояние кэшей и скраперов. Клиентские аккаунты видеть их
# не должны; триггер — аккаунт praktika (DB-роль manager), у которого оба пункта
# висели в топбаре на /trade-in/team.
#
# Почему в deny И страницы (/trade-in/sale-share, /trade-in/cache), И их API
# (/trade-in/api/v1/buildings/**, /trade-in/api/v1/trade-in/cache-stats/**): один
# deny-список гейтит СРАЗУ ТРИ места, потому что все трое сверяются с ним через
# один и тот же матчер —
# 1) пункт меню: Topbar фильтрует NAV_ITEMS по scopePath из /me;
# 2) сама страница: RouteGuard проверяет абсолютный путь из /me;
# 3) серверные ручки: app.core.rbac.rbac_guard (deny проверяется ПЕРВЫМ,
# внешний путь реконструируется как '/trade-in' + path).
# Только страницы = пункт исчез, но прямой URL и API остались открыты; только
# API = мёртвый пункт меню с 403 на каждый фетч.
#
# Почему '/trade-in/api/v1/buildings/**' безопасно закрывать целиком: весь
# роутер app/api/v1/buildings.py обслуживает ТОЛЬКО раздел sale-share
# (/sale-share, /sale-share/summary, /{house_id}/listings). Экран оценки его не
# использует — секция «Продажи в доме» питается estimate-хендлерами
# (useEstimatePlacementHistory / useSalesVsListings), а BuildingListingsDrawer
# импортируется единственной страницей app/sale-share/page.tsx.
#
# NB (границы глоба): '<prefix>/**' компилируется в '^<prefix>(?:/.*)?$' — матчит
# сам prefix, его же с трейлинг-слэшем и подпути через '/', но НЕ соседей по
# префиксу (см. app.core.rbac._db_glob_match и app.core.auth._glob_to_regex).
# Поэтому '/trade-in/cache/**' не задевает '/trade-in/cache-stats', а
# '/trade-in/api/v1/trade-in/cache-stats/**' — не '/…/cache-statistics'.
#
# Почему у cache-stats ГЛОБ, а не «более точный» '/trade-in/api/v1/trade-in/
# cache-stats': точный паттерн — это строгое равенство, и его обходит обычный
# трейлинг-слэш (измерено: '…/cache-stats/' → allowed=True). Сегодня от этого
# спасает только Starlette redirect_slashes (307 на путь без слэша → там уже
# 403), т.е. защита держалась бы на роутере, а не на RBAC — достаточно
# выключить redirect_slashes или сменить роутер, и deny тихо перестанет
# работать. Глоб закрывает и сам путь, и слэш, и любые будущие подпути.
# НЕ «уточнять» обратно до точного пути.
#
# NB (ограничение мини-матчера — читать перед копированием паттернов):
# DB_ROLE_PATHS и pilot.deny в auth/roles.yaml — зеркала по СМЫСЛУ, но матчеры
# у них РАЗНЫЕ. app.core.rbac._db_glob_match понимает ТОЛЬКО три формы:
# '/**' | '<prefix>/**' | точный путь (строгое равенство).
# app.core.auth._glob_to_regex (roles.yaml) понимает сверх этого ещё одиночную
# '*' ('/foo/*' = один сегмент). Паттерн с одиночной '*', скопированный сюда из
# roles.yaml, станет ЛИТЕРАЛЬНОЙ строкой и МОЛЧА перестанет что-либо запрещать —
# без ошибки на импорте и без падения тестов, если на него нет прямого теста.
# Т.е. в DB_ROLE_PATHS допустимы только '/**', '<prefix>/**' и точный путь;
# одиночная '*' здесь = silent no-op.
DB_ROLE_PATHS: dict[str, tuple[list[str], list[str]]] = {
"employee": (
["/trade-in/**", "/trade-in/api/v1/**"],
[
"/admin/**",
"/api/v1/admin/**",
"/trade-in/api/v1/admin/**",
"/trade-in/sale-share/**",
"/trade-in/cache/**",
"/trade-in/api/v1/buildings/**",
"/trade-in/api/v1/trade-in/cache-stats/**",
],
),
"manager": (
["/trade-in/**", "/trade-in/api/v1/**", "/api/v1/team/**"],
[
"/admin/**",
"/api/v1/admin/**",
"/trade-in/api/v1/admin/**",
"/trade-in/sale-share/**",
"/trade-in/cache/**",
"/trade-in/api/v1/buildings/**",
"/trade-in/api/v1/trade-in/cache-stats/**",
],
),
"admin": (["/**"], []),
}
def get_db_role_scope(role: str) -> tuple[list[str], list[str]]:
"""Возвращает (allowed_paths, deny_paths) для DB-роли.
Неизвестная роль (не должно случиться — CHECK-констрейнт на колонке
ограничивает role тремя значениями) -> fail-closed (пустой allow, deny всё).
"""
return DB_ROLE_PATHS.get(role, ([], ["/**"]))