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:
2026-09-13 22:57:59 +08:00
parent 9cb0f6e474
commit 0b82053ca2
2 changed files with 58 additions and 7 deletions
+46 -3
View File
@@ -18,14 +18,15 @@ DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099
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。
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
"""
@@ -117,9 +118,51 @@ def collect_findings() -> list[str]:
+ ", ".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]
+12 -4
View File
@@ -144,11 +144,19 @@ PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = (
# `PERMISSIONS`,同时挂在 `CUSTOMER_PERMISSIONS` 让 customer 角色自带。
# 号段续 9060(避开 9047-9059 qyqy 风险/推广/探针/投资目标号段):与 9041-9046 客服二期间隔 1,避免与既有迁移/种子冲突。
(9060, "account:read:self", "account", "read", "self"),
(9061, "trade:order:create", "trade", "order", "create"),
(9062, "trade:order:read", "trade", "order", "read"),
(9063, "trade:order:cancel", "trade", "order", "cancel"),
# ⚠️ 第 5 个字段是 **data_scope**,只能取 `self` / `own_customers` / `all`。
# 这四条原先误写成 `trade, order, create` 这样的「resource, action, action」三段,
# 于是 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"),
(9065, "trade:txn:read", "trade", "txn", "read"),
(9065, "trade:txn:read", "trade", "txn", "self"),
)
# 客户:业务侧自助能力(自己的会话、反馈、转人工、自己的记忆画像)。