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
Дефолт 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).
256 lines
16 KiB
Python
256 lines
16 KiB
Python
"""Резолв сессионной куки общего реестра (БД `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)
|