diff --git a/.forgejo/workflows/ci-tradein.yml b/.forgejo/workflows/ci-tradein.yml index 11396792..4a0bc79c 100644 --- a/.forgejo/workflows/ci-tradein.yml +++ b/.forgejo/workflows/ci-tradein.yml @@ -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/**' diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 48a47220..a261bc30 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -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/**' diff --git a/Caddyfile b/Caddyfile index 84f137de..ad74ba62 100644 --- a/Caddyfile +++ b/Caddyfile @@ -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 diff --git a/auth/roles.yaml b/auth/roles.yaml index 12131be7..1273eb38 100644 --- a/auth/roles.yaml +++ b/auth/roles.yaml @@ -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), чтобы дойти до фронта и увидеть diff --git a/tradein-mvp/backend/tests/test_rbac.py b/tradein-mvp/backend/tests/test_rbac.py index 8c8b64ab..ea0837dd 100644 --- a/tradein-mvp/backend/tests/test_rbac.py +++ b/tradein-mvp/backend/tests/test_rbac.py @@ -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" ) diff --git a/tradein-mvp/frontend/src/app/sale-share/page.tsx b/tradein-mvp/frontend/src/app/sale-share/page.tsx index e0c1fa35..e4db0bf5 100644 --- a/tradein-mvp/frontend/src/app/sale-share/page.tsx +++ b/tradein-mvp/frontend/src/app/sale-share/page.tsx @@ -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";