3 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| eccb895db1 |
feat(tradein): переключаемый реестр людей — подготовка переезда «Меры» в БД auth [PR-2b/6]
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 2m40s
Дефолт не меняет ничего: IDENTITY_STORE="tradein" — это сегодняшний прод, tradein_users/tradein_sessions, соединение с БД auth не открывается вообще. Переключение делается одной переменной окружения ПОСЛЕ того, как на проде появится пароль auth_app и будут скопированы данные. Так сделано намеренно: мерж, который зависит от невыполненного ручного шага, — это мерж, который ломает прод в момент невнимательности. Ядро. app/services/identity_store.py — единственное место, знающее, в какой БД и в каких таблицах живёт реестр. Имена таблиц берутся из фиксированного словаря по значению флага, не конкатенацией с вводом. app/core/auth_db.py — ЛЕНИВЫЙ engine БД auth (core/db.py создаёт свой на импорте; такое же для auth роняло бы старт без DSN). Одно понятие состояния доступа вместо двух. В tradein_users состояние — булев is_active, в auth.users — access_state из трёх значений. Конверсия живёт в одной функции to_access_state(): True→active, False→disabled, а неизвестная строка, NULL или чужой тип → disabled с WARNING. Fail-closed выбран сознательно: если следующая миграция добавит четвёртое состояние, оно по умолчанию НЕ будет пускать. Проверка доступа — свойство can_sign_in, а не сравнение со строкой. Логин в режиме auth. Пароль проверяется ВСЕГДА и ДО ветвления по состоянию — иначе появляется timing-oracle и перечисление логинов. Верный пароль + trial_expired → 403 с машиночитаемым code="access_expired", сессия НЕ создаётся. Верный пароль + disabled → тот же generic 401, что и при неверном пароле. Резолв уже выданной сессии пропускает только active — блокировка обрывает сессию немедленно, а не по истечении sliding-refresh. Старт падает явно, если IDENTITY_STORE=auth, а DSN не задан. Без этого ошибка конфигурации не похожа на аварию: продуктовая БД жива, приложение работает, а rbac_guard ловит исключение резолва вместе с любым другим сбоем и падает в legacy trusted-header ветку — то есть сутками раздаёт права из roles.yaml мимо реестра, включая аккаунты с disabled. Форма входа понимает новый код ответа. Ветвление по detail.code, а не по тексту: текст бэк вправе менять, код — нет. Гранты соблюдены, а не обойдены: auth_app не имеет UPDATE на role/manager_id и не имеет DELETE на users (миграция 004, column-level). Тесты: 2996 passed (+59). Единственный красный — test_search_cache_hit — предсуществующий: проверен контрольным полным прогоном на чистом main (2937 passed, тот же красный). |
|||
|
|
abb9398f3f |
fix(tradein/rbac): скрыть «Доля в продаже» и «Кэш» от клиентских аккаунтов
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 2m19s
CI Trade-In / backend-tests (pull_request) Successful in 3m13s
CI / openapi-codegen-check (pull_request) Successful in 3m34s
CI / backend-tests (pull_request) Successful in 16m7s
Аккаунт praktika (DB-роль manager) видел оба пункта в топбаре на /trade-in/team.
Это внутренние инструменты — аналитика рынка и состояние кэшей/скраперов, —
клиентские аккаунты их видеть не должны (решение владельца продукта).
Гейт один — deny-список роли, потому что все три места сверяются с ним через
общий матчер: пункт меню (Topbar по scopePath из /me), страница (RouteGuard) и
серверные ручки (rbac_guard). Правка только фронта спрятала бы пункт, оставив
прямой URL и API открытыми.
Закрыто для employee/manager (DB_ROLE_PATHS) и для legacy pilot (roles.yaml):
/trade-in/sale-share/**
/trade-in/cache/**
/trade-in/api/v1/buildings/**
/trade-in/api/v1/trade-in/cache-stats/**
У cache-stats ГЛОБ, а не точный путь: точный паттерн — строгое равенство, его
обходит трейлинг-слэш ('…/cache-stats/' → allowed=True), и защита держалась бы
на Starlette redirect_slashes, а не на RBAC. Замерено после правки: все варианты
(слэш, %2f, ./, ../) дают 403, утечек нет.
Основной продукт не задет: buildings.py обслуживает ТОЛЬКО sale-share, секция
«Продажи в доме» на экране оценки питается estimate-хендлерами. admin и analyst
сознательно вне deny — запиннено тестом, иначе «синхронизация» списков закрыла
бы их молча.
Заодно починен КРАСНЫЙ pre-existing тест главного бэкенда:
backend/tests/test_rbac.py::test_get_role_known_users ждал pilot у всех
user1..user10, но user2 («Брусника») стал expired 2026-07-30. CI это пропустил —
auth/roles.yaml не входит в paths-filter backend/**, из-за чего сьют не бежал.
Тесты: 153 passed (tradein) + 24 passed (site-finder, было 23+1 failed).
Новые — e2e через реальный rbac_guard по session-ветке (именно ею ходит
praktika), пин deny_paths в выдаче /me, границы глоба и regression-guard'ы.
Проверены снятием deny: 7 тестов краснеют, т.е. не тавтологии.
|
||
|
|
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. |