"""校验 RBAC 权限号段的一致性(只读,不连数据库)。 ## 为什么需要它 权限码的**定义源**是 `tools/seed_test_rbac.py` 的 `PERMISSIONS` 常量 —— 因为那个脚本是 **DELETE 重建**语义: ``` DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099 ``` 任何没并进 `PERMISSIONS` 的权限码,重建一次就没了,表现是"接口突然 403", 而且**没有任何报错线索**。 2026-09-12 出过一次真实事故:库里有一批 `9020-9035` 是 `grant_advisor_role.py` 用**旧号段** 建的,而投顾线把 `9020-9034` 写进了种子 —— 两套 **id→code 映射不同**。种子的清理只清 `role_id 9001-9003` 的角色绑定,`advisor`(9004) 的绑定**不在清理范围内**,于是重建后 advisor 会静默拿到**语义完全错误**的权限组合(例如 id 9020 从 `asset-allocation:generate:self` 变成 `investment-goal:write:self`),全程不报错。 本脚本把这条约束变成可自动检查的五条: 1. 种子内 id 不重复; 2. 每个 `grant_*.py` 声明的 `(id, code)` 都能在种子里找到**完全一致**的一条; 3. `CUSTOMER_PERMISSIONS` 引用的 id 都存在; 4. 各 `grant_*.py` 之间不抢同一个 id; 5. 每行 `PERMISSIONS` 的字段数与 `data_scope` 取值合法(见 `_scope_findings`)。 `tests/unit/tools/test_rbac_seed_consistency.py` 会调用它,所以这五条是纳入门禁的。 用法:python tools/check_rbac_seed_consistency.py """ from __future__ import annotations import importlib.util import sys from pathlib import Path from types import ModuleType PROJECT_ROOT = Path(__file__).resolve().parents[1] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) #: 参与校验的 grant 脚本(都只做「按 code 判重」的幂等补齐)。 #: 原先还有 `tools/grant_customer_service_phase2_permissions.py`(客服二期);客服 Agent 模块 #: 已移除、该脚本一并删除,故不在此列——**重建客服模块时须把它加回来**,否则客服侧的 #: 权限码会脱离「grant 脚本 ↔ RBAC 种子」的一致性校验。 GRANT_SCRIPTS: tuple[tuple[str, str, str], ...] = ( ("tools/grant_risk_permissions.py", "PERMISSIONS", "风控"), ) def _load(name: str, relative: str) -> ModuleType: spec = importlib.util.spec_from_file_location(name, PROJECT_ROOT / relative) if spec is None or spec.loader is None: raise RuntimeError(f"无法加载 {relative}") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module def collect_findings() -> list[str]: """返回问题列表;空列表表示一致。""" problems: list[str] = [] seed = _load("_rbac_seed", "tools/seed_test_rbac.py") seed_map: dict[int, str] = {} for row in seed.PERMISSIONS: permission_id, code = int(row[0]), str(row[1]) if permission_id in seed_map: problems.append( f"种子里 id {permission_id} 重复:{seed_map[permission_id]} 与 {code}" ) seed_map[permission_id] = code claimed: dict[int, str] = {} for relative, attribute, label in GRANT_SCRIPTS: module = _load(f"_rbac_{attribute.lower()}", relative) for row in getattr(module, attribute): permission_id, code = int(row[0]), str(row[1]) seeded = seed_map.get(permission_id) if seeded is None: problems.append(f"{label}({relative}):id {permission_id}({code})不在种子里") elif seeded != code: problems.append( f"{label}({relative}):id {permission_id} 与种子冲突 —— " f"种子={seeded},脚本={code}" ) if permission_id in claimed and claimed[permission_id] != code: problems.append( f"id {permission_id} 被两个脚本抢用:{claimed[permission_id]} 与 {code}" ) claimed[permission_id] = code for permission_id in seed.CUSTOMER_PERMISSIONS: if int(permission_id) not in seed_map: problems.append(f"CUSTOMER_PERMISSIONS 引用了不存在的 id {permission_id}") for permission_id in seed.RISK_PERMISSIONS: if int(permission_id) not in seed_map: problems.append(f"RISK_PERMISSIONS 引用了不存在的 id {permission_id}") required_risk_codes = { "risk:alert:read", "risk:alert:write", "risk:alert:scan", "risk:report:mail", } risk_codes = { seed_map[int(permission_id)] for permission_id in seed.RISK_PERMISSIONS if int(permission_id) in seed_map } missing_risk_codes = required_risk_codes - risk_codes if missing_risk_codes: problems.append( "RISK_PERMISSIONS 缺少风控权限码:" + ", ".join(sorted(missing_risk_codes)) ) problems.extend(_scope_findings(seed)) return problems #: `IdentityRepository.load_context` 只收集这三种 `data_scope` 的权限,其余**整条丢弃**。 VALID_DATA_SCOPES = frozenset({"self", "own_customers", "all"}) def _scope_findings(seed: ModuleType) -> list[str]: """第 5 条:每行 `PERMISSIONS` 的字段数与 `data_scope` 取值都必须合法。 ## 为什么值得单独守 `PERMISSIONS` 的行形状是 `(id, 权限码, resource, action, data_scope)`。 2026-09-13 出过一次事故:交易那 4 条写成了 `(9061, "trade:order:create", "trade", "order", "create")` —— 「resource, action」多写了一段, 于是 action 落进了 `data_scope`。 它**不报错、也不放宽**,而是**静默失效**:`load_context` 里 `if row["permission_code"] and row["data_scope"] in rank` 会把整条权限丢掉, 客户在库里"明明有"这四个权限,下单/委托/成交却全部 `403 AGENT_PERMISSION_DENIED`; 而同一批里 `account:read:self`、`holding:read:self` 的 scope 是 `self`, 照常 200 —— 所以现象特别像"只有交易坏了",几乎不会有人去怀疑权限行本身写错。 因为 `seed_test_rbac.py` 是 DELETE 重建语义,这个错误**每跑一次种子就重现一次**。 """ findings: list[str] = [] for index, row in enumerate(seed.PERMISSIONS, 1): if len(row) != 5: findings.append( f"种子 PERMISSIONS 第 {index} 行有 {len(row)} 个字段,应为 5 个" f"(id, 权限码, resource, action, data_scope):{row!r}" ) continue permission_id, code, scope = int(row[0]), str(row[1]), str(row[4]) if scope not in VALID_DATA_SCOPES: findings.append( f"种子 id {permission_id}({code})的 data_scope={scope!r} 非法:" f"只能是 {sorted(VALID_DATA_SCOPES)} 之一 —— " "否则该权限会被 IdentityRepository.load_context 静默丢弃,接口一律 403" ) return findings def main() -> int: if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr] seed = _load("_rbac_seed_report", "tools/seed_test_rbac.py") print(f"种子权限 {len(seed.PERMISSIONS)} 条;grant 脚本 {len(GRANT_SCRIPTS)} 个") problems = collect_findings() if problems: print("\n[失败] 号段不一致:") for item in problems: print(f" - {item}") print( "\n处置:权限码的定义以 tools/seed_test_rbac.py 的 PERMISSIONS 为准;" "grant 脚本只补种子里缺的,且 id 必须与种子一致。" ) return 1 print("一致性检查通过:种子内 id 唯一,各 grant 脚本与种子逐条一致。") return 0 if __name__ == "__main__": sys.exit(main())