gendesign/tradein-mvp/backend/app/services/auth_session.py
bot-backend eccb895db1
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
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 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 2m40s
feat(tradein): переключаемый реестр людей — подготовка переезда «Меры» в БД auth [PR-2b/6]
Дефолт не меняет ничего: IDENTITY_STORE="tradein" — это сегодняшний прод,
tradein_users/tradein_sessions, соединение с БД auth не открывается вообще.
Переключение делается одной переменной окружения ПОСЛЕ того, как на проде
появится пароль auth_app и будут скопированы данные. Так сделано намеренно:
мерж, который зависит от невыполненного ручного шага, — это мерж, который
ломает прод в момент невнимательности.

Ядро. app/services/identity_store.py — единственное место, знающее, в какой БД
и в каких таблицах живёт реестр. Имена таблиц берутся из фиксированного словаря
по значению флага, не конкатенацией с вводом. app/core/auth_db.py — ЛЕНИВЫЙ
engine БД auth (core/db.py создаёт свой на импорте; такое же для auth роняло бы
старт без DSN).

Одно понятие состояния доступа вместо двух. В tradein_users состояние — булев
is_active, в auth.users — access_state из трёх значений. Конверсия живёт в одной
функции to_access_state(): True→active, False→disabled, а неизвестная строка,
NULL или чужой тип → disabled с WARNING. Fail-closed выбран сознательно: если
следующая миграция добавит четвёртое состояние, оно по умолчанию НЕ будет
пускать. Проверка доступа — свойство can_sign_in, а не сравнение со строкой.

Логин в режиме auth. Пароль проверяется ВСЕГДА и ДО ветвления по состоянию —
иначе появляется timing-oracle и перечисление логинов. Верный пароль +
trial_expired → 403 с машиночитаемым code="access_expired", сессия НЕ создаётся.
Верный пароль + disabled → тот же generic 401, что и при неверном пароле.
Резолв уже выданной сессии пропускает только active — блокировка обрывает
сессию немедленно, а не по истечении sliding-refresh.

Старт падает явно, если IDENTITY_STORE=auth, а DSN не задан. Без этого ошибка
конфигурации не похожа на аварию: продуктовая БД жива, приложение работает, а
rbac_guard ловит исключение резолва вместе с любым другим сбоем и падает в
legacy trusted-header ветку — то есть сутками раздаёт права из roles.yaml мимо
реестра, включая аккаунты с disabled.

Форма входа понимает новый код ответа. Ветвление по detail.code, а не по тексту:
текст бэк вправе менять, код — нет.

Гранты соблюдены, а не обойдены: auth_app не имеет UPDATE на role/manager_id и
не имеет DELETE на users (миграция 004, column-level).

Тесты: 2996 passed (+59). Единственный красный — test_search_cache_hit —
предсуществующий: проверен контрольным полным прогоном на чистом main
(2937 passed, тот же красный).
2026-08-01 02:50:14 +03:00

