Commit graph

10 commits

Author SHA1 Message Date
bot-backend
b1fb7bb055 Merge remote-tracking branch 'forgejo/main' into feat/mera-public-api
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Successful in 46s
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 1m7s
CI Trade-In / backend-tests (pull_request) Successful in 4m45s
# Conflicts:
#	tradein-mvp/backend/app/core/rbac.py
2026-08-16 09:28:41 +03:00
bot-backend
b649caec2d feat(tradein/ops): приёмник GlitchTip webhook-алертов в Telegram-тему
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
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 4m50s
Мониторинг GlitchTip сейчас нем (alerts_projectalert/alerts_alertrecipient
пусты, EMAIL_URL=consolemail:// печатает письма в stdout, аудит на проде
2026-08-15). GlitchTip умеет получателя типа webhook, но шлёт свой Slack-
совместимый JSON без каких-либо заголовков — Telegram Bot API его не
понимает, нужен адаптер.

- app/api/v1/glitchtip.py: POST /api/v1/trade-in/ops/glitchtip-webhook —
  принимает issue- и uptime-алерты (структурно одинаковый payload у
  GlitchTip 6.1.6, см. docstring), форматирует короткое сообщение
  (проект/заголовок/ссылка/время получения) и шлёт через существующий
  TelegramClient в отдельную тему алертов. Обрезка под лимит Telegram
  (4096 симв.), неизвестная форма payload пересылается как есть с
  пометкой вместо 500.
