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
Дефолт не меняет ничего: 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, тот же красный).
334 lines
18 KiB
Python
334 lines
18 KiB
Python
"""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, ([], ["/**"]))
|