"""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, его же с трейлинг-слэшем и подпути через '/', но НЕ соседей по # префиксу (см. 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 понимает ТОЛЬКО три формы: # '/**' | '/**' | точный путь (строгое равенство). # app.core.auth._glob_to_regex (roles.yaml) понимает сверх этого ещё одиночную # '*' ('/foo/*' = один сегмент). Паттерн с одиночной '*', скопированный сюда из # roles.yaml, станет ЛИТЕРАЛЬНОЙ строкой и МОЛЧА перестанет что-либо запрещать — # без ошибки на импорте и без падения тестов, если на него нет прямого теста. # Т.е. в DB_ROLE_PATHS допустимы только '/**', '/**' и точный путь; # одиночная '*' здесь = 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, ([], ["/**"]))