From c08e4d7811af9a334d539ec9266302f4dd00dcd7 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Tue, 26 May 2026 07:40:44 +0000 Subject: [PATCH] =?UTF-8?q?feat(rbac):=20pilot=20scope=20=E2=86=92=20?= =?UTF-8?q?=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20/trade-in/**=20(#587)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decision 2026-05-26: pilot не видит landing/analytics/site-finder/concept, только Trade-In. --- auth/roles.yaml | 16 +++--- backend/tests/test_rbac.py | 28 +++++++--- caddy/users.caddy.snippet | 22 ++++---- .../src/lib/__tests__/isPathAllowed.test.ts | 52 ++++++++----------- tradein-mvp/backend/tests/test_rbac.py | 23 +++++--- 5 files changed, 82 insertions(+), 59 deletions(-) diff --git a/auth/roles.yaml b/auth/roles.yaml index 71ee8e37..7747012b 100644 --- a/auth/roles.yaml +++ b/auth/roles.yaml @@ -17,7 +17,9 @@ # — Caddy basic_auth sets X-Authenticated-User from {http.auth.user.id}, and # unknown users hit /me with 403 ("user not in roles config"). # -# Last updated: 2026-05-26 (PR A — RBAC). +# Last updated: 2026-05-26 (pilot scope narrowed to /trade-in/** only — decision +# 2026-05-26: pilot аккаунты пилот-программы видят ТОЛЬКО раздел Trade-In; +# Analytics / Site Finder / Concept / landing — admin-only). roles: admin: @@ -25,13 +27,13 @@ roles: - "/**" deny: [] pilot: + # Pilot имеет доступ ТОЛЬКО к разделу Trade-In (оценка вторички). + # Landing (/), analytics, site-finder, concept, остальной /api/v1/* — закрыты. + # Backend middleware всё равно пропускает known users на все non-admin paths + # (path-level enforcement делает frontend RouteGuard через `allowed_paths`), + # но для UX/security принципов keep deny список explicit. paths: - - "/" - - "/analytics/**" - - "/site-finder/**" - "/trade-in/**" - - "/concept/**" - - "/api/v1/**" - "/trade-in/api/v1/**" deny: - "/admin/**" @@ -51,3 +53,5 @@ users: user8: pilot user9: pilot user10: pilot + admintest: admin # temp QA 2026-05-26 + pilottest: pilot # temp QA 2026-05-26 diff --git a/backend/tests/test_rbac.py b/backend/tests/test_rbac.py index ca92b41b..ab957607 100644 --- a/backend/tests/test_rbac.py +++ b/backend/tests/test_rbac.py @@ -131,18 +131,25 @@ def test_is_path_allowed_admin_everywhere() -> None: assert auth_mod.is_path_allowed("admin", "/analytics/dashboard") -def test_is_path_allowed_pilot_excludes_admin() -> None: - # Pilot allowed paths - assert auth_mod.is_path_allowed("pilot", "/") - assert auth_mod.is_path_allowed("pilot", "/analytics/dashboard") - assert auth_mod.is_path_allowed("pilot", "/site-finder/123") +def test_is_path_allowed_pilot_only_tradein() -> None: + """Pilot имеет доступ ТОЛЬКО к /trade-in/** (decision 2026-05-26). + Landing, analytics, site-finder, concept, остальной /api/v1/* — закрыты.""" + # Pilot ALLOWED — только trade-in assert auth_mod.is_path_allowed("pilot", "/trade-in") + assert auth_mod.is_path_allowed("pilot", "/trade-in/") assert auth_mod.is_path_allowed("pilot", "/trade-in/123") - assert auth_mod.is_path_allowed("pilot", "/concept/abc") - assert auth_mod.is_path_allowed("pilot", "/api/v1/parcels/123") assert auth_mod.is_path_allowed("pilot", "/trade-in/api/v1/search") + assert auth_mod.is_path_allowed("pilot", "/trade-in/api/v1/me") - # Pilot DENIED — admin paths + # Pilot DENIED — landing + остальные разделы (admin-only) + assert not auth_mod.is_path_allowed("pilot", "/") + assert not auth_mod.is_path_allowed("pilot", "/analytics/dashboard") + assert not auth_mod.is_path_allowed("pilot", "/site-finder/123") + assert not auth_mod.is_path_allowed("pilot", "/concept/abc") + assert not auth_mod.is_path_allowed("pilot", "/api/v1/parcels/123") + assert not auth_mod.is_path_allowed("pilot", "/api/v1/analytics/dashboard") + + # Pilot DENIED — admin paths (explicit deny + not in allowed) assert not auth_mod.is_path_allowed("pilot", "/admin") assert not auth_mod.is_path_allowed("pilot", "/admin/jobs") assert not auth_mod.is_path_allowed("pilot", "/admin/scrape/runs/42") @@ -173,6 +180,11 @@ def test_get_user_scope_pilot() -> None: scope = auth_mod.get_user_scope("kopylov") assert scope["username"] == "kopylov" assert scope["role"] == "pilot" + # Pilot allowed только /trade-in/** (decision 2026-05-26). + assert "/trade-in/**" in scope["allowed_paths"] + assert "/trade-in/api/v1/**" in scope["allowed_paths"] + assert "/" not in scope["allowed_paths"] + assert "/analytics/**" not in scope["allowed_paths"] assert "/admin/**" in scope["deny_paths"] assert "/api/v1/admin/**" in scope["deny_paths"] diff --git a/caddy/users.caddy.snippet b/caddy/users.caddy.snippet index d66a0ebf..d6c6cda5 100644 --- a/caddy/users.caddy.snippet +++ b/caddy/users.caddy.snippet @@ -9,15 +9,17 @@ basic_auth bcrypt "GenDesign Pilot" { admin JDJhJDE0JHVXRGpmWFBMMmNZd1duQUFJVWFkRi5RVy4xbHBVL3BPaTF6dFBvbUJ5enZvaXQ3bnZ0eGU2 # internal master (qa-tester + health checks, 2026-05-23) - user1 JDJhJDE0JGVHLi5XYUtnUnI5TTUweDBYNGpxQXV5OTNtWFlkYkZQRzJXVzlhSWxRMnhFN25vVDhleE11 # TBD (2026-05-23) - user2 JDJhJDE0JHl1Vk1HL1ZJQlY5R0FJQndtSzRBcU8wQ0kxUjdicXpHRldBR2JLQmkvUlJ6aWp0Z0EvMjYu # TBD (2026-05-23) - user3 JDJhJDE0JGFOeEwwdnFaUjZiWWpkckVkWFdvT2VMVTkubWZrMVBtQlNmcmg4ZS9qU01OYndPVXRzT1Iy # TBD (2026-05-23) - user4 JDJhJDE0JDJWbkY0YWNIRGhCMlFGekhFM2tVSHVxY0JEWm9UOVNqRTBMbGYzbERHcE5sQjF3Znd6QkNh # TBD (2026-05-23) - user5 JDJhJDE0JDAzTEhkd25STjdUTUtUQkdaREdkSU9mdmlVWEI0d1NxTTRkQnhydklDLk5kT3c2MGdOZnA2 # TBD (2026-05-23) - user6 JDJhJDE0JC9vZnZaUU5TVkNUNnRiU3F3QjhYTXVCb2xaSWlzb3U5aUVtY29jSlRyMExOVGdpOHhYc3ky # TBD (2026-05-23) - user7 JDJhJDE0JFlFSDJRV1hsb0kyUmYuMjdMd1FuTHVObC9yQUNNQy9WRGZWMnlUc2wuRUFDOEV6SEJrbkou # TBD (2026-05-23) - user8 JDJhJDE0JFd0alo2VmZYWDlCcHFqellTWC9RSi44WVhQUi5hVFk5djd4emlwcWVvMDExcEU1MVpDL1Yu # TBD (2026-05-23) - user9 JDJhJDE0JHRPZzVEd2ZKaldUWXFSUzI5c3RHTE9NVnp1amRFVkVMTHRFMGF2eUgzbzk2SEJvekdLQ3ZT # TBD (2026-05-23) - user10 JDJhJDE0JEo1QVRUTk1wejgucUI1ZFAzTEE0WWVxQk55c29hTGlHZ243RVhUc2F1aldIc0pSSHVIOUxL # TBD (2026-05-23) + user1 JDJhJDE0JFdJR2YxSkc2RU90MTVKbzR5R2JOb3VzQVJPelZ6N1BoOGlrZ083WlRKSnY5QlhGSml2ekJD # pilot slot (regenerated 2026-05-26) + user2 JDJhJDE0JHA1ckY0Qi9ESVlhZTlIOGRDY09ZVXVuZlVYV2tQMHBOcnl6MlByQXdWbUJJWS53MU8uZ2hL # pilot slot (regenerated 2026-05-26) + user3 JDJhJDE0JHEuSklxVjlZVk96OXBsWmouV0E1anVzUFp4VVFWTW5aQWEvQllYWjFsVE82UHVPTXlnUndp # pilot slot (regenerated 2026-05-26) + user4 JDJhJDE0JG5WdFRxOUZDUVdBeURzR1ROZS5jMHVsamlNS0Y5ci95bVJCZnBkUHJoekZZM1hESmltR2su # pilot slot (regenerated 2026-05-26) + user5 JDJhJDE0JC8wbi5IcmNLOS4yMTdKbHpLa24yNnVCZmhmbUNteGpza0pVbDFYejlJQkhSMS96Y3pXaDl1 # pilot slot (regenerated 2026-05-26) + user6 JDJhJDE0JFZvSkxhR2RzMnB4cUVHUW9wSlRNUU8uMHAya1hiRDJVQ0RoaTd1ckdicFpxNnh0VHFCSEJH # pilot slot (regenerated 2026-05-26) + user7 JDJhJDE0JHJxVnFMdG1ZazFlQlRreVJqQjU1M2VVbGtBa0xGMzVXS0YzQWhTb2t6dGdVMGxuckE5WmhX # pilot slot (regenerated 2026-05-26) + user8 JDJhJDE0JDVxNEE4RVRVWi4yUkY2OG9KR2Z4MU8xYWVDYTdkTDNVckJGZHBZM3c5VjlqS3ZyNEFtcWVl # pilot slot (regenerated 2026-05-26) + user9 JDJhJDE0JGwuaDl1U2JkT1dOWDguVXhJMFozZk9Ec09NVmxyM2J2VVdxdlFzdktXWVNkZDVBMVBuY3Z5 # pilot slot (regenerated 2026-05-26) + user10 JDJhJDE0JEN4QlV2Z3Q3c2VqMU92OFV4YUo3cWVUUXJkRGExVU9RL3BYNzUweXhXZDdhdGh1MlFwcnN5 # pilot slot (regenerated 2026-05-26) kopylov JDJhJDE0JExUald3alVMbS5RdVV0TTRTcGk0cE9YeHNXS2E1QU5rTFp2TnFFQzdmWWNFV3djRVAzOUdH # Копылов (2026-05-23) + admintest JDJhJDE0JHB6TDV6ZWZFTmZuTTd0S2I0Uy8vZ3VlL2w5bEpKbjEwTGVYVTd2bkRhNW5qbGlYTlhvVkxT # QA test admin role 2026-05-26 + pilottest JDJhJDE0JFQwTW9UV1I0WHUwek5qeFR5SWxTQy4veFFFUzlvL1VoMHFycnJDTkU5czRoVXdwNXRKM2ph # QA test pilot role 2026-05-26 } diff --git a/frontend/src/lib/__tests__/isPathAllowed.test.ts b/frontend/src/lib/__tests__/isPathAllowed.test.ts index e0ce41be..344b0b16 100644 --- a/frontend/src/lib/__tests__/isPathAllowed.test.ts +++ b/frontend/src/lib/__tests__/isPathAllowed.test.ts @@ -5,28 +5,18 @@ * порт здесь, иначе UI начнёт врать про доступы (показывать ссылки, которые * backend заблокирует, или наоборот скрывать разрешённые). * - * Pairs мирорят `auth/roles.yaml`: - * admin: paths=["/**"] deny=[] - * pilot: paths=["/", "/analytics/**", "/site-finder/**", - * "/trade-in/**", "/concept/**", "/api/v1/**", - * "/trade-in/api/v1/**"] - * deny=["/admin/**", "/api/v1/admin/**", - * "/trade-in/api/v1/admin/**"] + * Pairs мирорят `auth/roles.yaml` (обновлено 2026-05-26 — pilot scope сужен + * до /trade-in/** only): + * admin: paths=["/**"] deny=[] + * pilot: paths=["/trade-in/**", "/trade-in/api/v1/**"] + * deny=["/admin/**", "/api/v1/admin/**", "/trade-in/api/v1/admin/**"] */ import { isPathAllowed } from "../isPathAllowed"; const ADMIN_PATHS = ["/**"] as const; const ADMIN_DENY = [] as const; -const PILOT_PATHS = [ - "/", - "/analytics/**", - "/site-finder/**", - "/trade-in/**", - "/concept/**", - "/api/v1/**", - "/trade-in/api/v1/**", -] as const; +const PILOT_PATHS = ["/trade-in/**", "/trade-in/api/v1/**"] as const; const PILOT_DENY = [ "/admin/**", "/api/v1/admin/**", @@ -48,24 +38,29 @@ describe("isPathAllowed — admin everywhere", () => { }); }); -describe("isPathAllowed — pilot allowed paths", () => { +describe("isPathAllowed — pilot allowed paths (только trade-in)", () => { const allowed: string[] = [ - "/", - "/analytics/dashboard", - "/site-finder/123", "/trade-in", + "/trade-in/", "/trade-in/123", - "/concept/abc", - "/api/v1/parcels/123", "/trade-in/api/v1/search", + "/trade-in/api/v1/me", ]; it.each(allowed)("allows %s", (path) => { expect(isPathAllowed(PILOT_PATHS, PILOT_DENY, path)).toBe(true); }); }); -describe("isPathAllowed — pilot denied paths", () => { +describe("isPathAllowed — pilot denied paths (landing + admin + остальное)", () => { const denied: string[] = [ + // Landing + non-tradein разделы — теперь denied (decision 2026-05-26) + "/", + "/analytics/dashboard", + "/site-finder/123", + "/concept/abc", + "/api/v1/parcels/123", + "/api/v1/analytics/dashboard", + // Admin paths (explicit deny + not in allowed) "/admin", "/admin/jobs", "/admin/scrape/runs/42", @@ -78,13 +73,12 @@ describe("isPathAllowed — pilot denied paths", () => { }); }); -describe("isPathAllowed — out-of-scope paths", () => { - it("denies path not in allowed list", () => { +describe("isPathAllowed — edge cases", () => { + it("denies path not matching any rule", () => { expect(isPathAllowed(PILOT_PATHS, PILOT_DENY, "/unknown")).toBe(false); }); - it("denies path with prefix-only match (no `/foo/**` rule)", () => { - // Pilot не имеет `/concept` (только `/concept/**`); но `/concept/**` после - // подстановки `(?:/.*)?` матчит и bare `/concept` — backend ведёт себя так же. - expect(isPathAllowed(PILOT_PATHS, PILOT_DENY, "/concept")).toBe(true); + it("matches bare /trade-in via /trade-in/** glob (no trailing /)", () => { + // Backend glob `/foo/**` после подстановки `(?:/.*)?` матчит и bare `/foo`. + expect(isPathAllowed(PILOT_PATHS, PILOT_DENY, "/trade-in")).toBe(true); }); }); diff --git a/tradein-mvp/backend/tests/test_rbac.py b/tradein-mvp/backend/tests/test_rbac.py index 170911d0..f8164098 100644 --- a/tradein-mvp/backend/tests/test_rbac.py +++ b/tradein-mvp/backend/tests/test_rbac.py @@ -128,16 +128,22 @@ def test_is_path_allowed_admin_everywhere() -> None: assert auth_mod.is_path_allowed("admin", "/analytics/dashboard") -def test_is_path_allowed_pilot_excludes_admin() -> None: - assert auth_mod.is_path_allowed("pilot", "/") - assert auth_mod.is_path_allowed("pilot", "/analytics/dashboard") - assert auth_mod.is_path_allowed("pilot", "/site-finder/123") +def test_is_path_allowed_pilot_only_tradein() -> None: + """Pilot имеет доступ ТОЛЬКО к /trade-in/** (decision 2026-05-26).""" + # Pilot ALLOWED — только trade-in assert auth_mod.is_path_allowed("pilot", "/trade-in") + assert auth_mod.is_path_allowed("pilot", "/trade-in/") assert auth_mod.is_path_allowed("pilot", "/trade-in/123") - assert auth_mod.is_path_allowed("pilot", "/concept/abc") - assert auth_mod.is_path_allowed("pilot", "/api/v1/parcels/123") assert auth_mod.is_path_allowed("pilot", "/trade-in/api/v1/search") + # Pilot DENIED — landing + остальные разделы (admin-only) + assert not auth_mod.is_path_allowed("pilot", "/") + assert not auth_mod.is_path_allowed("pilot", "/analytics/dashboard") + assert not auth_mod.is_path_allowed("pilot", "/site-finder/123") + assert not auth_mod.is_path_allowed("pilot", "/concept/abc") + assert not auth_mod.is_path_allowed("pilot", "/api/v1/parcels/123") + + # Pilot DENIED — admin paths assert not auth_mod.is_path_allowed("pilot", "/admin") assert not auth_mod.is_path_allowed("pilot", "/admin/jobs") assert not auth_mod.is_path_allowed("pilot", "/admin/scrape/runs/42") @@ -168,6 +174,11 @@ def test_get_user_scope_pilot() -> None: scope = auth_mod.get_user_scope("kopylov") assert scope["username"] == "kopylov" assert scope["role"] == "pilot" + # Pilot allowed только /trade-in/** (decision 2026-05-26). + assert "/trade-in/**" in scope["allowed_paths"] + assert "/trade-in/api/v1/**" in scope["allowed_paths"] + assert "/" not in scope["allowed_paths"] + assert "/analytics/**" not in scope["allowed_paths"] assert "/admin/**" in scope["deny_paths"] assert "/api/v1/admin/**" in scope["deny_paths"]