All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 / backend-tests (pull_request) Successful in 1m19s
Foundation для эпика #2549: session-cookie auth поверх legacy Caddy trusted-header. app.services.auth_session — CRUD для tradein_sessions (create/get/revoke) + get_user_by_username для password-логина; opaque secrets.token_urlsafe токены, sliding last_seen_at/expires_at refresh (не чаще раза в 5 минут). POST /api/v1/auth/login проверяет password_hash (bcrypt) через app.core.password, ставит httponly+secure cookie, пишет login_success/login_failed в user_events; per-username+IP rate-limit (SlidingWindowLimiter) отдельно от общего RateLimitMiddleware. POST /logout ревокает сессию и чистит cookie. Оба пути exempt из rbac_guard's auth-required gate (иначе логин сам себя не пропустил бы). rbac_guard теперь dual-mode: session-cookie резолвится первым (DB-роль employee/manager/admin -> paths как у pilot/+team/admin), fallback на legacy X-Authenticated-User + roles.yaml БЕЗ ИЗМЕНЕНИЙ когда auth_mode == "dual"; auth_mode == "db_only" отключает legacy header полностью. Резолвленный сессией username инжектится в ASGI scope headers (до call_next) — RequestAuditMiddleware и downstream route-хендлеры видят его прозрачно; RateLimitMiddleware (внешний относительно rbac_guard) для session-запросов лимитирует по IP, не по username — документированный trade-off, не регрессия. GET /me — session-first: валидная cookie отдаёт scope из tradein_users без похода в roles.yaml; без cookie — прежний legacy путь. session_secret остаётся опциональным (opaque-токены не требуют подписи) — пустое значение только logger.warning на старте, не startup-fail. Полный набор тестов (tests/test_rbac.py, test_internal_auth_secret.py, test_account_quota.py) проходит без правок — regression-safe.
87 lines
3.5 KiB
Python
87 lines
3.5 KiB
Python
"""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 из tradein_users (role/display_name/org/email) БЕЗ похода в
|
||
roles.yaml. Без cookie (или невалидная/истёкшая) — legacy X-Authenticated-User
|
||
путь, БЕЗ ИЗМЕНЕНИЙ (regression недопустим — существующие тесты держат его
|
||
бит-в-бит).
|
||
"""
|
||
|
||
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.core.db import get_db
|
||
from app.services.auth_session import get_db_role_scope, get_session_user
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
@router.get("/me")
|
||
async def me(
|
||
request: Request,
|
||
db: Annotated[Session, Depends(get_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) — `"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
|