gendesign/backend/app/services/auth_session.py
bot-backend 7be07efe70
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m59s
CI / backend-tests (pull_request) Successful in 15m13s
feat(sitefinder): «Птица» принимает сессию общего реестра [PR-2c/6]
Дефолт AUTH_MODE=legacy — сегодняшнее поведение байт-в-байт: соединение с БД
auth не открывается, кука не читается, отсутствие настроек не роняет старт.
Popup Caddy стоит и снимается последним PR эпика — инвариант «гейт уходит
последним» не нарушен.

У Site Finder не было авторизации вообще: rbac_guard доверял заголовку
X-Authenticated-User от Caddy. Теперь он умеет резолвить сессионную куку
общего реестра. ВЫДАВАТЬ сессии «Птица» не будет — логин один, у «Меры», а
кука host-only на gendsgn.ru с path=/ и так долетает до обоих продуктов.
Меньше кода и меньше мест, где можно ошибиться.

Режим трёхзначный, а не булев: legacy | dual | db_only. Это прямое следствие
ревью. При булевом флаге фолбэк «сессия не нашлась → верим заголовку» после
снятия popup превращался бы в полный обход аутентификации, и ничто в коде не
заставило бы про него вспомнить. В db_only легаси-ветка недостижима: 401.

Резолв уехал в threadpool. Три независимых ревьюера нашли одно и то же:
sync-запрос к БД в async-guard блокирует event loop на каждом non-public
запросе — ровно инцидент #1202, который в этом же файле уже лечили. Кука
разбирается на loop'е, в поток уезжает только строка токена; запрос без куки
не платит ни за поток, ни за коннект.

Срок годности сессии считают часы БД, а не приложения. Раньше проверка шла в
Python, а sliding-refresh переписывал строку через now() базы — при
расхождении часов истёкшая сессия не просто проходила, а продлевалась заново,
то есть воскресала навсегда. Теперь `expires_at > now()` в самом SELECT;
питоновская проверка оставлена вторым поясом.

Fail-fast на старте проверяет не синтаксис DSN, а живое соединение: SELECT 1.
Иначе неверный пароль или хост выглядели бы как «ни у кого нет сессии» —
сутками, потому что ошибку ловил бы except в guard'е.

Ещё из ревью: connect_timeout и statement_timeout по 3с (недоступный хост
вешал коннект на минуты); throttling логов сбоя реестра (иначе шторм в
GlitchTip на каждый запрос); тела SQL закреплены ассертами формы — мутация
любого фрагмента теперь красит тесты, до этого не красила ничего.

Дефолт хоста БД — postgres, и это зеркально «Мере». У неё gendesign-postgres,
потому что внутри её стека `postgres` — чужой контейнер; здесь стек главный, и
`postgres` из корневого compose и есть нужный сервер. Алиас gendesign-postgres
дефолтом был бы багом: контейнер beat состоит только в сети default и это имя
из него не разрезолвится.

Сверка реестра с ролевой картой сделана на живом проде: все 13 юзеров
auth.users присутствуют в auth/roles.yaml, ни один не получит 403 на всё.
Четыре QA-фикстуры (admintest, pilottest, analysttest, expiredtest) есть в
yaml, но не в реестре — после снятия popup войти ими через браузер будет
нельзя, только внутрисетевым заголовком.

Осознанный долг, вписан ⚠️-блоком перед guard'ом: paths/deny из roles.yaml
бэкендом не применяются (их энфорсит фронтовый RouteGuard), guard проверяет
только известность username и admin-пути. Это предсуществующее поведение;
менять его здесь значило бы изменить и легаси-ветку, то есть нарушить
«дефолт = сегодня».

