fix(rbac): 交易权限的 data_scope 写成了动作名,导致下单/成交静默 403
`tools/seed_test_rbac.py` 的 `PERMISSIONS` 行形状是
`(id, 权限码, resource, action, data_scope)`,而 T 段那四条写成了
`(9061, "trade:order:create", "trade", "order", "create")` ——
「resource, action」多写了一段,于是 action 落进了 `data_scope` 的位置。
## 后果是静默失效,不是放宽
`IdentityRepository.load_context` 只收集 `data_scope ∈ {self, own_customers, all}`
的权限,其余**整条丢弃**:
if row["permission_code"] and row["data_scope"] in rank:
于是客户在库里**明明有**这四个权限,`POST /api/v1/users/me/orders`、
`GET /api/v1/users/me/orders`、`GET /api/v1/users/me/transactions` 等 T 段端点
却全部返回 `403 AGENT_PERMISSION_DENIED`「缺少操作权限」。
而同一批里 `account:read:self`、`holding:read:self` 的 scope 是 `self`,照常 200 ——
现象特别像"只有交易坏了",几乎不会有人去怀疑**权限行本身写错了字段**。
因为 seed 是 `DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099` 的**重建**语义,
**每跑一次种子就重现一次**,而 `tools/seed_demo_data.py` 的第 1 步正是它。
## 修复与守卫
- 四条改为 `"self"`,并就地写清第 5 个字段是 `data_scope`、只能取三种值;
- `tools/check_rbac_seed_consistency.py` 增加**第 5 条检查**:每行字段数必须是 5,
且 `data_scope` 取值合法。已用模拟对象确认它能抓住事故写法
(非法 scope 报 2 条、字段数不足报 1 条、合法写法通过),
并由 `tests/unit/tools/test_rbac_seed_consistency.py` 纳入门禁。
修复后实测:库内非法 `data_scope` 归零(`self` 22 → 26);
`/users/me/{account/dashboard,holdings,transactions,orders}` 全部 200;
下单成交价 4.579(真实行情);e2e 冒烟 40/40;integration 106 passed。
This commit is contained in:
@@ -18,14 +18,15 @@ DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099
|
|||||||
advisor 会静默拿到**语义完全错误**的权限组合(例如 id 9020 从 `asset-allocation:generate:self`
|
advisor 会静默拿到**语义完全错误**的权限组合(例如 id 9020 从 `asset-allocation:generate:self`
|
||||||
变成 `investment-goal:write:self`),全程不报错。
|
变成 `investment-goal:write:self`),全程不报错。
|
||||||
|
|
||||||
本脚本把这条约束变成可自动检查的四条:
|
本脚本把这条约束变成可自动检查的五条:
|
||||||
|
|
||||||
1. 种子内 id 不重复;
|
1. 种子内 id 不重复;
|
||||||
2. 每个 `grant_*.py` 声明的 `(id, code)` 都能在种子里找到**完全一致**的一条;
|
2. 每个 `grant_*.py` 声明的 `(id, code)` 都能在种子里找到**完全一致**的一条;
|
||||||
3. `CUSTOMER_PERMISSIONS` 引用的 id 都存在;
|
3. `CUSTOMER_PERMISSIONS` 引用的 id 都存在;
|
||||||
4. 各 `grant_*.py` 之间不抢同一个 id。
|
4. 各 `grant_*.py` 之间不抢同一个 id;
|
||||||
|
5. 每行 `PERMISSIONS` 的字段数与 `data_scope` 取值合法(见 `_scope_findings`)。
|
||||||
|
|
||||||
`tests/unit/tools/test_rbac_seed_consistency.py` 会调用它,所以这四条是纳入门禁的。
|
`tests/unit/tools/test_rbac_seed_consistency.py` 会调用它,所以这五条是纳入门禁的。
|
||||||
|
|
||||||
用法:python tools/check_rbac_seed_consistency.py
|
用法:python tools/check_rbac_seed_consistency.py
|
||||||
"""
|
"""
|
||||||
@@ -117,9 +118,51 @@ def collect_findings() -> list[str]:
|
|||||||
+ ", ".join(sorted(missing_risk_codes))
|
+ ", ".join(sorted(missing_risk_codes))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
problems.extend(_scope_findings(seed))
|
||||||
|
|
||||||
return problems
|
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:
|
def main() -> int:
|
||||||
if hasattr(sys.stdout, "reconfigure"):
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]
|
sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]
|
||||||
|
|||||||
+12
-4
@@ -144,11 +144,19 @@ PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = (
|
|||||||
# `PERMISSIONS`,同时挂在 `CUSTOMER_PERMISSIONS` 让 customer 角色自带。
|
# `PERMISSIONS`,同时挂在 `CUSTOMER_PERMISSIONS` 让 customer 角色自带。
|
||||||
# 号段续 9060(避开 9047-9059 qyqy 风险/推广/探针/投资目标号段):与 9041-9046 客服二期间隔 1,避免与既有迁移/种子冲突。
|
# 号段续 9060(避开 9047-9059 qyqy 风险/推广/探针/投资目标号段):与 9041-9046 客服二期间隔 1,避免与既有迁移/种子冲突。
|
||||||
(9060, "account:read:self", "account", "read", "self"),
|
(9060, "account:read:self", "account", "read", "self"),
|
||||||
(9061, "trade:order:create", "trade", "order", "create"),
|
# ⚠️ 第 5 个字段是 **data_scope**,只能取 `self` / `own_customers` / `all`。
|
||||||
(9062, "trade:order:read", "trade", "order", "read"),
|
# 这四条原先误写成 `trade, order, create` 这样的「resource, action, action」三段,
|
||||||
(9063, "trade:order:cancel", "trade", "order", "cancel"),
|
# 于是 action 落进了 data_scope 位置(值为 'create'/'read'/'cancel')。
|
||||||
|
# 后果不是"宽松"而是**静默失效**:`IdentityRepository.load_context` 只收集
|
||||||
|
# data_scope 合法的权限,这四条会被整条丢掉 ⇒ 客户在**库里明明有**这四个权限,
|
||||||
|
# 下单/委托/成交却全部 `403 AGENT_PERMISSION_DENIED`,
|
||||||
|
# 而 T001/T006(`account:read:self`/`holding:read:self` 是 'self')照常 200,
|
||||||
|
# 所以现象特别像"只有交易坏了",很难联想到权限码本身写错。
|
||||||
|
(9061, "trade:order:create", "trade", "order", "self"),
|
||||||
|
(9062, "trade:order:read", "trade", "order", "self"),
|
||||||
|
(9063, "trade:order:cancel", "trade", "order", "self"),
|
||||||
(9064, "holding:read:self", "holding", "read", "self"),
|
(9064, "holding:read:self", "holding", "read", "self"),
|
||||||
(9065, "trade:txn:read", "trade", "txn", "read"),
|
(9065, "trade:txn:read", "trade", "txn", "self"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 客户:业务侧自助能力(自己的会话、反馈、转人工、自己的记忆画像)。
|
# 客户:业务侧自助能力(自己的会话、反馈、转人工、自己的记忆画像)。
|
||||||
|
|||||||
Reference in New Issue
Block a user