gendesign/tradein-mvp/backend/app/api/v1/me.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

93 lines
4 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.

"""GET /me — отдаёт текущего пользователя и его RBAC-scope.
MIRROR of main backend's app/api/v1/me.py — kept in sync manually.
Mounted at /api/v1/me; через Caddy `uri strip_prefix /trade-in` это становится
`/trade-in/api/v1/me` снаружи.
Caddy basic_auth пропускает `X-Authenticated-User: <username>` через
`header_up` в каждом reverse_proxy. Frontend дёргает /me чтобы понять
кому что показывать.
#2552: session-first. Валидная DB-session cookie (см. app.services.auth_session)
отдаёт scope из реестра людей (role/display_name/org/email) БЕЗ похода в
roles.yaml. Без cookie (или невалидная/истёкшая) — legacy X-Authenticated-User
путь, БЕЗ ИЗМЕНЕНИЙ (regression недопустим — существующие тесты держат его
бит-в-бит).
Сессия БД берётся у `identity_store.get_identity_db` (реестр), а не у
`app.core.db.get_db` (продуктовая БД): при `IDENTITY_STORE=auth` люди и сессии
живут в другой БД. В дефолтном режиме это ТОТ ЖЕ объект `Session`, что отдал бы
`get_db`, — поведение прода не меняется.
"""
from __future__ import annotations
import logging
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from sqlalchemy.orm import Session
from app.core.auth import UserScope, get_user_scope
from app.core.config import settings
from app.services.auth_session import get_db_role_scope, get_session_user
from app.services.identity_store import get_identity_db
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/me")
async def me(
request: Request,
db: Annotated[Session, Depends(get_identity_db)],
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> UserScope | dict[str, Any]:
"""Return the current user's RBAC scope (role + allowed/deny paths).
Return type is a union (не только `UserScope`) — `UserScope.role` — это
`Literal["admin","pilot","analyst","expired"]` (legacy roles.yaml names),
а DB-роли (реестр: tradein_users.role / auth.users.role) —
`"admin"/"manager"/"employee"`. FastAPI
строит response-схему из return-аннотации; жёсткий `UserScope` завернул бы
"employee"/"manager" в ResponseValidationError. Итоговая JSON-форма
ОДИНАКОВАЯ (те же 8 ключей) для обеих веток.
"""
token = request.cookies.get(settings.session_cookie_name)
if token:
try:
session_user = get_session_user(db, token)
except Exception:
logger.exception("me: session lookup failed")
session_user = None
if session_user is not None:
role = session_user["role"]
allowed_paths, deny_paths = get_db_role_scope(role)
return {
"username": session_user["username"],
"role": role,
"allowed_paths": allowed_paths,
"deny_paths": deny_paths,
"brand": None,
"display_name": session_user["display_name"],
"org": session_user["org_name"],
"email": session_user["email"],
}
if not x_authenticated_user:
raise HTTPException(
status_code=401,
detail="no authenticated user (Caddy basic_auth required)",
)
try:
return get_user_scope(x_authenticated_user)
except KeyError:
logger.warning(
"user %r authenticated via Caddy but missing from roles.yaml",
x_authenticated_user,
)
raise HTTPException(
status_code=403,
detail="user not in roles config",
) from None