feat(tradein/team): team-management API — employees CRUD, quotas, stats (#2554) #2563
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#2563
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "feat/tradein-team-api"
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 #2554 (эпик #2549, шаг 4/8).
app/api/v1/team.py—POST /employees,PATCH /employees/{id},GET /employees,GET /employees/{id}/history; identity только из session-cookie (legacyX-Authenticated-Userна уровне идентичности не принимается — барьер поверх rbac_guard)app/schemas/team.py— pydantic-модели, ASCII-regex на username (^[A-Za-z0-9._-]{3,64}$— закрывает latin-1 коллизию из ревью #2561)_authorize_employee()— manager + чужойmanager_id→ 404 (не 403, не палим существование); в POSTmanager_idиз тела для manager молча игнорируется → принудительно свой id; для admin — валидация что указанный id существует и role='manager'is_active=false) →revoke_user_sessions(), сессия отзывается немедленно (покрыто тестом)account_quota_overridesсуществующим паттерном,account_quota.pyне тронутuser_events(estimate_request) LEFT JOINtrade_in_estimates, пагинация limit≤200/offsetТесты: +22 (org-изоляция негативно, employee → 403, без сессии → 401, не-ASCII → 422, дубль → 409, ревок сессий при блокировке, квота в статусе).
uv run pytest -q: 2807 passed, 1 failed — pre-existingtest_search_cache_hit(локальный env, на CI зелёный).rbac.py/auth_session.py/account_quota.pyне менялись.Deep code review — PR #2563 (head
712c56f4)Verdict: CHANGES REQUESTED (1 HIGH). Не мержу.
Проверка не по тестам автора: собран независимый харнесс, где реальный SQL из
team.pyисполняется на настоящем движке (in-memory SQLite), а не матчится по строке — фильтры, бинды и upsert реально выполняются. 27 adversarial-кейсов, 26 зелёных, 1 красный — он ниже. Полныйpytest testsна ветке: 2807 passed / 9 skipped (единственный local-failtest_search_cache_hit— env-артефакт порядка тестов, на CI зелёный). CI на712c56f4зелёный, включаяCI Trade-In / backend-tests.HIGH — reset пароля не убивает сессии сотрудника
app/api/v1/team.py:323-327—revoke_user_sessionsвызывается ТОЛЬКО приbody.is_active is False. Приnew_password(строки 291-295, 314-315) хеш меняется, а строки вtradein_sessionsостаются.Эмпирика: PATCH
{"new_password": "BrandNew1!"}→ 200, хеш обновлён (verify_passwordновым паролем = True), при этомSELECT * FROM tradein_sessions WHERE token='tok_emp_stolen'возвращает живую строку.Почему это HIGH, а не «косметика»:
get_session_user(auth_session.py:113-131) делает sliding-refresh: активная сессия продлевает себеexpires_atна каждый запрос, т.е. чужая сессия живёт бесконечно, а не «до конца TTL»;auth_session.py:184-186) прямо называет смену пароля своим use-case.Фикс (self-lockout невозможен:
_fetch_employee_rowфильтруетrole='employee', свою/админскую строку не пропатчить):Плюс регресс-тест: сессия сотрудника исчезает после reset пароля.
MEDIUM
team.py, ни глобально в приложении нет проверки Origin/Referer/CSRF-токена, а auth — cookie-based. Практический риск низкий: cookie ставится сsamesite="lax"(auth.py:130), а тело обязано быть JSON — cross-site POST/PATCH куку не донесёт. Но это надо либо реализовать, либо явно зафиксировать SameSite-обоснование (докстринг + комментарий в issue), иначе следующая ручка потеряет и это.GET /employees(team.py:417-421). Замер: 10 сотрудников → 23 запроса (2N+3: сессия ×2, список, затемuser_limit+account_estimate_usageна каждого). Список ничем не ограничен — admin без?manager_idвытянет всех. Предложение: один запрос сLEFT JOIN account_quota_overrides+LEFT JOIN account_estimate_usageпо текущему периоду, либоlimit/offsetкак в history.team.py:3-14и с DoD «gate вrbac.py».rbac_guardсверяет ВНЕШНИЙ путь (_EXTERNAL_PREFIX + path,rbac.py:235), поэтому/api/v1/team/**в списке manager'а (auth_session.py:208) не матчится никогда, а employee проходит по своему/trade-in/**. Реально employee режется вторым барьеромcurrent_team_actor→ 403 (проверено на всех 4 роутах). Т.е. дыры нет, но defense-in-depth ровно один слой: новая team-ручка безDepends(current_team_actor)окажется доступна employee. Одна строка вDB_ROLE_PATHS(deny"/trade-in/api/v1/team/**"дляemployee) закрывает это.LOW
team.py:403— f-string вtext()для сборкиWHERE. Инъекции нет (интерполируются только литералы,manager_idидёт биндом — проверено «1 OR 1=1» / «1;DROP TABLE …» → 422), но правило проекта «никаких f-string в SQL» нарушено и грепу такие места не видны. Лучше два статических запроса.team.py:301-303—COALESCEне даёт очиститьdisplay_name/org_name/emailв NULL (только перезаписать). Фронту (шаг 6) может понадобиться сброс поля._upsert_quota_override(team.py:131-150) перетираетnoteстрокойteam-api: set by <actor>— прежняя пометка гранта теряется. Флагunlimitedпри этом сохраняется (проверено: былunlimited=1, после PATCHmonthly_limit=25остался1) — то есть менеджер может выставить лимит, который молча не применится; в ответе это видно черезquota.unlimited, так что не блокер._authorize_employee) — это как раз тот сигнал, который стоит видеть вuser_events.Что проверено эмпирически (зелёное)
Org-изоляция — все четыре роута:
is_activeостался true,display_nameNULL, старый пароль валиден;GET /employeesменеджером A → только его сотрудники (чужие и «сироты» сmanager_id IS NULLне видны);?manager_id=<B>игнорируется, выдача не расширяется;GET /employees/{чужой}/history→ 404;manager_idменеджера B → создан под A (проверено в БД, не только в ответе).Эскалация:
role/is_activeв теле POST игнорируются → в БДrole='employee';account_quota_overridesдля менеджера не появился (квоту себе не поднять);manager_id/role/usernameв теле → поля не меняются;Authn: без cookie → 401 на всех 4 роутах; протухшая сессия → 401; сессия деактивированного менеджера → 401; legacy
X-Authenticated-User: adminбез cookie → 401 (session-only заявка докстринга подтверждена); employee → 403 на всех 4 роутах.Инъекции/границы:
manager_id=1 OR 1=1/1;DROP TABLE tradein_users/'--→ 422, таблица цела;limit0/-1/201/1e9 → 422,limit=200→ 200;offset=-1→ 422; usernameаб_вг/ab/65 символов/adm'--/<script>→ 422. Паттерн:x::typeв файлах PR отсутствует, ruff чистый.Квоты:
unlimited-грант переживает upsert;account_estimate_usage.usedне сбрасывается сменой лимита (было 7 — осталось 7, и ответ отдаётused=7);monthly_limit0/-5 → 422.account_quota.pyне тронут (diff = ровно 4 файла), логика 191-й миграции не задета.Пароли:
hash_passwordприменяется в обоих путях, длинный (>72 байт) и пустой пароль → 422 без создания строки; в ответе POST нет ни пароля, ни слова password; в аудит-событиях пароля нет.История: возвращаются только события своего сотрудника (события другого юзера и не-
estimate_requestотфильтрованы), LEFT JOIN подтягивает результат только своей оценки. Индексы под оба запроса есть (user_events_username_created_at_idx,tradein_users_manager_id_idx).Дальше
Один фикс (HIGH) + регресс-тест — и PR мержится. MEDIUM 1-3 можно отдельными issue, но 3 — одна строка, дешевле сделать здесь же.
Deep code review — fixup
4ac39710(PR #2563)Verdict: APPROVE, мержу. HIGH закрыт, регрессий не нашёл. Остаются два 🟡 на follow-up (ниже) — оба latent, не блокируют.
Проверял тем же независимым харнессом (реальный SQL роутера на настоящем движке), +31 новый кейс на дельту. Полный
pytest testsна4ac39710: 2815 passed / 9 skipped, ruff чистый, CI зелёный включая явныйCI Trade-In / backend-tests.HIGH — закрыт, проверено end-to-end
Не только «строка удалена»: сессия сотрудника ДО reset'а резолвится (GET team-роута → 403 по роли, т.е. rbac её видит), ПОСЛЕ
PATCH {"new_password": …}та же cookie даёт 401 — доступ реально разорван, а не «строка пропала из таблицы».Дополнительно проверено:
role='employee'-фильтр в_fetch_employee_rowотсекает раньше revoke);{"new_password", "is_active": false}на сотрудника manager B → 404, и сессия чужого сотрудника НЕ снесена (revoke строго после_authorize_employee);Origin-check — дыр не нашёл, легитимный поток не сломан
Матрица из 12 векторов, все как ожидалось:
https://gendsgn.ruhttps://gendsgn.ru.evil.comhttps://evil.com/?x=https://gendsgn.ruhttp://gendsgn.ru(scheme)https://gendsgn.ru:443https://user@gendsgn.runull/not-a-urlhttps://gendsgn.ru/trade-in/teamhttps://gendsgn.ru.evil.com/x,https://evil.com/https://gendsgn.ruПрефиксных/суффиксных обходов нет — сравнение точное (
scheme://netlocvs whitelist), а неstartswith.Originвыигрывает уReferer(подделать Referer при живом Origin нельзя). 403 отдаётся ДО мутации: после отбитого PATCHis_active, пароль, сессии и quota-override остались нетронутыми.Про «оба заголовка отсутствуют → пропускаем» — согласен, дыры не создаёт:
Originвсегда, в т.ч. приmode:'no-cors'и при form-POST; подавить его черезReferrer-Policyнельзя (это про Referer);application/json— такой запрос отвалится на 422 в FastAPI, не дойдя до логики;Легитимный поток не ломается:
www.gendsgn.ruв Caddyfile — 301 на apex, приложение живёт только наhttps://gendsgn.ru, ровно это и лежит в продCORS_ORIGINS. SSR/curl изнутри контейнера идут по skip-ветке.GET-роуты намеренно не гейтятся Origin'ом — корректно (нет мутации, кросс-сайтовое чтение ответа режет CORS).
Батч-квоты — 5 запросов вместо 23, но одно расхождение семантики (🟡 follow-up)
Замер подтверждён: 5 запросов на N=10 (было 23). Эквивалентность
_batch_quota_statusvsaccount_quota.get_statusпрогнал попарно на кейсах: без override, с numeric-лимитом,used>0,used>limit(remaining=0, не отрицательный), unlimited-грант, admin-роль, пустой список (без похода в БД), спецсимволы в username (o'brien, кириллица,a;DROP TABLE …--,%_wild— бинд держит, таблица цела).Расхождение ровно одно, и оно из-за особенности старого
is_unlimited:Для юзера, которого НЕТ в
auth/roles.yaml(а это каждый созданный через team-API сотрудник),is_unlimitedвозвращает False, не заглядывая вaccount_quota_overrides._batch_quota_statusже честно читаетoverride.unlimitedдля любого. Итог: приunlimited=trueу DB-only сотрудника список покажетquota.unlimited=true, а реальный энфорсмент (check_and_raise/increment) продолжит считать лимит.Почему не блокирую: направление fail-safe (показываем свободу, а ограничиваем строже — не наоборот), и сегодня недостижимо —
unlimited=trueстоит только уkopylov/praktika(миграция 191), оба есть вroles.yaml, а сам team-API теперь всегда пишетunlimited=false. Но докстринг_batch_quota_statusутверждает «семантика ИДЕНТИЧНА» — это неправда, и грабли выстрелят, когда безлимит впервые выдадут DB-сотруднику. Фикс на 4 строки:Пагинация — границы ок, сортировка без тай-брейкера (🟡 follow-up)
limit0/-1/201/1e9 → 422,limit=200→ 200,offset=-1→ 422,offsetза пределами →[], страницы бьются корректно и не пересекаются.Но
ORDER BY created_at DESCбез тай-брейкера приLIMIT/OFFSETнеоднозначен, аcreated_at DEFAULT now()— это timestamp транзакции: сид #2557, вставляющий пачку юзеров одной транзакцией, даст всем БАЙТ-В-БАЙТ одинаковыйcreated_at. Тогда порядок между страницами в PG не гарантирован → строки могут дублироваться/теряться при листании. На SQLite мой кейс с одинаковымcreated_atпрошёл (там порядок детерминирован по rowid) — то есть это риск, а не воспроизведённый баг. Лечится одним словом:ORDER BY created_at DESC, id DESC.Мелочи (не требуют действий)
unlimited=falseпри явной установке лимита — семантика правильная (иначе лимит молча не применялся бы);noteчерезCOALESCEсохраняет прежнюю причину гранта («пилот, грант 2026-07-27» пережил PATCH), новый override получает сгенерированный note. Путь до praktika/kopylov недостижим: они неrole='employee'→ 404, их override'ы не тронуты (проверено).= ANY(CAST(:x AS text[]))со списком в бинде — прод-проверенный паттерн репозитория (search_query.py:88,deactivate_stale_avito.py:76,analysis_runs/repository.py:178), на SQLite не проверяем by design.Итог
Мержу. Два 🟡 выше — отдельным follow-up (предлагаю приклеить к шагу #2557/#2558 эпика, оба по 1-4 строки).