feat(tradein/auth): auth-core — login/logout, sessions, dual-mode rbac (#2552) #2561
No reviewers
Labels
No labels
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
Fable 5 ревью
feedback/max
generative
GG-форсайт
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
вторичка
ИРД
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#2561
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "feat/tradein-auth-core"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #2552 (эпик #2549, шаг 3/8).
app/services/auth_session.py: create/get/revoke сессий, sliding TTL (refresh ≤1 раз в 5 мин),DB_ROLE_PATHS(employee≈pilot, manager +/api/v1/team/**, admin/**)app/api/v1/auth.py:POST /login(bcrypt verify, единый 401, rate-limit username+IP → 429, события login_success/login_failed в user_events, httponly+secure+lax cookie),POST /logoutapp/core/rbac.py: dual-mode — session-cookie приоритетен, legacyX-Authenticated-User+roles.yaml без изменений приAUTH_MODE=dual;/auth/login|logoutисключены из auth-гейтаapp/api/v1/me.py: session-first;app/main.py: mount + non-fatal warning при пустом SESSION_SECRETX-Authenticated-Userвrequest.scope["headers"]для session-юзеров → RequestAudit (читает после call_next) видит корректное имя; RateLimit (читает до) для session-запросов остаётся по-IP — задокументированный trade-off, не регрессияТесты: +34 (session unit + API integration: 401-кейсы, 429, logout, dual/db_only, expired session, legacy regression). Полный
uv run pytest -q: 1 failed —test_search_cache_hit, pre-existing (идентично падает на чистом forgejo/main, проверено stash-rerun'ом; на CI main зелёный — локальный env).Деплой безопасен без SESSION_SECRET (opaque-токены не требуют подписи, только warning).
Deep review — 🔴 BLOCK (1 critical)
CI
CI Trade-In / backend-tests= success на08352665,test_search_cache_hitна CI не падает (pre-existing локальный — подтверждено). Легаси-путь rbac.py сверен построчно: бит-в-бит, только обёрнут вif not from_session:. Сессии/логин/rate-limit — в целом добротно. Блокирует одна находка.🔴
app/core/rbac.py:_propagate_authenticated_user— клиент управляет downstream-identityВоспроизведено локально поверх вашего же fake-DB харнесса (
tests/test_auth_api.py), эндпоинт-эхо читаетHeader(alias="X-Authenticated-User")— ровно как все хендлерыtrade_in.py:Последствия:
_assert_estimate_access/GET /trade-in/history/account_quota/support.py::_usernameберут identity из сырого заголовка и резолвят роль через legacyget_role()из roles.yaml → сессионный employee, подставив имя roles.yaml-админа, читает оценки всех аккаунтов, чужие support-треды и списывает квоту на жертву.rbac_guardпри этом авторизует его как employee — гейт/api/v1/admin/*держится, но данные утекают мимо.Почему это не только теория:
X-Internal-Auth-Secret— то есть ровно ту защиту, которая ставилась против запросов мимо Caddy изнутриgendesign_shared. С валидной сессией этот контроль обходится.header_up X-Authenticated-User {http.auth.user.id}перезаписывает клиентское значение). Но эпик #2549 как раз снимает basic_auth — мина взводится на шаги 4-8.Фикс (перезапись вместо skip, ASGI-имена всегда lowercase bytes):
X-Authenticated-User→ downstream видит сессионного юзера (сейчас такого кейса в 34 новых тестах нет — это и есть пробел покрытия).🟡 Medium
rbac.py—username.encode("latin-1")на кириллическом username даётUnicodeEncodeError→ 500 на КАЖДОМ запросе такого юзера.tradein_users.username—text NOT NULL UNIQUEбез ASCII-ограничения, seed #2557 такое имя вполне заведёт. Лечится"replace"в фиксе выше (или CHECK на колонке).api/v1/auth.py:login— таймингового выравнивания нет:credentials_okкороткозамыкается доverify_password, поэтому неизвестный юзер / NULL-хеш отвечают за ~1 мс, а существующий с паролем — за ~100-300 мс (bcrypt). Единый 401 по тексту есть, но user-enumeration остаётся по времени (rate-limit смягчает, не закрывает). Фикс: прогонятьverify_passwordпротив фиксированного dummy-хеша, когда юзер не найден/hash NULL.🟢 Low / follow-up
tradein_sessions— нет джоба уборки протухших строк (индекс наexpires_atесть, метельщика нет); токен лежит плейнтекстом (хеш токена был бы аккуратнее).f"{username}:{ip}"— разделитель:не экранирован, теоретические коллизии; username не ограничен по длине.test_session_user_can_reach_tradein_but_not_admin—assert status in (401, 403, 404)пропустит и случайный 404 от отсутствующего роута.✅ Проверено и в порядке
AUTH_MODE=dualбез cookie — бит-в-бит (порядок: нет заголовка → 401, #2213 secret → 401,get_roleKeyError → 403, admin-гейт, scope с fail-open);tests/test_rbac.pyиtest_internal_auth_secret.pyне тронуты и зелёные.request.cookies.getдоSessionLocal()); коннект вrbac_guardберётся и отдаётся ДОcall_next, с пулом не конфликтует.secrets.token_urlsafe(32), cookie httponly+secure+samesite=lax, expired/is_activeпроверки, revoke, sliding refresh ≤1/5 мин (нет UPDATE на каждый запрос).text()с bind-параметрами иCAST(:x AS ...), f-string'ов нет;tradein_sessions.token= PK (индекс есть).send_default_pii=False,include_local_variables=False).call_nextи видит инъекцию.password_hashбез вызова bcrypt наNone; лимитер пустые корзины подчищает при >10k ключей.После фикса 🔴 + теста на спуфинг — готов к повторному ревью и мержу.
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 без изменений.Re-review фикса
4ecc3d68— ✅ APPROVE, смерженоПроверял независимо: свой репро-набор поверх вашего fake-DB харнесса (
_Store/_FakeDB), новые тесты воркера не переиспользовал.🔴 закрыт — подтверждено
X-Authenticated-User: victim_or_adminalice✅alice✅alice✅X-AUTHENTICATED-USER(верхний регистр)alice✅alice✅Регистр и дубли — сверх того, что покрыл фикс-коммит: ASGI нормализует имена в lowercase bytes до middleware, фильтр по
b"x-authenticated-user"ловит все варианты.(б) Legacy-путь не сломан — ключевой риск фикса, проверен отдельно
_propagate_authenticated_userвызывается ровно в одном месте (rbac.py:174) подif session_user is not None, поэтому фильтрация не может задеть не-session-трафик. Эмпирически:X-Authenticated-User: kopylovkopylov✅ нетронутkopylov✅kopylov✅401✅tests/test_rbac.py,test_internal_auth_secret.py,test_auth_api.py,test_auth_session.py,test_password.py,test_ratelimit.py— 91 passed на фикс-коммите.(в) Echo-роут не протёк
grep -rn whoami app/→ пусто; встречается только вtests/test_auth_api.py. Роутер прода чист.(г) Dummy-хеш
_DUMMY_PASSWORD_HASH= 184 мс единожды на импорте (bcrypt rounds=12), импорт модуля целиком 877 мс — для старта контейнера незаметно. Не логируется, случайный на процесс, в репозиторий не попадает.verify_passwordтеперь вызывается ровно один раз на попытку → таймингового оракула нет. Length-prefixed ключ лимитера корректно разводит и:в username, и IPv6 в IP.🟡 Остаётся — follow-up на seed #2557 (не блокер)
encode("latin-1", "replace")убирает 500, но кириллические username одинаковой длины схлопываются в одну строку:То есть двое таких юзеров получают ОДИН downstream-identity → общий
created_by, общая квота, взаимный IDOR, общий support-тред. Тихая коллизия неприятнее прежнего громкого 500.Не блокирую, потому что недостижимо сегодня:
tradein_usersедет пустой (миграция 192 явно без seed,INSERT INTO tradein_usersв ветке нет) → сессия в проде пока не может существовать вовсе. Просьба закрыть в #2557:CHECK (username ~ '^[ -~]+$')на колонке, либо fail-closed ветка в_propagate_authenticated_userна не-кодируемом username (лучше 401, чем молчаливая склейка).🟢 Прежние low остаются как есть
Нет уборки протухших
tradein_sessions; токен в БД плейнтекстом.Merge / deploy
CI Trade-In / backend-tests— success, 1m25s на4ecc3d68(явный, не skipped).test_search_cache_hitна CI не падает.d3e0aa29.deploy-tradeinrun 6052 —test/build-backend/deploysuccess, cleanup 6053 success./trade-in/api/v1/healthи/trade-in/отдают 401 от Caddy basic_auth (не 502) за ~0.1с — стек поднялся.