chore(ci+rbac): auth/** в paths-фильтры + «Поиск домов» только у админа
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Successful in 1m56s
CI / frontend-tests (pull_request) Successful in 2m14s
CI Trade-In / backend-tests (pull_request) Successful in 3m1s
CI / openapi-codegen-check (pull_request) Successful in 2m15s
CI / backend-tests (pull_request) Successful in 15m48s

Две связанные вещи, обе — по решению владельца продукта.

1. auth/** в paths-фильтры обоих CI (ci.yml, ci-tradein.yml).
   auth/roles.yaml — общий RBAC-конфиг двух стеков, но лежит в корне репы и не
   попадал НИ В ОДИН фильтр: правка ролей/пользователей не запускала ни backend-,
   ни tradein-сьют. Так 2026-07-30 в main уехал красный test_get_role_known_users
   (user2 переведён в expired, тест ждал pilot) — обнаружен только вручную и
   починен в PR #2587. Теперь правка roles.yaml гоняет оба гейта.

2. «Поиск домов» (/trade-in/sale-share) — ТЕСТОВЫЙ продукт, доступ только у
   админа. Раньше он был закрыт от клиентских ролей (employee/manager/pilot), но
   оставался открыт внутренней роли analyst. «Только у админа» включает и
   внутренние роли → analyst добавлен в deny по sale-share.

   Асимметрия с «Кэшем» намеренная и запиннена тестом: Кэш — не продукт, а
   диагностика кэшей/скраперов, т.е. ровно тот инструмент, ради которого роль
   analyst заведена; ему он оставлен.

Замеры после правки (реальный is_path_allowed поверх roles.yaml):
  роль     | Поиск домов | Кэш  | ядро продукта
  admin    | True        | True | True
  analyst  | False       | True | True
  pilot    | False       | False| True

Тест test_yaml_roles_deliberately_outside_client_deny переписан: пиннит ОБЕ
стороны асимметрии, а не только «analyst видит всё». Набор внутренних путей
разрезан на _SALE_SHARE_PATHS / _CACHE_TOOL_PATHS с assert'ом, что разрез
покрывает исходный набор целиком — иначе новый путь добавят и забудут отнести
к продукту, оставив analyst непроверенным.

Заодно поправлены устаревшие комментарии «Доступ: pilot + admin» в Caddyfile
(vanity-редирект gendsgn.ru/sale-share) и в докстринге самой страницы.

Тесты: 77 passed (tradein rbac/auth_session/auth_api) + 24 passed (site-finder).
tsc --noEmit + next build — зелёные. YAML обоих workflow провалидирован.
This commit is contained in:
bot-backend 2026-07-31 20:24:24 +03:00
parent fdf222cc5c
commit 762d1f68c6
6 changed files with 94 additions and 21 deletions

View file

@ -43,6 +43,13 @@ jobs:
# [tool.uv.workspace] меняют реальные зависимости → гейт обязан бежать.
- 'tradein-mvp/uv.lock'
- 'tradein-mvp/pyproject.toml'
# auth/roles.yaml — общий RBAC-конфиг обоих стеков, лежит В КОРНЕ
# репы и монтируется в tradein-backend (/app/auth/roles.yaml).
# tests/test_rbac.py читает именно его, поэтому правка ролей обязана
# гонять и этот гейт. Без строки правка roles.yaml не запускала НИ
# ОДИН сьют (та же дыра закрыта симметрично в ci.yml) — так на main
# уехал красный test_get_role_known_users (2026-07-30 → PR #2587).
- 'auth/**'
- '.forgejo/workflows/ci-tradein.yml'
frontend:
- 'tradein-mvp/frontend/**'

View file

@ -52,6 +52,14 @@ jobs:
backend:
- 'backend/**'
- 'data/sql/**'
# auth/roles.yaml — общий RBAC-конфиг ОБОИХ стеков (bind-mount в
# backend и в tradein-backend). Правка ролей/пользователей меняет
# поведение backend/tests/test_rbac.py, но сам файл лежит вне
# 'backend/**' → без этой строки сьют no-op'ился, и правка уезжала
# в main без единого прогона. Так и случилось 2026-07-30: user2
# переведён в expired, test_get_role_known_users стал красным и
# доехал до main незамеченным (починен в PR #2587).
- 'auth/**'
- '.forgejo/workflows/ci.yml'
frontend:
- 'frontend/**'

View file

@ -139,12 +139,19 @@ gendsgn.ru {
# /trade-in/sale-share, для которого теперь нет Caddy basic_auth (как и
# для остального /trade-in). Это НЕ делает страницу публичной: она всё
# ещё за собственной авторизацией trade-in — `RouteGuard` во фронте
# (`app/layout.tsx`, «Доступ: pilot + admin») и сессия для
# `/api/v1/buildings/sale-share*` на бэке; без валидной сессии юзер
# получит редирект на /login, а не контент. Смысл переноса — не открыть
# страницу всем, а убрать несогласованность: короткий URL не должен быть
# строже (Caddy basic_auth) целевого адреса, к которому и так уже нет
# (`app/layout.tsx`) и сессия для `/api/v1/buildings/sale-share*` на
# бэке; без валидной сессии юзер получит редирект на /login, а не
# контент. Смысл переноса — не открыть страницу всем, а убрать
# несогласованность: короткий URL не должен быть строже (Caddy
# basic_auth) целевого адреса, к которому и так уже нет
# basic_auth-барьера (только собственный login trade-in).
#
# ОБНОВЛЕНО 2026-07-31: доступ к разделу сузился с «pilot + admin» до
# ТОЛЬКО admin — «Поиск домов» признан тестовым продуктом, клиентам не
# показывается (deny в auth/roles.yaml для pilot и analyst + в
# DB_ROLE_PATHS для employee/manager). Сам редирект не трогаем: он ведёт
# на страницу, а гейт стоит на роли — для всех, кроме admin, короткий
# адрес приведёт на NoAccessScreen.
@saleshare path /sale-share /sale-share/
handle @saleshare {
redir /trade-in/sale-share permanent

View file

@ -81,15 +81,28 @@ roles:
# для любого role != "admin" → analyst авто-403 на admin-API без доп. кода.
# deny ниже драйвит фронтовый RouteGuard (deny_paths из /me) для UI-gating
# /admin/** страниц.
# NB: клиентский deny 2026-07-31 («Доля в продаже» / «Кэш», см. pilot выше)
# на analyst СОЗНАТЕЛЬНО не распространён — analyst внутренняя роль и оба
# раздела для неё рабочий инструмент. Это не забытая дыра.
# Клиентский deny 2026-07-31 (см. pilot выше) распространён на analyst
# ЧАСТИЧНО — асимметрия намеренная, не недосмотр:
# «Поиск домов» (/trade-in/sale-share + /api/v1/buildings/**) — ЗАКРЫТ.
# Решение владельца продукта 2026-07-31: это ТЕСТОВЫЙ продукт, доступ
# только у admin. «Только у админа» = включая внутренние роли, поэтому
# analyst тоже в deny.
# «Кэш» (/trade-in/cache + cache-stats) — ОСТАВЛЕН открытым: это не
# продукт, а диагностика состояния кэшей/скраперов, т.е. ровно тот
# рабочий инструмент, ради которого роль analyst и заведена
# («видит ВСЁ кроме admin-управления», см. выше).
# Обе стороны этой асимметрии запиннены тестом
# tradein-mvp/backend/tests/test_rbac.py::test_yaml_roles_deliberately_outside_client_deny
# — если решение поменяется, тест упадёт и заставит обновить и его, и этот
# комментарий, а не тихо разойтись с реальностью.
paths:
- "/**"
deny:
- "/admin/**"
- "/api/v1/admin/**"
- "/trade-in/api/v1/admin/**"
- "/trade-in/sale-share/**"
- "/trade-in/api/v1/buildings/**"
expired:
# Пробный доступ закончился — нет доступа ни к чему. Аккаунт остаётся в
# caddy/users.caddy.snippet (basic_auth), чтобы дойти до фронта и увидеть

View file

@ -395,6 +395,27 @@ _INTERNAL_TOOL_PATHS = (
"/trade-in/api/v1/trade-in/cache-stats/",
)
# Разрез тех же путей по ПРОДУКТАМ — нужен для ролей, у которых доступ
# асимметричен (см. test_yaml_roles_deliberately_outside_client_deny).
# «Поиск домов» — тестовый продукт, admin-only (решение владельца 2026-07-31).
_SALE_SHARE_PATHS = (
"/trade-in/sale-share",
"/trade-in/api/v1/buildings/sale-share",
"/trade-in/api/v1/buildings/sale-share/summary",
"/trade-in/api/v1/buildings/123/listings",
)
# «Кэш» — не продукт, а диагностика состояния кэшей/скраперов.
_CACHE_TOOL_PATHS = (
"/trade-in/cache",
"/trade-in/api/v1/trade-in/cache-stats",
"/trade-in/api/v1/trade-in/cache-stats/",
)
# Разрез обязан покрывать исходный набор целиком — иначе новый внутренний путь
# добавят в _INTERNAL_TOOL_PATHS, забудут отнести к продукту, и роль analyst
# останется непроверенной на нём.
assert set(_SALE_SHARE_PATHS) | set(_CACHE_TOOL_PATHS) == set(_INTERNAL_TOOL_PATHS)
# Основной продукт — не должен быть задет deny выше.
_CORE_PRODUCT_PATHS = ("/trade-in/", "/trade-in/api/v1/trade-in/estimate")
@ -422,14 +443,29 @@ def test_yaml_roles_deliberately_outside_client_deny() -> None:
ровно тот класс рассинхрона, что уже случился с user2 (см.
backend/tests/test_rbac.py::test_get_role_known_users).
`analyst` внутренняя роль (paths "/**", deny только admin-управление);
решение не распространять на неё клиентский deny осознанное, а не забытое.
`analyst` (внутренняя роль, paths "/**") попадает под клиентский deny
ЧАСТИЧНО, и обе стороны асимметрии здесь запиннены намеренно:
- «Поиск домов» ЗАКРЫТ тестовый продукт, доступ только у admin
(решение владельца 2026-07-31; «только у админа» включает и внутренние
роли, поэтому analyst тоже в deny);
- «Кэш» ОТКРЫТ это не продукт, а диагностика кэшей/скраперов, ровно тот
инструмент, ради которого роль analyst и заведена.
Если решение поменяется упадёт этот тест, а не пользователь на проде.
"""
for path in _INTERNAL_TOOL_PATHS:
assert auth_mod.is_path_allowed("admin", path), f"admin lost access to {path}"
for path in _SALE_SHARE_PATHS:
assert not auth_mod.is_path_allowed("analyst", path), (
f"analyst не должен видеть «Поиск домов» ({path}) — тестовый продукт, "
f"admin-only; если решение изменилось, обнови тест И комментарий у роли "
f"analyst в auth/roles.yaml"
)
for path in _CACHE_TOOL_PATHS:
assert auth_mod.is_path_allowed("analyst", path), (
f"analyst lost access to {path} — если это намеренно, обнови этот тест "
f"и комментарий у роли analyst в auth/roles.yaml"
f"analyst потерял «Кэш» ({path}) — это его рабочий инструмент; если "
f"закрыли намеренно, обнови тест И комментарий у роли analyst"
)

View file

@ -5,16 +5,18 @@
* Порог % дома вторички, где доля квартир, выставленных на продажу, порога.
* Сигнал для девелопера: расселение / инвест-выход / проблемный дом.
*
* Доступ: ТОЛЬКО admin (с 2026-07-31). Раздел признан внутренним инструментом
* клиентские аккаунты его не видят: явный deny `/trade-in/sale-share/**` +
* `/trade-in/api/v1/buildings/**` заведён для DB-ролей employee/manager
* (`app/services/auth_session.py: DB_ROLE_PATHS`) и для legacy `pilot`
* (`auth/roles.yaml`). Роль `analyst` сознательно не в deny внутренняя.
* Доступ: ТОЛЬКО admin (с 2026-07-31). Это ТЕСТОВЫЙ продукт решение владельца
* продукта: клиентам не показывать, доступ только у админа. Явный deny
* `/trade-in/sale-share/**` + `/trade-in/api/v1/buildings/**` заведён для
* DB-ролей employee/manager (`app/services/auth_session.py: DB_ROLE_PATHS`),
* для legacy `pilot` И для внутренней `analyst` (`auth/roles.yaml`) «только у
* админа» включает и внутренние роли. Ср. соседний «Кэш»: он закрыт от клиентов,
* но analyst'у оставлен, т.к. это диагностика, а не продукт.
*
* NB: короткий адрес `gendsgn.ru/sale-share` (301 сюда, см. Caddyfile) после
* этого ведёт на NoAccessScreen для всех, кроме admin. Если раздел снова станет
* продаваемым продуктом, одним снятием deny не обойтись: нужен per-account
* carve-out сейчас скоуп только ролевой, выдать его отдельному клиенту нечем.
* NB: короткий адрес `gendsgn.ru/sale-share` (301 сюда, см. Caddyfile) ведёт
* на NoAccessScreen для всех, кроме admin. Если раздел когда-нибудь станет
* продаваемым, одним снятием deny не обойтись: нужен per-account carve-out
* скоуп сейчас только ролевой, выдать раздел отдельному клиенту нечем.
*/
import { useCallback, useMemo, useRef, useState } from "react";
import dynamic from "next/dynamic";