Тесты: 4594 passed, 0 failed. Главный — подделка: валидная кука плюс
присланный клиентом X-Authenticated-User другого пользователя, выигрывает
кука, и роут, читающий заголовок напрямую, видит владельца куки. У «Птицы»
таких прямых читателей одиннадцать, поэтому перезапись ASGI-scope обязана быть
полной, а не skip-if-present (CRITICAL #2552).
2026-08-02 17:00:54 +03:00

256 lines
16 KiB
Python
Raw Permalink 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.

"""Резолв сессионной куки общего реестра (БД `auth`) — сторона «Птицы».
Эпик «единый вход»: вместо браузерного popup'а Caddy basic_auth у продукта одна
нейтральная форма входа. Живёт она у «Меры» (`/trade-in/login`): та проверяет
пароль, пишет строку в `auth.sessions` и ставит куку host-only на gendsgn.ru с
`path="/"` — поэтому браузер шлёт её и на `/site-finder/**` тоже.
«Птица» эту куку ТОЛЬКО ЧИТАЕТ. Здесь нет и не должно появиться `create_session` /
`revoke_session`: выдача и отзыв — исключительная ответственность единственной
формы входа, второй эмитент сессий означал бы два места, где решается «кого
пускать», и расходящиеся правила блокировки.
Что модуль отдаёт вызывающему: `resolve_session_token(token)` → `SessionUser`
(username + состояние доступа) либо None. Что делать с username дальше — дело
guard'а: авторизация «Птицы» (какие пути кому видны) по-прежнему живёт в
`auth/roles.yaml` (`app.core.auth.get_role`), продуктовые роли реестра
(`auth.users.role` — admin/manager/employee, миграция data/sql/auth/004) сюда
намеренно НЕ протаскиваются: это другая ролевая модель, и её отображение на
roles.yaml — отдельное решение стадии 2, а не побочный эффект резолва сессии.
Токены опаковые (`secrets.token_urlsafe` на стороне «Меры») — не JWT, не подписаны:
валидность проверяется исключительно наличием строки в БД + `expires_at` +
состоянием доступа юзера. Никакого разделяемого секрета между стеками для этого
не нужно — только доступ к одной БД.
Имена таблиц (`users`, `sessions`) и колонок — литералы из data/sql/auth/001 и 004;
снаружи в SQL-строку не попадает ничего, значения идут bind-параметрами.
Зеркало по подходу: tradein-mvp/backend/app/services/auth_session.py («Мера»). Там
модуль дополнительно умеет две схемы (переходный `identity_store`) и выдачу сессий —
здесь этого нет за ненадобностью.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from enum import StrEnum
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core import auth_db
from app.core.config import settings
logger = logging.getLogger(__name__)
# Sliding-window refresh: last_seen_at/expires_at продлеваются НЕ чаще раза в 5
# минут — иначе каждый API-запрос авторизованного юзера бил бы в БД лишним UPDATE
# (guard резолвит сессию на КАЖДЫЙ non-public запрос). Значение и механика — те же,
# что у «Меры» (tradein-mvp/.../auth_session.py:51): сессия общая, и продлевать её
# два продукта обязаны одинаково.
_SLIDING_REFRESH_INTERVAL = timedelta(minutes=5)
class AccessState(StrEnum):
"""Состояние доступа аккаунта — значения дословно из `auth.users.access_state`.
CHECK-констрейнт `users_access_state_ck`, миграция data/sql/auth/004; семантика
оттуда же (решение владельца от 2026-07-31):
active — доступ есть;
trial_expired — пароль верный, но пробный период истёк;
disabled — доступ закрыт владельцем.
Для «Птицы» все три состояния делятся надвое (`can_sign_in`): отдельный экран
«пробный доступ закончился» — сюжет формы входа, то есть «Меры»; сюда приходит
уже вошедший человек, и всё, что не `active`, для него значит одно — сессии нет.
"""
ACTIVE = "active"
TRIAL_EXPIRED = "trial_expired"
DISABLED = "disabled"
@property
def can_sign_in(self) -> bool:
"""True только для `active` — единственная проверка «пускать ли».
Вынесена в свойство, чтобы вызывающий не писал `state == "active"`: добавится
четвёртое состояние — оно по умолчанию окажется «не пускать», а не «пускать,
потому что не disabled».
"""
return self is AccessState.ACTIVE
def to_access_state(value: object) -> AccessState:
"""Приводит значение колонки `users.access_state` к `AccessState`.
Fail-closed: неизвестная строка, NULL и любой неожиданный тип → `disabled` +
WARNING. Обратный выбор (пускать всё, что не `disabled`) означал бы, что новое
состояние, добавленное миграцией раньше кода, молча раздаёт доступ — а миграции
БД `auth` применяются деплоем «Птицы» (.forgejo/workflows/deploy.yml), то есть
опередить код они могут запросто.
"""
if isinstance(value, str):
try:
return AccessState(value)
except ValueError:
logger.warning(
"auth_session: неизвестное состояние доступа %r → трактую как disabled", value
)
return AccessState.DISABLED
logger.warning(
"auth_session: состояние доступа %r неожиданного типа %s → трактую как disabled",
value,
type(value).__name__,
)
return AccessState.DISABLED
@dataclass(frozen=True, slots=True)
class SessionUser:
"""Кто стоит за валидной сессионной кукой.
Attributes:
username: логин из реестра. Именно он, а не значение куки, дальше едет в
RBAC «Птицы» (`app.core.auth.get_role`).
access_state: всегда `AccessState.ACTIVE` — не-active сюда не доходит
(см. `get_session_user`). Поле оставлено явным, чтобы состояние доступа
во всём коде называлось и выражалось одинаково, а не превращалось в
неявное «раз объект вернулся, значит active».
"""
username: str
access_state: AccessState
def get_session_user(db: Session, token: str) -> SessionUser | None:
"""Резолвит сессионный токен в пользователя, или None если сессия невалидна.
Невалидна = не найдена / истекла / состояние доступа юзера не `active`.
Состояние доступа: пропускается ТОЛЬКО `AccessState.ACTIVE`. Любое другое
(`disabled`, `trial_expired`, а также нераспознанное — `to_access_state`
fail-closed'ит его в `disabled`) делает уже выданную сессию недействительной
НЕМЕДЛЕННО, не дожидаясь `expires_at`. Иначе заблокированный человек продолжал
бы работать до истечения TTL (до 30 дней), а sliding-refresh продлевал бы ему
сессию бесконечно — то есть блокировка в реестре не блокировала бы ничего.
Sliding refresh: если с последнего `last_seen_at` прошло >= 5 минут — продлевает
`last_seen_at`/`expires_at` ОДНИМ UPDATE (ровно как «Мера»: тот же интервал, тот
же одиночный UPDATE обеих колонок, тот же best-effort). Продлевать обе колонки
обязательно: обновляй «Птица» только `last_seen_at`, человек, работающий весь
день в ней одной, был бы разлогинен по `expires_at` несмотря на активность.
Сбой refresh (напр. read-only реплика) логируется и НЕ мешает вернуть валидного
юзера — это best-effort продление, а не часть решения «валидна ли сессия».
Принимает уже открытую сессию БД `auth` (не открывает сам) — так модуль остаётся
тривиально unit-тестируемым. Обычный вызывающий берёт `resolve_session_token`.
⚠️ `db` ОБЯЗАНА быть сессией БД `auth` (`app.core.auth_db.auth_session()`), а не
`app.core.db.get_db`: в продуктовой БД gendesign таблиц `users`/`sessions` нет.
Исключения БД наружу НЕ глушатся (кроме best-effort refresh): сбой реестра —
часть auth-решения, и вызывающий обязан его увидеть, чтобы закрыться, а не
трактовать как «сессии нет».
"""
if not token:
return None
row = db.execute(
text(
"""
SELECT s.expires_at, s.last_seen_at, u.username, u.access_state
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token = :token
AND s.expires_at > now()
"""
),
{"token": token},
).fetchone()
if row is None:
return None
now = datetime.now(UTC)
# Второй пояс к `AND s.expires_at > now()` в SELECT'е выше. Первый пояс — часами
# БД, и это принципиально: строку продлевает UPDATE ниже, где `expires_at =
# now() + interval` считает СЕРВЕР. Реши мы срок годности только часами процесса
# (`datetime.now(UTC)`), отставание этих часов давало бы не «сессия проживёт на
# дельту дольше», а НЕОБРАТИМОЕ воскрешение: строку, которую БД уже считает
# мёртвой, Python пропустил бы, тут же сработал бы sliding-refresh и отодвинул
# expires_at на полный TTL от серверного now(). Секунда расхождения → +30 дней.
# Обе стороны сравнения обязаны брать время из одного источника.
#
# Проверку на None оставляем первой: `expires_at` объявлен NOT NULL
# (data/sql/auth/001), но если колонку когда-нибудь ослабят, это дешевле
# разбирательства, почему сравнение с None упало TypeError'ом в auth-пути.
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(
"""
UPDATE 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:
# Без username в сообщении: строка лога — не место для связки
# «кто именно» + «в какой момент», а разбор всё равно идёт по времени.
logger.warning("auth_session: sliding refresh failed", exc_info=True)
try:
db.rollback()
except Exception:
# Причина сбоя UPDATE'а может быть оборванным соединением — тогда и
# rollback бросит. Без этого except «best-effort продление» переставало
# бы быть best-effort: валидный юзер, чью сессию не удалось продлить,
# получал бы не доступ, а исключение наружу (и в guard'е — деградацию
# на легаси-заголовок, а в db_only — отказ).
logger.warning("auth_session: rollback after failed refresh failed", exc_info=True)
return SessionUser(username=row.username, access_state=access_state)
def resolve_session_token(token: str | None) -> SessionUser | None:
"""Резолвит токен сессионной куки, сам открывая соединение с БД `auth`.
Точка входа для `rbac_guard` (`app/main.py`), который зовёт её в threadpool —
внутри синхронный psycopg-I/O, а guard живёт на event loop'е. Возвращает None,
если сессии нет или она недействительна.
Режим `legacy` (`AUTH_MODE=legacy`, ДЕФОЛТ) → None СРАЗУ, без единого
обращения к БД: инвариант «выключенный флаг = ни одного коннекта к реестру»
держится этим модулем, а не соглашением с вызывающим. Тихий None здесь безопасен,
потому что направлен в сторону fail-closed — он означает ровно «session-auth не
используется», то есть сегодняшнее поведение (Caddy basic_auth + trusted-header),
и никому ничего не открывает.
Исключения НЕ глушатся — ни `AuthDatabaseNotConfiguredError` (флаг включён, DSN
пуст/битый), ни ошибки соединения. Решение «что делать со сломанным реестром»
принимает guard, и оно неочевидно: молча откатиться на trusted-header значит
раздавать права из roles.yaml в обход реестра, включая заблокированные аккаунты.
Прятать такое внутри резолвера нельзя.
Raises:
AuthDatabaseNotConfiguredError: флаг включён, а DSN БД `auth` пуст или не
разобрался (см. `app.core.auth_db`).
"""
if not settings.auth_session_enabled:
return None
if not token:
return None
with auth_db.auth_session() as db:
return get_session_user(db, token)