- Auth: GlitchTip не может слать кастомные заголовки (aiohttp.post без
  headers=) — переиспользуем TRADEIN_INTERNAL_AUTH_SECRET (#2213) как
  query-параметр `secret`, constant-time compare. В отличие от rbac.py
  пустой секрет здесь fail-CLOSED (503), это единственный auth-рубеж пути.
- config.py: TELEGRAM_ALERTS_CHAT_ID / TELEGRAM_ALERTS_TOPIC_ID — намеренно
  отдельные от TELEGRAM_SUPPORT_*, чтобы алерты не лились в топик клиентов.
- rbac.py: путь добавлен в _PUBLIC_PATHS (фиксированный, без секрета в
  самом пути — секрет только в query).
- docker-compose.prod.yml: glitchtip-worker (реально шлёт вебхуки, не
  glitchtip-web) переведён на networks: [default, shared] — без этого
  tradein-backend не резолвится с его стороны (общей сети не было вообще).
2026-08-15 22:39:44 +03:00
bot-backend
d046eaada2 feat(mera/b2c): публичный API МЕРЫ — /api/public/mera/{suggest,coverage}
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
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 4m49s
Первый шаг к отдельному B2C-интерфейсу оценки на meraocenka.ru: домен получает
собственную поверхность бэкенда вместо того, чтобы тянуть куски закрытого
контура.

## Отдельный префикс, а не проброс кусков /api/v1/*

На meraocenka.ru действует allowlist-by-default. Открыть там API можно было
двумя способами: перечислить нужные v1-пути поимённо — или завести префикс, под
которым по определению не лежит ничего закрытого. Выбран второй: при первом
одна опечатка в матчере (`/trade-in/api/*` вместо точного пути) открывает
наружу весь v1 — ~20 ручек, включая PDF расчётов, фотографии и админку. Цена
ошибки, а не удобство.

Добавить сюда приватную ручку теперь нужно СПЕЦИАЛЬНО — положив файл в
app/api/public/. Случайно нельзя.

## Ноль записей в БД

Обе ручки только читают: /coverage — один SELECT, /suggest — прокси
автокомплита. Это условие, при котором публичная форма работает ДО контура
согласия 152-ФЗ (#2895: сегодня адрес физлица попадает в trade_in_estimates
раньше согласия, а пути удаления в бэкенде нет). Платный расчёт, который писать
будет, открывается только вместе с ним.

## Делегирование, а не копии

Обе ручки вызывают те же функции, что обслуживают закрытый контур
(v1.geocode.suggest_addresses, v1.trade_in.coverage_probe). Разбор #2894
показал, чем кончается вторая копия когорты: проба отвечает «данные есть» там,
где платный расчёт видит ноль. Публичный ответ переиспользует
CoverageProbeResponse — на нём уже стоит гейт «ни одного price-подобного поля».

## Бюджеты

Общего 300/60с мало: /suggest через DaData-тир — платный внешний вызов, абуз
стоит денег. Свои per-IP окна: 40/мин на подсказки (человек с debounce'ом
тратит единицы на адрес), 15/мин на пробу.

## Проверено

11 тестов, из них структурные: набор ручек под /api/public проверяется на
РАВЕНСТВО (третья, добавленная без правки теста, роняет сборку) и сверяется с
rbac._PUBLIC_PATHS в обе стороны — чтобы не осталось открытого пути-призрака.
Рядом висит закрытый маршрут-двойник: без него «аноним получает 200» одинаково
зелёный и когда исключение точечное, и когда auth-гейт снят целиком.

Мутации:
  убрать пути из rbac._PUBLIC_PATHS → 7 failed / 4 passed
  снять бюджет с /coverage         → 2 failed / 9 passed
  откат                            → 11 passed

Плюс 72 passed на связке rbac + coverage + version, `caddy validate` = Valid
configuration, ruff чист.

Смоук периметра дополнен парой, которую нельзя разделять: публичные ручки
отвечают 200 анонимно И /trade-in/api/v1/* на этом домене по-прежнему 404.
Зелёная только первая проверка = API открыт целиком, а тест этого не заметил.

Refs #2894, #2895
2026-08-15 22:32:53 +03:00
8423af5dd5 feat(tradein): версионирование продукта — единый источник, подвал, PDF, /versions (#2824)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Successful in 36s
Deploy Trade-In / build-frontend (push) Successful in 2m23s
Deploy Trade-In / test (push) Successful in 3m14s
Deploy Trade-In / build-backend (push) Successful in 4m19s
Deploy Trade-In / deploy (push) Successful in 1m44s
2026-08-10 16:00:54 +00:00
ac0c472faf fix(tradein/auth): убрать устаревшее «Caddy basic_auth required» из 401 (#2573) (#2649)
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
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 2m38s
Deploy Trade-In / build-backend (push) Successful in 58s
Deploy Trade-In / deploy (push) Successful in 1m6s
2026-08-05 08:45:57 +00:00
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, тот же красный).
2026-08-01 02:50:14 +03:00
bot-backend
40fc94ee91 feat(tradein/support): чат поддержки без входа — экран логина и «доступа нет»
All checks were successful
CI / changes (pull_request) Successful in 12s
CI Trade-In / changes (pull_request) Successful in 12s
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 1m42s
CI Trade-In / backend-tests (pull_request) Successful in 2m31s
После cutover'а на свою авторизацию (#2558) единственным каналом в поддержку
остался чат ЗА логином, а самая частая причина писать в поддержку — как раз
«не могу войти». 2026-07-31 это выстрелило: «Практика» весь день билась в форму
входа (5 неудачных попыток с трёх разных IP, ни одной успешной) и сообщить об
этом из продукта не могла ничем — на /login не было ни чата, ни контакта.

Backend — 4 ручки /api/v1/trade-in/support/anon/* (public в rbac_guard):
- Идентичность анонима — opaque-токен в httpOnly+Secure куке; тред живёт в тех
  же web_support_threads под ключом `anon:<token>`. Двоеточие делает коллизию с
  реальным логином структурно невозможной (CHECK миграции 193 разрешает только
  `^[A-Za-z0-9._-]{3,64}$`) — аноним не может попасть в чужой тред.
- Изоляция та же, что у авторизованной ветки: thread_id снаружи не принимается
  ни в каком виде, тред резолвится ИСКЛЮЧИТЕЛЬНО из куки.
- Форма куки валидируется — мусор из браузера не становится ключом треда.
- В Telegram-топик уходит не токен (это bearer треда), а `anon-<6 hex sha256>`;
  зеркало помечено «[С САЙТА · БЕЗ ВХОДА]» — оператору важно, что аккаунта нет.
- Анти-абуз: два бюджета — per-token (12/мин) и per-IP (10/10мин). Второй ловит
  обход ротацией куки, без него публичная ручка записи в общий топик беззащитна.
- Кука и запись в БД — только после успешного sendMessage (порядок операций H1),
  неудачная отправка не закрепляет за посетителем пустой тред.

Frontend:
- `SupportScope = "auth" | "anon"` в useSupportChat: scope выбирает базовый путь
  и входит в ключ кэша (иначе после логина в панели висела бы переписка анонима).
  Дефолт "auth" — существующие места монтирования не меняются.
- `AnonSupportWidget` монтируется на /login и в NoAccessScreen — обе точки тупики,
  из которых пользователю больше некуда идти. На /login добавлена подсказка.

Ответы оператора маршрутизируются без изменений в bridge.py: реплай резолвится
по topic_message_id → thread_id, кто автор треда — там неважно.

Тесты: 13 новых на анонимную ветку + 2 на границу public/authed в rbac_guard.
62 passed (test_support + test_rbac).
2026-07-31 16:04:51 +03:00
bot-backend
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 без изменений.
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
ca46411346 fix(tradein/tests): тесты авторизации проверяют настоящий guard + реальный рендер PDF (#2541)
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 5m2s
Deploy Trade-In / build-backend (push) Successful in 5m38s
Deploy Trade-In / deploy (push) Successful in 1m4s
2026-07-26 23:04:43 +00:00