10 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 8958a657cb |
fix(tradein/auth): агрегированный отказ пишется на ERROR — иначе канала нет (#2715)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 3m8s
Бэкенд поднят с LoggingIntegration(level=INFO, event_level=ERROR) (app/main.py): событием GlitchTip запись становится ровно с ERROR, а WARNING остаётся строкой в docker-логе, которая умирает с ротацией и редеплоем — то есть ровно тем следом, на бесполезность которого жалуется пункт 1 issue. Прецедент цены известен (#2674): монитор писал WARNING про протухшие куки, событий было ноль. Спама не будет: запись не чаще раза в окно (1с), все группируются в один issue. Получателей у проекта по-прежнему нет (#2673) — событие будет видно в интерфейсе и никому не уйдёт; это сказано в docstring, а не подразумевается. Уровень закреплён тестом: понижение до WARNING выключило бы канал молча. Refs #2715 |
|||
| 1610c01c6d |
fix(tradein/auth): отказ по насыщению — до выборки из БД и с агрегированным следом (#2715)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-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 3m18s
Два пункта хвоста #2712/#2717. 1. След инцидента. Отказ при насыщении сознательно не пишет login_failed и не тратит бюджет неудач по имени (иначе насыщением блокируют чужую учётку) — значит инцидент был виден только строкой logger.warning НА КАЖДЫЙ отказ. Лог у бэкенда общий и ограниченный (docker json-file, 20m × 3): при флуде в сотни запросов в секунду 60 МБ прокручиваются за минуты и выселяют все прочие логи ровно во время атаки. Теперь на окно (1с) — одна строка и одно событие login_verify_saturated в user_events, оба с числом отказов, накопленных с прошлой записи. Событие важнее строки: аудит переживает и ротацию логов, и редеплой. Имя в событии пустое намеренно — отказ случился до того, как мы на имя посмотрели, а запись присланного дала бы атакующему строки аудита с любым именем на выбор. 2. Гейт насыщения переехал ПЕРЕД выборкой пользователя. Раньше заведомо отклоняемый запрос всё равно брал соединение из пула и делал SELECT по имени — и это была единственная работа на пути отказа, чьё время зависит от существования учётки (bcrypt, который эту разницу ровняет, до отказанного запроса не доходит). Добавлен verify_slots_saturated(key) — тот же предикат, что решает отказ, но без взятия слота; verify_password_bounded зовёт его же, так что двум условиям разъехаться нечем и инвариант «одна точка выноса = одна точка учёта» цел. Предчек учитывает и общий потолок, и долю на ключ (#2714): при флуде с одного адреса первой упирается именно доля. Замер (20 заведомо отклоняемых запросов): до — 20 выборок из БД, 20 строк лога; после — 0 выборок, 1 строка. Все три ветки проверены мутацией кода: тесты краснеют. Refs #2715 |
|||
| 6cf9172d96 |
fix(tradein/auth): доля слотов сверки пароля на адрес — потолок перестаёт бить по своим (#2714) (#2717)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 3m0s
Deploy Trade-In / build-backend (push) Successful in 1m1s
Deploy Trade-In / deploy (push) Successful in 1m9s
|
|||
| 9d8114158b |
fix(tradein/auth): bcrypt вне событийного цикла + настоящий потолок темпа логинов (#2665) (#2712)
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 3m4s
Deploy Trade-In / build-backend (push) Successful in 1m2s
Deploy Trade-In / deploy (push) Successful in 1m15s
|
|||
| 40fdf11f19 |
fix(tradein/auth): не ронять и не занимать пул на замедлении входа (#2571)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
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 2m56s
Ревью нашло два способа положить сервис ровно под той нагрузкой, ради которой писалась защита. Первый: `min()` вычисляет оба аргумента, поэтому `float(2 ** (excess - 1))` при 1045 неудачах по имени за окно падал с OverflowError. Счётчик ничем не ограничен сверху — `record()` только копит метки и на лимит не смотрит. С этой попытки и до конца окна вход отдавал 500 мгновенно, без задержки и без записи в аудит: терялись обе ценности PR, и трение, и сигнал. Показатель степени зажат; 2**16 заведомо выше любого разумного потолка, поэтому видимое поведение не меняется. Второй: сон шёл внутри области жизни сессии БД. В дефолтном режиме `get_identity_db` отдаёт ту же сессию, что `get_db`, а SELECT в `get_user_by_username` оставляет её в открытой транзакции — соединение висело занятым все восемь секунд. Пятнадцати одновременных неудач хватало, чтобы выбрать QueuePool целиком и уронить любой другой эндпоинт по pool_timeout. Отказ в обслуживании против всех сразу — хуже той блокировки учётки, ради ухода от которой замедление и выбиралось. Соединение теперь возвращается в пул перед сном. Заодно: длина имени ограничена 64 (верх CHECK'а реестра) — сырое имя становится ключом обоих лимитеров, а их словарь при часовом окне не подчищается; и явно записано, что `limit` у счётчика на имя не порог. |
|||
| 7d154de1f7 |
feat(tradein/auth): глобальный потолок попыток входа на имя пользователя (#2571)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 6s
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 2m50s
Лимит на логине ключевался парой (username, IP), поэтому распределённый перебор одного имени с тысячи адресов получал по 5 попыток с каждого источника и не упирался ни во что. После снятия Caddy basic_auth с /trade-in (#2558) POST /auth/login — единственная ручка, доступная из интернета без кредов, так что дыра открыта прямо сейчас. Поверх существующего per-IP лимита добавлен глобальный счётчик неудач на ИМЯ, без IP в ключе. Превышение порога не блокирует учётку, а растит задержку ответа (удвоение от 1с до потолка): блокировка по имени была бы вектором отказа в обслуживании против конкретного человека — не зная пароля, злоумышленник гарантированно выключал бы чужой вход. Задержка применяется по ПРИСЛАННОМУ имени, без проверки его в реестре, и из одного места — общего хвоста всех отказов по кредам. Иначе «быстрый 401» для несуществующего имени стал бы оракулом существования учётки, то есть ровно той user-enumeration, от которой уже защищают одинаковый generic-ответ и безусловный bcrypt. |
|||
| 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 тестов краснеют, т.е. не тавтологии.
|
||
|
|
4ecc3d689c |
fix(tradein/auth): предотвратить подмену X-Authenticated-User при session-auth (#2552)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
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 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 без изменений.
|
||
|
|
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. |