334 lines
18 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).
Схема НЕ зашита: имена таблиц и имя колонки состояния доступа берутся из
`app.services.identity_store.identity_schema()` — эпик «единый вход» переводит
реестр людей с `tradein_users`/`tradein_sessions` (migration
`192_tradein_users_auth.sql`, БД tradein) на `users`/`sessions` (БД `auth`,
миграции data/sql/auth/001-004) флагом `IDENTITY_STORE`, дефолт которого =
сегодняшнее прод-поведение. Никаких других отличий между режимами у этого
модуля нет: SQL один и тот же, подставляются только имена из фиксированного
словаря `identity_store._SCHEMAS`.
Опаковые (`secrets.token_urlsafe`) токены-сессии — не JWT, не подписаны: валидность
проверяется исключительно наличием строки + `expires_at` + состоянием доступа
юзера в БД, поэтому `SESSION_SECRET` НЕ обязателен для работы этого модуля
(зарезервирован на будущее, см. `app.core.config.Settings.session_secret` docstring).
Все функции здесь принимают уже открытую `db: Session` — сами НЕ открывают
сессию (вызывающая сторона решает время жизни транзакции: `rbac_guard` и
роуты открывают её по-разному). ⚠️ Это ОБЯЗАНА быть сессия РЕЕСТРА
(`identity_store.identity_session()` / `Depends(get_identity_db)`), а не
`app.core.db.get_db`: при `IDENTITY_STORE=auth` запрос уйдёт в БД tradein,
где таблиц `users`/`sessions` нет. В дефолтном режиме это один и тот же объект.
Модуль остаётся тривиально unit-тестируемым — тесты просто передают
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
from app.services.identity_store import identity_schema, to_access_state
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`).
"""
schema = identity_schema()
token = secrets.token_urlsafe(_TOKEN_BYTES)
db.execute(
text(
f"""
INSERT INTO {schema.sessions_table}
(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 если сессия
невалидна (не найдена / истекла / доступ юзера не `active`).
Состояние доступа: пропускает ТОЛЬКО `AccessState.ACTIVE`. Любое другое
(`disabled`, `trial_expired`, а также нераспознанное — `to_access_state`
fail-closed'ит его в `disabled`) делает уже выданную сессию недействительной
немедленно, без ожидания TTL. Это то же решение, что и в булевой схеме
(`is_active = false` → None), просто теперь состояний больше одного:
«пробный период истёк» гасит живую сессию так же, как блокировка — иначе
сотрудник, залогиненный до истечения пробного доступа, продолжал бы
работать, а sliding-refresh продлевал бы ему сессию бесконечно.
Sliding refresh: если с последнего `last_seen_at` прошло >=5 минут —
продлевает `expires_at`/`last_seen_at` ОДНИМ UPDATE. Сбой refresh
(напр. read-replica) логируется и НЕ мешает вернуть валидного юзера —
это best-effort продление, а не часть решения "валидна ли сессия".
"""
if not token:
return None
schema = identity_schema()
row = db.execute(
text(
f"""
SELECT s.user_id, s.expires_at, s.last_seen_at,
u.username, u.role, u.display_name, u.org_name, u.email,
u.{schema.access_state_column} AS access_state
FROM {schema.sessions_table} s
JOIN {schema.users_table} 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
access_state = to_access_state(row.access_state)
if not access_state.can_sign_in:
return None
if row.last_seen_at is None or (now - row.last_seen_at) >= _SLIDING_REFRESH_INTERVAL:
try:
db.execute(
text(
f"""
UPDATE {schema.sessions_table}
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,
# Всегда AccessState.ACTIVE — не-active сюда не доходит (см. выше).
# Ключ оставлен вместо прежнего `is_active`, чтобы состояние доступа во
# ВСЁМ коде называлось и выражалось одинаково.
"access_state": access_state,
}
def get_user_by_username(db: Session, username: str) -> dict[str, Any] | None:
"""Возвращает строку реестра по username, или None если не найден.
Используется login-флоу (`app.api.v1.auth.login`) для password-проверки.
Отдаёт `password_hash` как есть (может быть NULL — переходный период,
см. migration 192 docstring) — вызывающая сторона решает, что с ним делать.
`access_state` — уже `AccessState` (не сырое значение колонки): решение
«пускать / не пускать / показать экран пробного периода» принимает login,
и принимать его он обязан по ОДНОМУ понятию, а не по boolean в одном режиме
и строке в другом. Отсутствие юзера состоянием НЕ выражается (None остаётся
None) — иначе login потерял бы разницу между «нет такого логина» и
«заблокирован», а она нужна ему для выбора события аудита.
"""
schema = identity_schema()
row = db.execute(
text(
f"""
SELECT id, username, password_hash, role,
{schema.access_state_column} AS access_state,
display_name, org_name, email
FROM {schema.users_table}
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,
"access_state": to_access_state(row.access_state),
"display_name": row.display_name,
"org_name": row.org_name,
"email": row.email,
}
def revoke_session(db: Session, token: str) -> None:
"""Удаляет одну сессию по токену (logout). No-op если токен не найден."""
schema = identity_schema()
db.execute(text(f"DELETE FROM {schema.sessions_table} WHERE token = :token"), {"token": token})
db.commit()
def revoke_user_sessions(db: Session, user_id: int) -> None:
"""Удаляет ВСЕ сессии юзера — смена пароля и блокировка обязаны рвать
активные сессии немедленно (см. `app.api.v1.team.update_employee`)."""
schema = identity_schema()
db.execute(
text(f"DELETE FROM {schema.sessions_table} WHERE user_id = :user_id"),
{"user_id": user_id},
)
db.commit()
# ---------------------------------------------------------------------------
# DB-role → RBAC scope (paths/deny) — #2552 dual-mode.
# ---------------------------------------------------------------------------
#
# Роли реестра ('admin'|'manager'|'employee' — CHECK-констрейнт: tradein м.192 для
# tradein_users.role, auth м.004 для auth.users.role; наборы значений совпадают
# намеренно, чтобы код «Меры» переехал на общий реестр без правок в проверках роли)
# НЕ являются ключами 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, ([], ["/**"]))