feat(rbac): pilot scope → только /trade-in/** #587
5 changed files with 82 additions and 59 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue