Compare commits

...
Sign in to create a new pull request.

8 commits

Author SHA1 Message Date
d3e0aa296c Merge pull request 'feat(tradein/auth): auth-core — login/logout, sessions, dual-mode rbac (#2552)' (#2561) from feat/tradein-auth-core into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m23s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 1m27s
2026-07-30 17:19:44 +00:00
bot-backend
4ecc3d689c fix(tradein/auth): предотвратить подмену X-Authenticated-User при session-auth (#2552)
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / 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 1m25s
CRITICAL: _propagate_authenticated_user делала skip-if-present вместо
перезаписи — клиент-контролируемый X-Authenticated-User (Caddy шлёт его
на КАЖДЫЙ прод-запрос) выигрывал у резолвленной сессии для всего
downstream-трафика, читающего заголовок напрямую (_assert_estimate_access*,
account_quota, /trade-in/history, support.py) — в обоих auth_mode
(dual и db_only). Теперь заголовок безусловно перезаписывается сессионным
username (ASGI header-имена всегда lowercase bytes).

Medium: .encode("latin-1") без errors="replace" крашил бы 500-кой каждый
запрос кириллического username. Login timing-oracle — verify_password
короткозамыкалась на unknown-username/NULL-hash (~1мс vs ~100-300мс bcrypt)
→ теперь всегда сверяется против dummy-хеша при отсутствующем юзере/хеше.
Login rate-limit key length-prefixed — username с ':' (или IPv6 IP) больше
не может схлопнуть чужой бюджет.

Новые тесты подтверждают регрессию: прогнаны на старом коде (до фикса)
через временный откат rbac.py — все три (spoof dual-mode, spoof db_only,
кириллица) падали с 'victim' == 'alice' / UnicodeEncodeError; после
фикса — зелёные. test_rbac.py/test_internal_auth_secret.py без изменений.
2026-07-30 20:14:26 +03:00
bot-backend
0835266516 feat(tradein/auth): auth-core — login/logout, sessions, dual-mode rbac (#2552)
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.
2026-07-30 10:48:28 +03:00
876538c57f Merge pull request 'feat(tradein/auth): password hashing + session config foundation (#2550)' (#2560) from feat/tradein-auth-password-foundation into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m6s
Deploy Trade-In / build-backend (push) Successful in 1m52s
Deploy Trade-In / deploy (push) Successful in 1m15s
2026-07-30 07:04:56 +00:00
e703d64a7f Merge pull request 'feat(tradein/db): tradein_users + tradein_sessions schema (#2551)' (#2559) from feat/tradein-auth-users-schema into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m2s
Deploy Trade-In / build-backend (push) Successful in 50s
Deploy Trade-In / deploy (push) Successful in 1m19s
2026-07-30 06:59:23 +00:00
bot-backend
3f1b86f16a feat(tradein/auth): password hashing + session config foundation (#2550)
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 11s
CI / 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) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m14s
2026-07-30 09:40:51 +03:00
bot-backend
0c5246913a feat(tradein/db): tradein_users + tradein_sessions schema (#2551)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
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 1m10s
2026-07-30 09:40:05 +03:00
600717f44c Merge pull request 'test(tradein/rbac): user2 → expired в test_get_role_known_users (после #2548)' (#2553) from fix/tradein-rbac-test-user2-expired into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m0s
Deploy Trade-In / build-backend (push) Successful in 32s
Deploy Trade-In / deploy (push) Successful in 1m20s
2026-07-30 06:23:34 +00:00
13 changed files with 1777 additions and 57 deletions

View file

@ -0,0 +1,156 @@
"""POST /api/v1/auth/login + /logout — DB-backed session auth (#2552, эпик #2549).
Переходный механизм, параллельный legacy Caddy trusted-header auth (roles.yaml).
См. `app.core.rbac.rbac_guard` (dual-mode resolver) и `app.services.auth_session`
(session CRUD). Mounted at `/api/v1/auth`; через Caddy `uri strip_prefix /trade-in`
это `/trade-in/api/v1/auth/*` снаружи.
Security:
- Неверные creds (неизвестный username / неактивен / password_hash NULL /
неверный пароль) ОДИНАКОВЫЙ 401 с generic сообщением не раскрываем,
существует ли username (user-enumeration защита).
- #2552 post-review Medium 2: `verify_password` ВСЕГДА вызывается ровно
один раз для несуществующего username / NULL password_hash сверяем
против статичного dummy-хеша (`_DUMMY_PASSWORD_HASH`, сгенерирован один
раз на импорте модуля), результат игнорируется. Без этого короткое
замыкание (`user is None сразу 401`) давало наблюдаемую разницу во
времени ответа (~1мс без bcrypt vs ~100-300мс с ним) классический
timing-oracle для user-enumeration, даже при одинаковом detail-сообщении.
- Rate-limit по (username, IP) ЖЁСТЧЕ общего `RateLimitMiddleware`
(`/api/*`), т.к. login типичная brute-force поверхность. Использует
`SlidingWindowLimiter` (тот же примитив, что и общий rate-limit). Ключ
length-prefixed (`len(username):username:ip`) без этого произвольный
username с `:` внутри мог бы схлопнуть бюджет с другой (username, ip)
парой (IPv6-адреса тоже содержат `:`, так что просто эскейпить разделитель
в username недостаточно паразитная граница возможна с обеих сторон).
- Raw-пароль НИКОГДА не логируется и не попадает в user_events payload
только username/ip/user_agent/path/method (см. schedule_event ниже).
"""
from __future__ import annotations
import logging
import secrets
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import get_db
from app.core.password import hash_password, verify_password
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
from app.services.auth_session import create_session, get_user_by_username, revoke_session
from app.services.user_events import schedule_event
logger = logging.getLogger(__name__)
router = APIRouter()
# Отдельный, более узкий бюджет чем общий per-user/per-IP `/api/*` лимит
# (см. app.core.ratelimit.SlidingWindowLimiter docstring — designed именно для
# такого случая). Ключ = username+IP: не даёт распределённому brute-force по
# ОДНОМУ аккаунту с разных IP уйти от лимита целиком (per-IP было бы недостаточно),
# и не блокирует ВЕСЬ IP из-за перебора чужих логинов одним же клиентом.
_LOGIN_LIMITER = SlidingWindowLimiter(
limit=settings.login_rate_limit,
window_s=settings.login_rate_limit_window_s,
)
# Timing-oracle защита (см. module docstring): bcrypt-хеш случайного пароля,
# сгенерированный ОДИН РАЗ на импорте модуля — используется вместо
# password_hash, когда юзер не найден/деактивирован/без пароля, чтобы
# `verify_password` (доминирующая по времени операция, ~100-300мс) всегда
# отрабатывала полный bcrypt-компар, независимо от того, существует ли аккаунт.
_DUMMY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
_INVALID_CREDENTIALS_DETAIL = "неверный логин или пароль"
class LoginRequest(BaseModel):
username: str
password: str
class LoginResponse(BaseModel):
ok: bool = True
@router.post("/login", response_model=LoginResponse)
async def login(
body: LoginRequest,
request: Request,
response: Response,
db: Annotated[Session, Depends(get_db)],
) -> LoginResponse:
ip = _client_ip(request)
user_agent = request.headers.get("user-agent")
rate_key = f"{len(body.username)}:{body.username}:{ip}"
retry_after = _LOGIN_LIMITER.check(rate_key)
if retry_after is not None:
raise HTTPException(
status_code=429,
detail="слишком много попыток входа, попробуйте позже",
headers={"Retry-After": str(int(retry_after) + 1)},
)
user = get_user_by_username(db, body.username)
hash_to_check = (
user["password_hash"]
if user is not None and user["password_hash"] is not None
else _DUMMY_PASSWORD_HASH
)
# ВСЕГДА вызывается — dummy-хеш при отсутствующем юзере/NULL password_hash
# держит время ответа одинаковым независимо от существования аккаунта.
password_ok = verify_password(body.password, hash_to_check)
credentials_ok = user is not None and user["is_active"] and password_ok
if not credentials_ok:
schedule_event(
event_type="login_failed",
username=body.username,
ip=ip,
user_agent=user_agent,
path="/api/v1/auth/login",
method="POST",
)
raise HTTPException(status_code=401, detail=_INVALID_CREDENTIALS_DETAIL)
assert user is not None # narrowed by credentials_ok above
token = create_session(db, user_id=user["user_id"], ip=ip, user_agent=user_agent)
response.set_cookie(
key=settings.session_cookie_name,
value=token,
max_age=settings.session_ttl_hours * 3600,
httponly=True,
secure=True,
samesite="lax",
path="/",
)
schedule_event(
event_type="login_success",
username=user["username"],
ip=ip,
user_agent=user_agent,
path="/api/v1/auth/login",
method="POST",
)
return LoginResponse(ok=True)
@router.post("/logout")
async def logout(
request: Request,
response: Response,
db: Annotated[Session, Depends(get_db)],
) -> dict[str, bool]:
token = request.cookies.get(settings.session_cookie_name)
if token:
revoke_session(db, token)
response.delete_cookie(key=settings.session_cookie_name, path="/")
return {"ok": True}

View file

@ -7,16 +7,26 @@ Mounted at /api/v1/me; через Caddy `uri strip_prefix /trade-in` это ст
Caddy basic_auth пропускает `X-Authenticated-User: <username>` через Caddy basic_auth пропускает `X-Authenticated-User: <username>` через
`header_up` в каждом reverse_proxy. Frontend дёргает /me чтобы понять `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 from __future__ import annotations
import logging import logging
from typing import Annotated from typing import Annotated, Any
from fastapi import APIRouter, Header, HTTPException 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.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__) logger = logging.getLogger(__name__)
@ -25,9 +35,40 @@ router = APIRouter()
@router.get("/me") @router.get("/me")
async def me( async def me(
request: Request,
db: Annotated[Session, Depends(get_db)],
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None, x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> UserScope: ) -> UserScope | dict[str, Any]:
"""Return the current user's RBAC scope (role + allowed/deny paths).""" """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: if not x_authenticated_user:
raise HTTPException( raise HTTPException(
status_code=401, status_code=401,

View file

@ -49,6 +49,28 @@ class Settings(BaseSettings):
default="", validation_alias="TRADEIN_INTERNAL_AUTH_SECRET" default="", validation_alias="TRADEIN_INTERNAL_AUTH_SECRET"
) )
# ── #2550: DB-auth foundation (bcrypt password hashing + session cookie) ────
# Подготовительные поля для #2549 (эпик). Enforcement непустого session_secret
# (fail-fast при пустом значении в prod) добавится в #2552 — здесь дефолт
# намеренно пустой, чтобы прод-контейнер не падал на старте до того как
# секрет проставлен в .env.runtime. ENV: SESSION_SECRET.
session_secret: str = Field(default="", validation_alias="SESSION_SECRET")
# Имя cookie для DB-based сессии (отдельно от Caddy basic_auth / trusted-header).
session_cookie_name: str = Field(
default="tradein_session", validation_alias="SESSION_COOKIE_NAME"
)
# TTL сессии в часах. Дефолт 720ч (30 дней).
session_ttl_hours: int = Field(default=720, validation_alias="SESSION_TTL_HOURS")
# "dual" — переходный режим (Caddy trusted-header ИЛИ DB-сессия оба валидны);
# "db_only" — только DB-сессия (Caddy basic_auth убран). Переключение — #2552+.
auth_mode: Literal["dual", "db_only"] = Field(default="dual", validation_alias="AUTH_MODE")
# Rate-limit на /login: не более login_rate_limit попыток за
# login_rate_limit_window_s секунд на ключ (обычно IP или username).
login_rate_limit: int = Field(default=5, validation_alias="LOGIN_RATE_LIMIT")
login_rate_limit_window_s: int = Field(
default=300, validation_alias="LOGIN_RATE_LIMIT_WINDOW_S"
)
# Geocoder. Env var name `YANDEX_GEOCODER_API_KEY` — consistent с scripts/ # Geocoder. Env var name `YANDEX_GEOCODER_API_KEY` — consistent с scripts/
# backfill_house_coords.py + audit_address_mismatch.py + main backend # backfill_house_coords.py + audit_address_mismatch.py + main backend
# OpenRouteService_API_KEY pattern. Renamed from YANDEX_GEOCODER_KEY (PR F). # OpenRouteService_API_KEY pattern. Renamed from YANDEX_GEOCODER_KEY (PR F).

View file

@ -0,0 +1,61 @@
"""Bcrypt password hashing для DB-auth (#2550 — foundation, эпик #2549).
bcrypt тихо обрезает пароли длиннее 72 байт (UTF-8) это silent-truncation
дыра (два разных пароля с общим 72-байтовым префиксом хешируются одинаково).
`hash_password` явно ловит это и падает с ValueError вместо тихого поведения.
`verify_password` на длинном пароле возвращает False (не raise) сравнение
паролей не должно ронять запрос авторизации.
"""
from __future__ import annotations
import logging
import bcrypt
logger = logging.getLogger(__name__)
_BCRYPT_MAX_BYTES = 72
_BCRYPT_ROUNDS = 12
def hash_password(plain: str) -> str:
"""Хеширует пароль через bcrypt (rounds=12).
Raises:
ValueError: пустой пароль или пароль длиннее 72 байт в UTF-8
(bcrypt тихо обрезает недопустимо, см. модульный docstring).
"""
if not plain:
raise ValueError("password must not be empty")
encoded = plain.encode("utf-8")
if len(encoded) > _BCRYPT_MAX_BYTES:
raise ValueError(
f"password too long: {len(encoded)} bytes (bcrypt max {_BCRYPT_MAX_BYTES})"
)
salt = bcrypt.gensalt(rounds=_BCRYPT_ROUNDS)
hashed = bcrypt.hashpw(encoded, salt)
return hashed.decode("utf-8")
def verify_password(plain: str, hashed: str) -> bool:
"""Сверяет пароль с bcrypt-хешем.
Пустой пароль или пароль длиннее 72 байт в UTF-8 False (не raise
verify это false/true проверка на этапе логина, а не валидация ввода).
"""
if not plain or not hashed:
return False
encoded = plain.encode("utf-8")
if len(encoded) > _BCRYPT_MAX_BYTES:
return False
try:
return bcrypt.checkpw(encoded, hashed.encode("utf-8"))
except (ValueError, TypeError) as e:
# Malformed hash (напр. не-bcrypt строка в БД) — не должно ронять login.
logger.warning("verify_password: malformed hash rejected: %s", e)
return False

View file

@ -7,12 +7,16 @@ manually". The copy drifted: it was missing the #2213
``X-Internal-Auth-Secret`` defense-in-depth check that the real guard has, ``X-Internal-Auth-Secret`` defense-in-depth check that the real guard has,
so a regression in that check would NOT have failed CI. so a regression in that check would NOT have failed CI.
This module holds the real guard with no DB/lifespan/scheduler side effects This module holds the real guard. Historically it had "no DB/lifespan/scheduler
(only ``app.core.auth`` + ``app.core.config``, both side-effect-free at side effects" beyond ``app.core.auth``/``app.core.config`` (both side-effect-free
import time beyond requiring ``DATABASE_URL`` in the environment for at import time). #2552 (dual-mode DB-session auth) adds a conditional per-request
``Settings()``). ``app/main.py`` and the test apps both import THIS module, DB round trip via ``app.core.db.SessionLocal`` но ТОЛЬКО когда запрос реально
so tests exercise the exact production code path instead of a copy that can несёт session-cookie (``request.cookies.get(settings.session_cookie_name)``);
silently fall out of sync. без cookie (весь существующий тестовый трафик, legacy Caddy trusted-header
запросы) ветка не выполняется ноль новых DB-побочных эффектов для старых
путей. ``app/main.py`` and the test apps both import THIS module, so tests
exercise the exact production code path instead of a copy that can silently
fall out of sync.
""" """
from __future__ import annotations from __future__ import annotations
@ -21,12 +25,15 @@ import logging
import re import re
import secrets import secrets
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Any
from fastapi import Request from fastapi import Request
from fastapi.responses import JSONResponse, Response from fastapi.responses import JSONResponse, Response
from app.core.auth import get_role, is_path_allowed from app.core.auth import get_role, is_path_allowed
from app.core.config import settings from app.core.config import settings
from app.core.db import SessionLocal
from app.services.auth_session import get_db_role_scope, get_session_user
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -40,7 +47,22 @@ logger = logging.getLogger(__name__)
# Public paths без auth (/health, /docs, /openapi.json) пропускаем — # Public paths без auth (/health, /docs, /openapi.json) пропускаем —
# X-Authenticated-User там не приходит из Caddy. # X-Authenticated-User там не приходит из Caddy.
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/") _ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"}) # #2552: /api/v1/auth/login + /logout — по определению вызываются ДО того, как
# клиент аутентифицирован (login) или могут вызываться с уже протухшей/отсутствующей
# сессией (logout — должен уметь чистить stale cookie без валидной auth). Свой
# rate-limit у /login отдельный (app.api.v1.auth._LOGIN_LIMITER), RateLimitMiddleware
# на /api/* всё равно применяется — это ослабляет ТОЛЬКО rbac_guard'овский
# auth-required gate, не остальные защиты.
_PUBLIC_PATHS = frozenset(
{
"/health",
"/docs",
"/redoc",
"/openapi.json",
"/api/v1/auth/login",
"/api/v1/auth/logout",
}
)
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед # #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед
# tradein-backend, а globs в roles.yaml — ВНЕШНИЕ (/trade-in/api/v1/**). Для # tradein-backend, а globs в roles.yaml — ВНЕШНИЕ (/trade-in/api/v1/**). Для
# scope-проверки восстанавливаем внешний путь. # scope-проверки восстанавливаем внешний путь.
@ -51,6 +73,77 @@ _EXTERNAL_PREFIX = "/trade-in"
_RBAC_BOOTSTRAP_EXEMPT = ("/api/v1/me", "/api/v1/brand") _RBAC_BOOTSTRAP_EXEMPT = ("/api/v1/me", "/api/v1/brand")
def _db_glob_match(pattern: str, path: str) -> bool:
"""Мини-матчер для фиксированного набора DB-role паттернов
(``app.services.auth_session.DB_ROLE_PATHS`` только формы ``/**`` и
``<prefix>/**``, не нужна полная semantics ``app.core.auth._glob_to_regex``
тот модуль private и MIRROR'ится вручную с основным бэкендом, лишний
импорт private-символа оттуда увеличивал бы drift-риск)."""
if pattern == "/**":
return True
if pattern.endswith("/**"):
prefix = pattern[: -len("/**")]
return path == prefix or path.startswith(prefix + "/")
return path == pattern
def _db_role_path_allowed(role: str, path: str) -> bool:
paths, deny = get_db_role_scope(role)
if any(_db_glob_match(p, path) for p in deny):
return False
return any(_db_glob_match(p, path) for p in paths)
def _propagate_authenticated_user(request: Request, username: str) -> None:
"""Инжектит ``X-Authenticated-User`` в ASGI scope — ПЕРЕЗАПИСЫВАЯ, а не
только добавляя при отсутствии, чтобы ``RateLimitMiddleware``/
``RequestAuditMiddleware`` (оба читают сырой заголовок напрямую,
#2213/#2550) и downstream route-хендлеры (читающие его через FastAPI
``Header()``) видели РЕЗОЛВЛЕННОГО ИЗ СЕССИИ юзера без правок в каждом
из этих мест по отдельности (минимально инвазивный способ).
#2552 post-review fix (CRITICAL): раньше это была skip-if-present
мутация (``if request.headers.get(...): return``) сессия резолвилась
ПЕРВОЙ (см. rbac_guard), но клиент-контролируемый ``X-Authenticated-User``
(который Caddy шлёт на КАЖДЫЙ прод-запрос) выигрывал у неё для ВСЕГО
downstream-трафика: атакующий с валидной cookie юзера ``alice`` мог
подделать заголовок ``X-Authenticated-User: victim`` и получить доступ к
данным victim в ~15 роутах, читающих заголовок напрямую
(``_assert_estimate_access*``, ``account_quota``, ``/trade-in/history``,
``support.py``) работало в ОБОИХ auth_mode (dual и db_only), т.к. эти
хендлеры не знают про rbac_guard'овский ``from_session`` флаг, только про
сырой заголовок. Session-identity ДОЛЖНА быть источником истины, если
сессия резолвлена полная перезапись, не skip.
Механизм: ``request.scope`` ОДИН и тот же dict-объект, прокинутый по
ссылке через весь ASGI call chain (Starlette не копирует scope между
слоями middleware). Мутация ``scope["headers"]`` ЗДЕСЬ видна:
- downstream call_next() цепочке (ExceptionMiddleware Router
endpoint) т.к. rbac_guard мутирует scope ДО вызова call_next();
- ``RequestAuditMiddleware`` он внешний относительно rbac_guard
(см. app/main.py: последний ``add_middleware`` оборачивает
предыдущие) и читает ``request.headers`` уже ПОСЛЕ ``call_next()``
отработал весь внутренний стек, включая эту мутацию.
ASGI header-имена всегда lowercase bytes (см. ASGI spec), поэтому
фильтр по ``b"x-authenticated-user"`` ловит заголовок независимо от
регистра, в котором его прислал клиент (Starlette уже нормализует).
Известное ограничение: ``RateLimitMiddleware`` тоже внешний относительно
rbac_guard, но читает заголовок ДО вызова call_next() (до того, как этот
guard успевает отработать) для ЭТОГО конкретного запроса сессионный
юзер лимитируется по IP, а не по username (per-user множитель не
применяется). Не регрессия (IP-лимит применялся бы и раньше до
добавления session-auth такие запросы вообще были 401), просто более
строгий бюджет специфично для session-cookie-запросов; при необходимости
точного per-user квотинга для DB-юзеров переносить резолв сессии выше
RateLimit в app/main.py отдельным issue.
"""
request.scope["headers"] = [
(k, v) for k, v in request.scope.get("headers", []) if k != b"x-authenticated-user"
] + [(b"x-authenticated-user", username.encode("latin-1", "replace"))]
async def rbac_guard( async def rbac_guard(
request: Request, request: Request,
call_next: Callable[[Request], Awaitable[Response]], call_next: Callable[[Request], Awaitable[Response]],
@ -59,6 +152,37 @@ async def rbac_guard(
if path in _PUBLIC_PATHS: if path in _PUBLIC_PATHS:
return await call_next(request) return await call_next(request)
username: str | None = None
role: str | None = None
from_session = False
# #2552: session-cookie резолвится ПЕРВЫМ. Если cookie нет вообще —
# request.cookies.get() возвращает None без единого похода в БД (ноль
# side-effects для всего существующего трафика без cookie).
token = request.cookies.get(settings.session_cookie_name)
if token:
session_user: dict[str, Any] | None = None
try:
with SessionLocal() as db:
session_user = get_session_user(db, token)
except Exception:
logger.exception("RBAC: session lookup failed for %s", path)
if session_user is not None:
username = session_user["username"]
role = session_user["role"]
from_session = True
_propagate_authenticated_user(request, username)
if not from_session:
# auth_mode == "db_only" — легаси trusted-header путь ПОЛНОСТЬЮ
# отключён, даже если валидный X-Authenticated-User присутствует.
if settings.auth_mode != "dual":
return JSONResponse(
status_code=401,
content={"detail": "valid session required"},
)
# ---- legacy trusted-header path — BIT-FOR-BIT как было до #2552 ----
username = request.headers.get("X-Authenticated-User") username = request.headers.get("X-Authenticated-User")
if not username: if not username:
return JSONResponse( return JSONResponse(
@ -94,6 +218,9 @@ async def rbac_guard(
content={"detail": "user not in roles config"}, content={"detail": "user not in roles config"},
) )
assert username is not None
assert role is not None
if _ADMIN_API_RE.match(path) and role != "admin": if _ADMIN_API_RE.match(path) and role != "admin":
logger.info("RBAC: blocked %s (role=%s) from %s", username, role, path) logger.info("RBAC: blocked %s (role=%s) from %s", username, role, path)
return JSONResponse( return JSONResponse(
@ -101,15 +228,14 @@ async def rbac_guard(
content={"detail": "admin only"}, content={"detail": "admin only"},
) )
# #R2-H3: энфорсим roles.yaml scope (paths/deny) для ВСЕХ non-admin путей, а не # #R2-H3: энфорсим scope (paths/deny) для ВСЕХ non-admin путей, а не
# только /admin/*. Иначе revoked (role=expired, paths:[] deny:/**) или узко- # только /admin/*. Bootstrap-пути (/me, /brand) исключены — иначе revoked/
# скоупленный аккаунт достаёт non-admin API (напр. POST /api/v1/search — # scope-narrowed юзер не смог бы получить свою роль вовсе.
# экспорт листингов), который roles.yaml ему запрещает. Bootstrap-пути (/me,
# /brand) исключены выше по списку. roles.yaml globs внешние → восстанавливаем
# внешний путь (Caddy срезал /trade-in). На сбой парса — fail-open + громкий
# лог: не лочим платящего pilot из-за конфиг-бага (admin-гейт выше остаётся).
if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT): if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT):
external_path = _EXTERNAL_PREFIX + path external_path = _EXTERNAL_PREFIX + path
if from_session:
allowed = _db_role_path_allowed(role, external_path)
else:
try: try:
allowed = is_path_allowed(role, external_path) allowed = is_path_allowed(role, external_path)
except Exception: except Exception:

View file

@ -23,6 +23,7 @@ from sentry_sdk.integrations.starlette import StarletteIntegration
from app.api.v1 import ( from app.api.v1 import (
admin, admin,
audit, audit,
auth,
brand, brand,
buildings, buildings,
geocode, geocode,
@ -106,6 +107,19 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
".env.runtime ОБОИХ стеков (Caddy главного стека + tradein-backend)" ".env.runtime ОБОИХ стеков (Caddy главного стека + tradein-backend)"
) )
# #2552: session_secret зарезервирован на будущее (напр. подписанные токены) —
# opaque session-токены (secrets.token_urlsafe, см. app.services.auth_session)
# НЕ требуют подписи, их валидность проверяется исключительно наличием строки
# в tradein_sessions + expires_at/is_active. Пустой session_secret НЕ должен
# ронять старт контейнера (не startup-fail) — только громкий WARNING, чтобы
# прод не остался без него незамеченно до момента, когда он реально понадобится.
if not settings.session_secret:
logger.warning(
"SESSION_SECRET пуст — не блокирует старт (opaque session-токены не "
"требуют подписи), но задай его в .env.runtime до появления фич, "
"которым подпись реально нужна"
)
# FDW bootstrap: create/refresh USER MAPPING for gendesign_remote postgres_fdw server. # FDW bootstrap: create/refresh USER MAPPING for gendesign_remote postgres_fdw server.
# Best-effort: failure does not abort startup, just logs. # Best-effort: failure does not abort startup, just logs.
try: try:
@ -158,6 +172,7 @@ def health() -> dict[str, str]:
return {"status": "ok", "environment": settings.environment} return {"status": "ok", "environment": settings.environment}
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"]) app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"]) app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"]) app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"])

View file

@ -0,0 +1,221 @@
"""Session-сервис для DB-backed auth (#2552, эпик #2549 — auth-core).
Схема: `tradein_users` + `tradein_sessions` (migration `192_tradein_users_auth.sql`).
Опаковые (`secrets.token_urlsafe`) токены-сессии не JWT, не подписаны: валидность
проверяется исключительно наличием + `expires_at`/`is_active` строкой в БД, поэтому
`SESSION_SECRET` НЕ обязателен для работы этого модуля (зарезервирован на будущее,
см. `app.core.config.Settings.session_secret` docstring).
Все функции здесь принимают уже открытую `db: Session` сами НЕ открывают
`SessionLocal()` (вызывающая сторона решает время жизни транзакции: `rbac_guard`
и `app.core.db.get_db()`-роуты открывают её по-разному). Это делает модуль
тривиально unit-тестируемым без патчинга `SessionLocal` тесты просто передают
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
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`).
"""
token = secrets.token_urlsafe(_TOKEN_BYTES)
db.execute(
text(
"""
INSERT INTO tradein_sessions (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 если сессия
невалидна (не найдена / истекла / юзер деактивирован).
Sliding refresh: если с последнего `last_seen_at` прошло >=5 минут
продлевает `expires_at`/`last_seen_at` ОДНИМ UPDATE. Сбой refresh
(напр. read-replica) логируется и НЕ мешает вернуть валидного юзера
это best-effort продление, а не часть решения "валидна ли сессия".
"""
if not token:
return None
row = db.execute(
text(
"""
SELECT s.user_id, s.expires_at, s.last_seen_at,
u.username, u.role, u.display_name, u.org_name, u.email, u.is_active
FROM tradein_sessions s
JOIN tradein_users 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
if not row.is_active:
return None
if row.last_seen_at is None or (now - row.last_seen_at) >= _SLIDING_REFRESH_INTERVAL:
try:
db.execute(
text(
"""
UPDATE tradein_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:
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,
"is_active": row.is_active,
}
def get_user_by_username(db: Session, username: str) -> dict[str, Any] | None:
"""Возвращает строку `tradein_users` по username, или None если не найден.
Используется login-флоу (`app.api.v1.auth.login`) для password-проверки.
Отдаёт `password_hash` как есть (может быть NULL переходный период,
см. migration 192 docstring) вызывающая сторона решает, что с ним делать.
"""
row = db.execute(
text(
"""
SELECT id, username, password_hash, role, is_active,
display_name, org_name, email
FROM tradein_users
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,
"is_active": row.is_active,
"display_name": row.display_name,
"org_name": row.org_name,
"email": row.email,
}
def revoke_session(db: Session, token: str) -> None:
"""Удаляет одну сессию по токену (logout). No-op если токен не найден."""
db.execute(text("DELETE FROM tradein_sessions WHERE token = :token"), {"token": token})
db.commit()
def revoke_user_sessions(db: Session, user_id: int) -> None:
"""Удаляет ВСЕ сессии юзера (напр. смена пароля / принудительный logout всех
устройств не используется этим PR напрямую, задел для будущих admin-действий)."""
db.execute(text("DELETE FROM tradein_sessions WHERE user_id = :user_id"), {"user_id": user_id})
db.commit()
# ---------------------------------------------------------------------------
# DB-role → RBAC scope (paths/deny) — #2552 dual-mode.
# ---------------------------------------------------------------------------
#
# tradein_users.role ('admin'|'manager'|'employee', CHECK-констрейнт migration 192)
# НЕ являются ключами auth/roles.yaml (тот файл — legacy Caddy trusted-header путь,
# который этот эпик намеренно не трогает). Маппинг ниже даёт DB-ролям тот же
# paths/deny-смысл, что и legacy-ролям, БЕЗ правки roles.yaml:
# employee -> те же права, что legacy pilot (/trade-in/** только).
# manager -> employee + задел /api/v1/team/** (роутер появится в #2554).
# admin -> полный доступ, как legacy admin.
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/**"],
),
"manager": (
["/trade-in/**", "/trade-in/api/v1/**", "/api/v1/team/**"],
["/admin/**", "/api/v1/admin/**", "/trade-in/api/v1/admin/**"],
),
"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, ([], ["/**"]))

View file

@ -0,0 +1,88 @@
-- Migration 192: tradein_users + tradein_sessions — DB-backed auth (issue #2551, эпик #2549)
--
-- WHY:
-- Trade-in auth сейчас держится на legacy Caddy basic-auth fallback (см. auth/roles.yaml,
-- упомянут в 191_account_quota_unlimited_flag.sql как "хардкод username в коде"). Эпик #2549
-- переводит auth на DB-backed модель: пользователи + сессии как данные, роли admin/manager/
-- employee с иерархией manager -> employee. Эта миграция — только схема (Foundation),
-- без seed-данных (seed — отдельная задача #2557) и без Python-кода (backend wiring — отдельно).
--
-- WHAT:
-- 1. tradein_users — identity + role + org-иерархия.
-- - password_hash NULL допустим: переходный период, когда логин ещё идёт через
-- legacy Caddy fallback, а не через password verify в приложении.
-- - role CHECK ('admin','manager','employee') — три уровня доступа.
-- - manager_id — self-FK, ON DELETE SET NULL (увольнение/удаление manager'а не должно
-- каскадно сносить его employees, они просто остаются без привязки).
-- - CHECK role_manager_hierarchy: admin/manager обязаны иметь manager_id IS NULL
-- (это top-level роли, у них нет "начальника" в этой модели); employee — manager_id
-- любой, включая NULL (свободный слот employee без организации допустим).
-- 2. tradein_sessions — токен-based сессии, привязаны к user_id, ON DELETE CASCADE
-- (удалили пользователя — его сессии теряют смысл, каскадная очистка корректна).
-- last_seen_at отдельно от created_at — для idle-timeout / активности сессии.
-- 3. Индексы: expires_at (уборка протухших сессий), user_id (список сессий юзера),
-- partial на manager_id (иерархия) — WHERE manager_id IS NOT NULL, т.к. большинство
-- admin/manager строк это NULL и не участвуют в lookup "employees этого manager'а".
--
-- IDEMPOTENCY:
-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS. Повторный прогон — no-op.
-- CHECK-констрейнты добавлены inline в CREATE TABLE (не через ALTER) — при повторном
-- запуске CREATE TABLE IF NOT EXISTS не выполнится вообще, констрейнт не задублируется.
--
-- Dependencies: нет (новые таблицы, ничего существующего не меняем).
-- Deploy order: эта миграция — Foundation эпика #2549. Seed (#2557) и backend auth-код —
-- отдельные PR'ы ПОСЛЕ этой (SQL-схема первой, см. .claude/rules/sql.md "Migration order").
BEGIN;
CREATE TABLE IF NOT EXISTS tradein_users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username text NOT NULL UNIQUE,
password_hash text NULL,
role text NOT NULL CHECK (role IN ('admin', 'manager', 'employee')),
manager_id bigint NULL REFERENCES tradein_users(id) ON DELETE SET NULL,
display_name text NULL,
org_name text NULL,
email text NULL,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT tradein_users_role_manager_hierarchy_ck CHECK (
role NOT IN ('admin', 'manager') OR manager_id IS NULL
)
);
COMMENT ON TABLE tradein_users IS
'Trade-in DB-backed auth — пользователи (issue #2551, эпик #2549). password_hash NULL '
'допустим в переходный период (логин через legacy Caddy fallback). Seed — отдельно (#2557).';
COMMENT ON COLUMN tradein_users.password_hash IS
'NULL = логин только через legacy Caddy basic-auth fallback, не через password verify.';
COMMENT ON COLUMN tradein_users.manager_id IS
'Self-FK на tradein_users(id). NULL для admin/manager (top-level, CHECK ниже) или для '
'employee без назначенной организации.';
CREATE TABLE IF NOT EXISTS tradein_sessions (
token text PRIMARY KEY,
user_id bigint NOT NULL REFERENCES tradein_users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
last_seen_at timestamptz NOT NULL DEFAULT now(),
ip_address inet NULL,
user_agent text NULL
);
COMMENT ON TABLE tradein_sessions IS
'Trade-in DB-backed auth — активные сессии (issue #2551, эпик #2549). '
'ON DELETE CASCADE от tradein_users: удалённый пользователь теряет все сессии.';
CREATE INDEX IF NOT EXISTS tradein_sessions_expires_at_idx
ON tradein_sessions (expires_at);
CREATE INDEX IF NOT EXISTS tradein_sessions_user_id_idx
ON tradein_sessions (user_id);
CREATE INDEX IF NOT EXISTS tradein_users_manager_id_idx
ON tradein_users (manager_id)
WHERE manager_id IS NOT NULL;
COMMIT;

View file

@ -25,6 +25,7 @@ dependencies = [
"sentry-sdk>=2.0.0", # мониторинг ошибок → GlitchTip (#396) "sentry-sdk>=2.0.0", # мониторинг ошибок → GlitchTip (#396)
"redis>=5.0.0", # async hot cache для /api/v1/search (Phase 3.2) "redis>=5.0.0", # async hot cache для /api/v1/search (Phase 3.2)
"pyyaml>=6.0.0", # RBAC roles.yaml loader (app/core/auth.py) "pyyaml>=6.0.0", # RBAC roles.yaml loader (app/core/auth.py)
"bcrypt>=4.2.0", # password hashing для DB-auth (#2550)
"playwright>=1.45", # Playwright client для connect к tradein-browser (#905) "playwright>=1.45", # Playwright client для connect к tradein-browser (#905)
"scraper-kit", # internal workspace-package (#2137) — общие утилиты скрапперов; "scraper-kit", # internal workspace-package (#2137) — общие утилиты скрапперов;
# резолвится из workspace (см. [tool.uv.sources]), не с PyPI. # резолвится из workspace (см. [tool.uv.sources]), не с PyPI.

View file

@ -0,0 +1,536 @@
"""Integration tests for #2552 auth-core: POST /login, /logout, dual-mode GET /me,
and rbac_guard session-cookie resolution.
Uses the REAL `rbac_guard` (app.core.rbac) + REAL `auth.router` / `me.router` wired
into an isolated FastAPI test app (same pattern as tests/test_rbac.py), with an
in-memory fake DB standing in for `tradein_users`/`tradein_sessions`:
- `app.core.rbac.SessionLocal` is monkeypatched (rbac_guard opens its own session,
it's middleware — no FastAPI DI available there).
- `app.core.db.get_db` is overridden via `app.dependency_overrides` (auth.py /
me.py use `Depends(get_db)`, the idiomatic FastAPI-testable path).
Both point at the SAME `_Store` instance per test, so a session created by POST
/login is immediately visible to rbac_guard's own DB round trip on the next request.
"""
from __future__ import annotations
import os
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import Annotated, Any
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from fastapi import FastAPI, Header
from fastapi.testclient import TestClient
from app.api.v1 import auth as auth_router
from app.api.v1 import me as me_router
from app.core import auth as auth_mod
from app.core import config
from app.core.db import get_db
from app.core.password import hash_password
from app.core.rbac import rbac_guard
# ---------------------------------------------------------------------------
# Fake DB backing tradein_users / tradein_sessions
# ---------------------------------------------------------------------------
class _Store:
def __init__(self) -> None:
self.users: dict[str, dict[str, Any]] = {}
self.sessions: dict[str, dict[str, Any]] = {}
self._next_id = 1
def add_user(
self,
username: str,
password_hash: str | None,
*,
role: str = "employee",
is_active: bool = True,
display_name: str | None = "Alice A.",
org_name: str | None = "Org LLC",
email: str | None = "alice@example.com",
) -> int:
uid = self._next_id
self._next_id += 1
self.users[username] = {
"id": uid,
"username": username,
"password_hash": password_hash,
"role": role,
"is_active": is_active,
"display_name": display_name,
"org_name": org_name,
"email": email,
}
return uid
def user_by_id(self, uid: int) -> dict[str, Any] | None:
for u in self.users.values():
if u["id"] == uid:
return u
return None
def add_expired_session(self, token: str, user_id: int) -> None:
now = datetime.now(UTC)
self.sessions[token] = {
"user_id": user_id,
"expires_at": now - timedelta(minutes=1),
"last_seen_at": now - timedelta(minutes=1),
}
class _FakeDB:
"""Minimal Session stand-in dispatching on SQL text — see module docstring."""
def __init__(self, store: _Store) -> None:
self.store = store
def __enter__(self) -> _FakeDB:
return self
def __exit__(self, *exc: object) -> bool:
return False
def close(self) -> None:
pass
def commit(self) -> None:
pass
def rollback(self) -> None:
pass
def execute(self, stmt: object, params: dict[str, Any] | None = None) -> SimpleNamespace:
sql = str(stmt)
p = params or {}
if "INSERT INTO tradein_sessions" in sql:
now = datetime.now(UTC)
self.store.sessions[p["token"]] = {
"user_id": p["user_id"],
"expires_at": now + timedelta(hours=p["ttl_hours"]),
"last_seen_at": now,
}
return SimpleNamespace(fetchone=lambda: None)
if "UPDATE tradein_sessions" in sql and "SET last_seen_at" in sql:
sess = self.store.sessions.get(p["token"])
if sess is not None:
now = datetime.now(UTC)
sess["last_seen_at"] = now
sess["expires_at"] = now + timedelta(hours=p["ttl_hours"])
return SimpleNamespace(fetchone=lambda: None)
if "DELETE FROM tradein_sessions WHERE token" in sql:
self.store.sessions.pop(p["token"], None)
return SimpleNamespace(fetchone=lambda: None)
if "DELETE FROM tradein_sessions WHERE user_id" in sql:
uid = p["user_id"]
for tok in [t for t, s in self.store.sessions.items() if s["user_id"] == uid]:
del self.store.sessions[tok]
return SimpleNamespace(fetchone=lambda: None)
if "FROM tradein_sessions s" in sql and "JOIN tradein_users u" in sql:
sess = self.store.sessions.get(p["token"])
if sess is None:
return SimpleNamespace(fetchone=lambda: None)
user = self.store.user_by_id(sess["user_id"])
if user is None:
return SimpleNamespace(fetchone=lambda: None)
row = SimpleNamespace(
user_id=sess["user_id"],
expires_at=sess["expires_at"],
last_seen_at=sess["last_seen_at"],
username=user["username"],
role=user["role"],
display_name=user["display_name"],
org_name=user["org_name"],
email=user["email"],
is_active=user["is_active"],
)
return SimpleNamespace(fetchone=lambda: row)
if "FROM tradein_users" in sql:
user = self.store.users.get(p["username"])
if user is None:
return SimpleNamespace(fetchone=lambda: None)
row = SimpleNamespace(**user)
return SimpleNamespace(fetchone=lambda: row)
raise AssertionError(f"unhandled fake SQL in test_auth_api: {sql!r}")
# ---------------------------------------------------------------------------
# Test app
# ---------------------------------------------------------------------------
def _build_test_app(store: _Store) -> FastAPI:
app = FastAPI()
app.middleware("http")(rbac_guard)
app.include_router(auth_router.router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(me_router.router, prefix="/api/v1", tags=["me"])
@app.get("/api/v1/trade-in/dummy")
async def tradein_dummy() -> dict:
return {"ok": True}
@app.get("/api/v1/trade-in/whoami")
async def tradein_whoami(
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> dict:
"""Echoes the X-Authenticated-User header exactly as a downstream handler
(`_assert_estimate_access*`, `account_quota`, etc.) would see it used to
assert session-identity wins over a client-forged header (#2552 spoof fix)."""
return {"user": x_authenticated_user}
def _override_get_db(): # generator dependency — matches app.core.db.get_db shape
yield _FakeDB(store)
app.dependency_overrides[get_db] = _override_get_db
return app
@pytest.fixture(autouse=True)
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
auth_mod.reset_cache_for_tests()
auth_router._LOGIN_LIMITER._hits.clear()
monkeypatch.setattr(config.settings, "auth_mode", "dual")
@pytest.fixture
def store() -> _Store:
return _Store()
@pytest.fixture
def client(store: _Store, monkeypatch: pytest.MonkeyPatch) -> TestClient:
monkeypatch.setattr("app.core.rbac.SessionLocal", lambda: _FakeDB(store))
# base_url=https:// — login sets the session cookie with Secure=True (real prod
# behaviour, not weakened for tests); httpx's cookie jar silently drops Secure
# cookies on a plain-http connection, so a plain http://testserver client would
# never resend the cookie on subsequent requests within the same test.
return TestClient(_build_test_app(store), base_url="https://testserver")
# ---------------------------------------------------------------------------
# POST /login
# ---------------------------------------------------------------------------
def test_login_happy_path_sets_cookie(client: TestClient, store: _Store) -> None:
store.add_user("alice", hash_password("Secret123!"), role="employee")
resp = client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
assert resp.status_code == 200, resp.text
assert resp.json() == {"ok": True}
cookie_name = config.settings.session_cookie_name
assert cookie_name in resp.cookies
assert resp.cookies[cookie_name]
# Сессия реально создана в сторе под этим токеном.
assert resp.cookies[cookie_name] in store.sessions
def test_login_wrong_password_401_and_logs_failed_event(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
store.add_user("alice", hash_password("Secret123!"), role="employee")
events: list[dict[str, Any]] = []
monkeypatch.setattr(auth_router, "schedule_event", lambda **kw: events.append(kw))
resp = client.post("/api/v1/auth/login", json={"username": "alice", "password": "wrong"})
assert resp.status_code == 401
assert "detail" in resp.json()
assert config.settings.session_cookie_name not in resp.cookies
assert any(e["event_type"] == "login_failed" for e in events)
failed = next(e for e in events if e["event_type"] == "login_failed")
assert failed["username"] == "alice"
# Raw-пароль никогда не попадает в событие.
assert "wrong" not in str(failed)
def test_login_unknown_username_401_generic_message(client: TestClient) -> None:
resp = client.post("/api/v1/auth/login", json={"username": "ghost", "password": "whatever"})
assert resp.status_code == 401
# НЕ раскрываем, что юзера не существует — то же сообщение, что и wrong-password.
body_ghost = resp.json()["detail"]
resp2 = client.post("/api/v1/auth/login", json={"username": "ghost2", "password": "x"})
assert resp2.json()["detail"] == body_ghost
def test_login_inactive_user_401(client: TestClient, store: _Store) -> None:
store.add_user("bob", hash_password("Secret123!"), role="employee", is_active=False)
resp = client.post("/api/v1/auth/login", json={"username": "bob", "password": "Secret123!"})
assert resp.status_code == 401
def test_login_null_password_hash_401(client: TestClient, store: _Store) -> None:
store.add_user("carol", None, role="employee")
resp = client.post("/api/v1/auth/login", json={"username": "carol", "password": "anything"})
assert resp.status_code == 401
def test_login_always_calls_verify_password_timing_oracle_guard(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
"""#2552 post-review Medium 2: `verify_password` должен выполняться ровно
один раз на КАЖДУЮ попытку логина включая неизвестный username и NULL
password_hash иначе короткое замыкание даёт наблюдаемый timing-oracle
для user-enumeration. Тест не измеряет тайминг (флейки в CI), а проверяет
сам факт + аргумент вызова через monkeypatch-счётчик."""
store.add_user("alice", hash_password("Secret123!"), role="employee")
store.add_user("nullhash", None, role="employee")
calls: list[str] = []
real_verify = auth_router.verify_password
def _counting_verify(plain: str, hashed: str) -> bool:
calls.append(hashed)
return real_verify(plain, hashed)
monkeypatch.setattr(auth_router, "verify_password", _counting_verify)
resp_unknown = client.post("/api/v1/auth/login", json={"username": "ghost", "password": "x"})
assert resp_unknown.status_code == 401
resp_null_hash = client.post(
"/api/v1/auth/login", json={"username": "nullhash", "password": "x"}
)
assert resp_null_hash.status_code == 401
resp_wrong_pw = client.post(
"/api/v1/auth/login", json={"username": "alice", "password": "wrong"}
)
assert resp_wrong_pw.status_code == 401
assert len(calls) == 3
# Unknown user / NULL hash — сверяется против dummy-хеша, не против NULL.
assert calls[0] == auth_router._DUMMY_PASSWORD_HASH
assert calls[1] == auth_router._DUMMY_PASSWORD_HASH
# Реальный юзер с реальным hash — НЕ dummy.
assert calls[2] != auth_router._DUMMY_PASSWORD_HASH
def test_login_rate_limit_429(client: TestClient, store: _Store) -> None:
store.add_user("dave", hash_password("Secret123!"), role="employee")
limit = config.settings.login_rate_limit
for _ in range(limit):
resp = client.post("/api/v1/auth/login", json={"username": "dave", "password": "wrong"})
assert resp.status_code == 401
resp = client.post("/api/v1/auth/login", json={"username": "dave", "password": "wrong"})
assert resp.status_code == 429
assert "Retry-After" in resp.headers
# ---------------------------------------------------------------------------
# POST /logout
# ---------------------------------------------------------------------------
def test_logout_revokes_session_and_clears_cookie(client: TestClient, store: _Store) -> None:
store.add_user("alice", hash_password("Secret123!"), role="employee")
login_resp = client.post(
"/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"}
)
token = login_resp.cookies[config.settings.session_cookie_name]
assert token in store.sessions
logout_resp = client.post("/api/v1/auth/logout")
assert logout_resp.status_code == 200
assert logout_resp.json() == {"ok": True}
assert token not in store.sessions
# ---------------------------------------------------------------------------
# GET /me — session-first + dual-mode legacy fallback
# ---------------------------------------------------------------------------
def test_me_with_session_cookie_returns_db_role(client: TestClient, store: _Store) -> None:
store.add_user(
"alice",
hash_password("Secret123!"),
role="employee",
display_name="Алиса",
org_name="ООО Ромашка",
email="alice@romashka.ru",
)
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
resp = client.get("/api/v1/me")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["username"] == "alice"
assert body["role"] == "employee"
assert "/trade-in/**" in body["allowed_paths"]
assert "/admin/**" in body["deny_paths"]
assert body["display_name"] == "Алиса"
assert body["org"] == "ООО Ромашка"
assert body["email"] == "alice@romashka.ru"
def test_me_manager_role_gets_team_path(client: TestClient, store: _Store) -> None:
store.add_user("mgr", hash_password("Secret123!"), role="manager")
client.post("/api/v1/auth/login", json={"username": "mgr", "password": "Secret123!"})
resp = client.get("/api/v1/me")
assert resp.status_code == 200
assert "/api/v1/team/**" in resp.json()["allowed_paths"]
def test_me_without_cookie_dual_mode_legacy_still_works(client: TestClient) -> None:
"""Regression guard: без сессии, auth_mode=dual — legacy X-Authenticated-User
путь через roles.yaml работает БЕЗ ИЗМЕНЕНИЙ."""
resp = client.get("/api/v1/me", headers={"X-Authenticated-User": "admin"})
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["username"] == "admin"
assert body["role"] == "admin"
assert body["allowed_paths"] == ["/**"]
def test_me_no_cookie_no_header_401(client: TestClient) -> None:
resp = client.get("/api/v1/me")
assert resp.status_code == 401
def test_expired_session_falls_back_to_no_auth(client: TestClient, store: _Store) -> None:
"""Истёкшая сессия трактуется как отсутствие cookie: без legacy-заголовка — 401."""
uid = store.add_user("alice", hash_password("Secret123!"), role="employee")
store.add_expired_session("expired-tok", uid)
client.cookies.set(config.settings.session_cookie_name, "expired-tok")
resp = client.get("/api/v1/trade-in/dummy")
assert resp.status_code == 401
def test_expired_session_falls_back_to_legacy_header_in_dual_mode(
client: TestClient, store: _Store
) -> None:
"""Истёкшая сессия + валидный legacy header в dual-mode — header отрабатывает."""
uid = store.add_user("alice", hash_password("Secret123!"), role="employee")
store.add_expired_session("expired-tok", uid)
client.cookies.set(config.settings.session_cookie_name, "expired-tok")
resp = client.get(
"/api/v1/trade-in/dummy",
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 200
assert resp.json() == {"ok": True}
# ---------------------------------------------------------------------------
# rbac_guard dual vs db_only
# ---------------------------------------------------------------------------
def test_db_only_mode_rejects_legacy_header_without_session(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(config.settings, "auth_mode", "db_only")
resp = client.get(
"/api/v1/trade-in/dummy",
headers={"X-Authenticated-User": "admin"},
)
assert resp.status_code == 401
assert "session" in resp.json()["detail"].lower()
def test_db_only_mode_accepts_valid_session(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
store.add_user("alice", hash_password("Secret123!"), role="employee")
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
monkeypatch.setattr(config.settings, "auth_mode", "db_only")
resp = client.get("/api/v1/trade-in/dummy")
assert resp.status_code == 200
assert resp.json() == {"ok": True}
def test_session_user_can_reach_tradein_but_not_admin(client: TestClient, store: _Store) -> None:
store.add_user("alice", hash_password("Secret123!"), role="employee")
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
ok = client.get("/api/v1/trade-in/dummy")
assert ok.status_code == 200
denied = client.get("/api/v1/admin/dummy")
# rbac_guard's admin-gate matches the path regex BEFORE routing even happens
# (route isn't registered on this test app) — role=employee != admin -> 403,
# never a 404 (a bare "any non-2xx" assertion would mask a rbac_guard typo).
assert denied.status_code == 403
# ---------------------------------------------------------------------------
# #2552 post-review CRITICAL fix: session identity must win over a spoofed
# client-sent X-Authenticated-User header (was a skip-if-present bug — the
# forged header used to override the session for every downstream reader of
# the raw header: _assert_estimate_access*, account_quota, /trade-in/history,
# support.py — in BOTH auth_mode=dual and db_only).
# ---------------------------------------------------------------------------
def test_session_identity_wins_over_spoofed_header_dual_mode(
client: TestClient, store: _Store
) -> None:
store.add_user("alice", hash_password("Secret123!"), role="employee")
store.add_user("victim", hash_password("Secret123!"), role="employee")
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
resp = client.get(
"/api/v1/trade-in/whoami",
headers={"X-Authenticated-User": "victim"},
)
assert resp.status_code == 200
assert resp.json()["user"] == "alice"
def test_session_identity_wins_over_spoofed_header_db_only_mode(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
store.add_user("alice", hash_password("Secret123!"), role="employee")
store.add_user("victim", hash_password("Secret123!"), role="employee")
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
monkeypatch.setattr(config.settings, "auth_mode", "db_only")
resp = client.get(
"/api/v1/trade-in/whoami",
headers={"X-Authenticated-User": "victim"},
)
assert resp.status_code == 200
assert resp.json()["user"] == "alice"
def test_cyrillic_username_session_propagation_does_not_500(
client: TestClient, store: _Store
) -> None:
"""#2552 post-review Medium 1: `.encode("latin-1")` без errors="replace" на
кириллическом username крашил бы КАЖДЫЙ запрос такого юзера с 500."""
store.add_user("алиса", hash_password("Secret123!"), role="employee")
login_resp = client.post(
"/api/v1/auth/login", json={"username": "алиса", "password": "Secret123!"}
)
assert login_resp.status_code == 200, login_resp.text
resp = client.get("/api/v1/trade-in/whoami")
assert resp.status_code == 200, resp.text
# latin-1 "replace" гарантированно не крашит — точное значение (что именно
# получится из non-latin1 байт) не является контрактом, важно отсутствие 500.
assert resp.json()["user"] is not None

View file

@ -0,0 +1,306 @@
"""Tests for app.services.auth_session — session CRUD + DB-role scope mapping (#2552).
Coverage:
- create_session: INSERT with CAST(...) (never `:x::type`), commit, unique tokens.
- get_session_user: valid/expired/inactive/missing-row + sliding refresh (only when
last_seen_at is stale, best-effort a refresh failure still returns the user).
- get_user_by_username: found/not-found.
- revoke_session / revoke_user_sessions: DELETE + commit.
- get_db_role_scope: employee/manager/admin/unknown mapping.
All functions here take `db: Session` as a plain argument (no SessionLocal() opened
internally) unit tests just pass a hand-rolled fake, mirroring the `_FakeSession`
pattern from tests/test_user_events.py but adapted for `.fetchone()`-based reads.
"""
from __future__ import annotations
import os
import re
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services import auth_session as svc
# ---------------------------------------------------------------------------
# Fake DB session
# ---------------------------------------------------------------------------
class _FakeDB:
"""Minimal `Session` stand-in: queued `.fetchone()` results per `execute()` call,
in call order. `execute()` beyond the queue returns a result with `fetchone()
-> None`. Set `.raise_on_call = N` to make the Nth `execute()` (1-indexed) raise.
"""
def __init__(self, rows: list[Any] | None = None) -> None:
self._rows: list[Any] = list(rows or [])
self.executed: list[tuple[str, dict[str, Any] | None]] = []
self.committed = 0
self.rolled_back = 0
self.raise_on_call: int | None = None
def execute(self, stmt: object, params: dict[str, Any] | None = None) -> SimpleNamespace:
call_no = len(self.executed) + 1
self.executed.append((str(stmt), params))
if self.raise_on_call == call_no:
raise RuntimeError("simulated DB failure")
row = self._rows.pop(0) if self._rows else None
return SimpleNamespace(fetchone=lambda: row)
def commit(self) -> None:
self.committed += 1
def rollback(self) -> None:
self.rolled_back += 1
def _session_row(
*,
user_id: int = 1,
expires_at: datetime | None = None,
last_seen_at: datetime | None = None,
username: str = "alice",
role: str = "employee",
is_active: bool = True,
) -> SimpleNamespace:
now = datetime.now(UTC)
return SimpleNamespace(
user_id=user_id,
expires_at=expires_at if expires_at is not None else now + timedelta(hours=1),
last_seen_at=last_seen_at if last_seen_at is not None else now,
username=username,
role=role,
display_name="Alice A.",
org_name="Org LLC",
email="alice@example.com",
is_active=is_active,
)
def _user_row(
*,
user_id: int = 1,
username: str = "alice",
password_hash: str | None = "hash",
role: str = "employee",
is_active: bool = True,
) -> SimpleNamespace:
return SimpleNamespace(
id=user_id,
username=username,
password_hash=password_hash,
role=role,
is_active=is_active,
display_name="Alice A.",
org_name="Org LLC",
email="alice@example.com",
)
# ---------------------------------------------------------------------------
# create_session
# ---------------------------------------------------------------------------
def test_create_session_inserts_and_commits() -> None:
db = _FakeDB()
token = svc.create_session(db, user_id=42, ip="1.2.3.4", user_agent="pytest")
assert db.committed == 1
assert len(db.executed) == 1
sql, params = db.executed[0]
assert "INSERT INTO tradein_sessions" in sql
assert params is not None
assert params["user_id"] == 42
assert params["ip"] == "1.2.3.4"
assert params["user_agent"] == "pytest"
assert params["token"] == token
assert isinstance(token, str)
assert len(token) >= 32
def test_create_session_cast_not_doublecolon() -> None:
db = _FakeDB()
svc.create_session(db, user_id=1)
sql, _ = db.executed[0]
assert not re.search(r":\w+::\w", sql)
assert "CAST(:ttl_hours AS integer)" in sql
assert "CAST(:ip AS inet)" in sql
def test_create_session_tokens_are_unique() -> None:
db = _FakeDB()
t1 = svc.create_session(db, user_id=1)
t2 = svc.create_session(db, user_id=1)
assert t1 != t2
# ---------------------------------------------------------------------------
# get_session_user
# ---------------------------------------------------------------------------
def test_get_session_user_no_token_returns_none() -> None:
db = _FakeDB()
assert svc.get_session_user(db, "") is None
assert db.executed == []
def test_get_session_user_missing_row_returns_none() -> None:
db = _FakeDB(rows=[None])
assert svc.get_session_user(db, "tok") is None
sql, params = db.executed[0]
assert "FROM tradein_sessions s" in sql
assert "JOIN tradein_users u" in sql
assert params == {"token": "tok"}
def test_get_session_user_expired_returns_none() -> None:
now = datetime.now(UTC)
db = _FakeDB(rows=[_session_row(expires_at=now - timedelta(minutes=1))])
assert svc.get_session_user(db, "tok") is None
# Никакого sliding-refresh UPDATE для невалидной сессии.
assert len(db.executed) == 1
def test_get_session_user_inactive_returns_none() -> None:
db = _FakeDB(rows=[_session_row(is_active=False)])
assert svc.get_session_user(db, "tok") is None
assert len(db.executed) == 1
def test_get_session_user_valid_recent_no_refresh() -> None:
"""last_seen_at свежий (<5 мин) — sliding refresh НЕ триггерится."""
now = datetime.now(UTC)
db = _FakeDB(rows=[_session_row(last_seen_at=now - timedelta(minutes=1))])
result = svc.get_session_user(db, "tok")
assert result is not None
assert result["username"] == "alice"
assert result["role"] == "employee"
assert result["display_name"] == "Alice A."
assert result["org_name"] == "Org LLC"
assert result["email"] == "alice@example.com"
assert result["user_id"] == 1
# Только 1 execute (SELECT) — никакого UPDATE.
assert len(db.executed) == 1
assert db.committed == 0
def test_get_session_user_stale_last_seen_triggers_refresh() -> None:
"""last_seen_at старше 5 минут — один UPDATE (sliding refresh) + commit."""
now = datetime.now(UTC)
db = _FakeDB(rows=[_session_row(last_seen_at=now - timedelta(minutes=10))])
result = svc.get_session_user(db, "tok")
assert result is not None
assert len(db.executed) == 2
update_sql, update_params = db.executed[1]
assert "UPDATE tradein_sessions" in update_sql
assert "SET last_seen_at" in update_sql
assert not re.search(r":\w+::\w", update_sql)
assert "CAST(:ttl_hours AS integer)" in update_sql
assert update_params == {"ttl_hours": 720, "token": "tok"}
assert db.committed == 1
def test_get_session_user_refresh_failure_is_swallowed() -> None:
"""Sliding-refresh UPDATE падает — всё равно возвращаем валидного юзера
(best-effort refresh, не часть решения "валидна ли сессия")."""
now = datetime.now(UTC)
db = _FakeDB(rows=[_session_row(last_seen_at=now - timedelta(minutes=10))])
db.raise_on_call = 2
result = svc.get_session_user(db, "tok")
assert result is not None
assert result["username"] == "alice"
assert db.rolled_back == 1
assert db.committed == 0
# ---------------------------------------------------------------------------
# get_user_by_username
# ---------------------------------------------------------------------------
def test_get_user_by_username_found() -> None:
db = _FakeDB(rows=[_user_row()])
user = svc.get_user_by_username(db, "alice")
assert user is not None
assert user["username"] == "alice"
assert user["password_hash"] == "hash"
assert user["role"] == "employee"
assert user["is_active"] is True
sql, params = db.executed[0]
assert "FROM tradein_users" in sql
assert params == {"username": "alice"}
def test_get_user_by_username_not_found() -> None:
db = _FakeDB(rows=[None])
assert svc.get_user_by_username(db, "ghost") is None
# ---------------------------------------------------------------------------
# revoke_session / revoke_user_sessions
# ---------------------------------------------------------------------------
def test_revoke_session_deletes_and_commits() -> None:
db = _FakeDB()
svc.revoke_session(db, "tok")
assert db.committed == 1
sql, params = db.executed[0]
assert "DELETE FROM tradein_sessions" in sql
assert "token" in sql
assert params == {"token": "tok"}
def test_revoke_user_sessions_deletes_and_commits() -> None:
db = _FakeDB()
svc.revoke_user_sessions(db, 7)
assert db.committed == 1
sql, params = db.executed[0]
assert "DELETE FROM tradein_sessions" in sql
assert "user_id" in sql
assert params == {"user_id": 7}
# ---------------------------------------------------------------------------
# get_db_role_scope
# ---------------------------------------------------------------------------
def test_get_db_role_scope_employee_matches_legacy_pilot() -> None:
paths, deny = svc.get_db_role_scope("employee")
assert "/trade-in/**" in paths
assert "/trade-in/api/v1/**" in paths
assert "/admin/**" in deny
assert "/api/v1/admin/**" in deny
def test_get_db_role_scope_manager_adds_team_path() -> None:
paths, deny = svc.get_db_role_scope("manager")
assert "/trade-in/**" in paths
assert "/api/v1/team/**" in paths
assert "/admin/**" in deny
def test_get_db_role_scope_admin_full_access() -> None:
paths, deny = svc.get_db_role_scope("admin")
assert paths == ["/**"]
assert deny == []
def test_get_db_role_scope_unknown_role_denies_all() -> None:
paths, deny = svc.get_db_role_scope("ghost")
assert paths == []
assert deny == ["/**"]

View file

@ -0,0 +1,79 @@
"""Тесты для app/core/password.py — bcrypt hash/verify (#2550)."""
from __future__ import annotations
import pytest
from app.core.password import hash_password, verify_password
def test_roundtrip() -> None:
"""hash_password → verify_password с тем же паролем возвращает True."""
hashed = hash_password("correct horse battery staple")
assert verify_password("correct horse battery staple", hashed) is True
def test_wrong_password_returns_false() -> None:
"""Неверный пароль против валидного хеша → False."""
hashed = hash_password("correct horse battery staple")
assert verify_password("wrong password", hashed) is False
def test_hash_too_long_raises_value_error() -> None:
"""Пароль >72 байт в UTF-8 → ValueError в hash_password (нет silent truncation)."""
long_password = "a" * 73
with pytest.raises(ValueError):
hash_password(long_password)
def test_hash_exactly_72_bytes_ok() -> None:
"""Ровно 72 байта — граничное значение, ещё допустимо."""
password = "a" * 72
hashed = hash_password(password)
assert verify_password(password, hashed) is True
def test_hash_too_long_multibyte_raises_value_error() -> None:
"""40 кириллических символов = 80 байт UTF-8 (2 байта/символ) → ValueError.
Проверяет, что лимит считается в байтах, а не в символах иначе 40-символьный
кириллический пароль (< 72 символов, но 80 байт) прошёл бы мимо guard'а.
"""
long_cyrillic_password = "а" * 40
assert len(long_cyrillic_password.encode("utf-8")) == 80
with pytest.raises(ValueError):
hash_password(long_cyrillic_password)
def test_verify_too_long_returns_false_not_raise() -> None:
"""verify_password на >72-байтовом пароле возвращает False, НЕ raise."""
hashed = hash_password("some valid password")
long_password = "a" * 73
assert verify_password(long_password, hashed) is False
def test_hash_empty_raises_value_error() -> None:
"""Пустой пароль → ValueError в hash_password."""
with pytest.raises(ValueError):
hash_password("")
def test_verify_empty_returns_false() -> None:
"""Пустой пароль в verify_password → False (не raise)."""
hashed = hash_password("some valid password")
assert verify_password("", hashed) is False
def test_hash_is_unique_due_to_salt() -> None:
"""Два хеша одного пароля различаются (уникальная соль на каждый вызов)."""
password = "correct horse battery staple"
hash1 = hash_password(password)
hash2 = hash_password(password)
assert hash1 != hash2
assert verify_password(password, hash1) is True
assert verify_password(password, hash2) is True
def test_verify_malformed_hash_returns_false() -> None:
"""Некорректный (не-bcrypt) хеш в verify_password → False, не raise."""
assert verify_password("some password", "not-a-bcrypt-hash") is False

68
tradein-mvp/uv.lock generated
View file

@ -43,6 +43,72 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
] ]
[[package]]
name = "bcrypt"
version = "5.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" },
{ url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" },
{ url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" },
{ url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" },
{ url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" },
{ url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" },
{ url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" },
{ url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" },
{ url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" },
{ url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" },
{ url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" },
{ url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" },
{ url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" },
{ url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" },
{ url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" },
{ url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" },
{ url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" },
{ url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" },
{ url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" },
{ url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" },
{ url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" },
{ url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" },
{ url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" },
{ url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" },
{ url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" },
{ url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" },
{ url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" },
{ url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
{ url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
{ url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
{ url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
{ url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
{ url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
{ url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
{ url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
{ url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
{ url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
{ url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
{ url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
{ url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
{ url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
{ url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
{ url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
{ url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
{ url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
{ url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
{ url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
{ url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
{ url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
{ url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
{ url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
{ url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
{ url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
{ url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
]
[[package]] [[package]]
name = "brotli" name = "brotli"
version = "1.2.0" version = "1.2.0"
@ -1609,6 +1675,7 @@ name = "tradein-mvp-backend"
version = "0.1.0" version = "0.1.0"
source = { virtual = "backend" } source = { virtual = "backend" }
dependencies = [ dependencies = [
{ name = "bcrypt" },
{ name = "curl-cffi" }, { name = "curl-cffi" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "geoalchemy2" }, { name = "geoalchemy2" },
@ -1644,6 +1711,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "bcrypt", specifier = ">=4.2.0" },
{ name = "curl-cffi", specifier = ">=0.7.0" }, { name = "curl-cffi", specifier = ">=0.7.0" },
{ name = "fastapi", specifier = ">=0.115.0" }, { name = "fastapi", specifier = ">=0.115.0" },
{ name = "geoalchemy2", specifier = ">=0.15.0" }, { name = "geoalchemy2", specifier = ">=0.15.0" },