Files
group_fqcd_jr/tools/grant_risk_permissions.py
T
lzf_0626 deeee9bb01 fix(risk): 补齐风控缺失的三个 RBAC 权限——写操作与扫描原本全是死的
**发现**(由"给邮件端点加权限"这个任务引出来的):风控 service 层声明了四个权限码,
而 sys_permission 表里只有一个。

| 权限码 | 用途 | 原先状态 |
|---|---|---|
| risk:alert:read  | 查询 / 研判 / 日报 / 通知 | 已存在,正常 |
| risk:alert:write | 处置 / 证据归档 | **不存在** → 6 处调用全被拒 |
| risk:alert:scan  | 规则扫描 | **不存在** → 扫描端点调不了 |
| risk:report:mail | 日报邮件 | **不存在**(本次新增) |

失效表现是 ForbiddenAgentError,而**只读查询一切正常** —— 所以很容易以为"风控能用",
直到去点处置按钮才发现。这与 risk:alert:read 当初缺失是同一类问题,只是面更广。

**改动**:把早先那个只建一条权限的脚本改造成覆盖四个,并授权给 risk_operator 与 admin
(风控 Agent 的 allowed_roles 两个都声明了,只给其中一个会让另一个"声明了却用不了")。
data_scope 取 all —— 风控要处理全部客户的预警,且多个 service 按
context.data_scope == "all" 决定是否放行全量。脚本更名为 grant_risk_permissions.py。

**实测**(9002 risk_operator):
- POST /risk/alerts/scan → **200**「规则扫描完成」(原先必被拒)
- POST /risk/alerts/ALDEMO0002/acknowledgements → **409**「只有待处理预警可以确认接收」
  —— 不是 403,说明**权限已通过**、卡在业务状态(该预警是"调查中"),属正当拒绝
- 对照组:customer 调扫描 → **403 缺少操作权限**,边界未放松

顺带发现:这个 409 复用了错误码 RUN_NOT_CANCELLABLE,语义不符 —— 属 docs/25 里已记的一项。
2026-09-11 13:37:30 +08:00

122 lines
5.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""补齐风控模块需要的 RBAC 权限。
**背景(实测)**:风控 service 层用 `AuthorizationService.require` 声明了四个权限码,
但 `sys_permission` 表里**只有一个**(`risk:alert:read`,本脚本早先建的)。其余三个不存在,
于是对应功能**全部失败关闭**:
| 权限码 | 用途 | 缺失后果 |
|---|---|---|
| `risk:alert:read` | 预警查询 / 研判 / 日报 / 通知 | (已建,正常) |
| `risk:alert:write` | 预警处置 / 证据归档 | 6 处调用被拒:确认、关闭、升级、归档都做不了 |
| `risk:alert:scan` | 规则扫描 | 扫描端点调不了 |
| `risk:report:mail` | 发送日报邮件 | 本次新增;邮件默认关闭,但仍需授权 |
失效的表现是 `ForbiddenAgentError`,而**只读查询一切正常** —— 所以很容易以为"风控能用",
直到去点处置按钮才发现。这与 `risk:alert:read` 当初缺失是同一类问题,只是面更广。
**字段怎么填**(照现有数据推断,不是自创):
- 权限匹配实际用的是 `permission_code` 全串(`identity_repository.py:25-37`),
`resource` / `action` 只是元数据;
- 参照 `fund:quote:read`(`resource=fund`、`action=quote`):`resource` 取第一段、
`action` 取第二段;
- `data_scope` 取 `all`:风控要处理**全部客户**的预警,且 `risk_query_service.py:194`、
`risk_analysis_service.py:126`、`risk_evidence_archive_service.py:218`、
`risk_action_service.py:212` 都按 `context.data_scope == "all"` 决定是否放行全量数据。
**授权给谁**:`risk_operator` 与 `admin`。风控 Agent 的
`AgentDefinition.allowed_roles = ("risk_operator", "admin")` 两个都声明了,
所以两个角色都该能真正用起来 —— 只给 `risk_operator` 会让 admin 声明了却用不了。
幂等:权限与绑定都已存在时直接跳过。
用法:python tools/grant_risk_alert_read_permission.py
"""
import asyncio
import datetime as dt
import sys
import asyncmy
from app.core.config import get_settings
# (permission_code, resource, action, 授予的角色)
PERMISSIONS: tuple[tuple[str, str, str, tuple[str, ...]], ...] = (
("risk:alert:read", "risk", "alert", ("risk_operator", "admin")),
("risk:alert:write", "risk", "alert", ("risk_operator", "admin")),
("risk:alert:scan", "risk", "alert", ("risk_operator", "admin")),
("risk:report:mail", "risk", "report", ("risk_operator", "admin")),
)
DATA_SCOPE = "all"
async def connect() -> "asyncmy.Connection":
settings = get_settings()
# MYSQL_DSN 形如 mysql+asyncmy://user:pass@host:port/db
dsn = settings.mysql_dsn.split("://", 1)[1]
credentials, location = dsn.split("@", 1)
user, password = credentials.split(":", 1)
host_port, database = location.split("/", 1)
host, _, port = host_port.partition(":")
return await asyncmy.connect(
host=host, port=int(port or 3306), user=user, password=password, db=database
)
async def main() -> int:
connection = await connect()
granted = 0
try:
cursor = connection.cursor()
now = dt.datetime.now(dt.UTC).replace(tzinfo=None)
for code, resource, action, roles in PERMISSIONS:
await cursor.execute(
"SELECT id FROM sys_permission WHERE permission_code = %s", (code,)
)
row = await cursor.fetchone()
if row is None:
await cursor.execute(
"INSERT INTO sys_permission"
" (permission_code, resource, `action`, data_scope, created_at, updated_at)"
" VALUES (%s, %s, %s, %s, %s, %s)",
(code, resource, action, DATA_SCOPE, now, now),
)
await cursor.execute(
"SELECT id FROM sys_permission WHERE permission_code = %s", (code,)
)
row = await cursor.fetchone()
print(f"[权限] 已创建 {code}(scope={DATA_SCOPE})")
else:
print(f"[权限] {code} 已存在")
permission_id = int(row[0])
for role_code in roles:
await cursor.execute("SELECT id FROM sys_role WHERE role_code = %s", (role_code,))
role = await cursor.fetchone()
if role is None:
print(f"[角色] 找不到 {role_code},跳过 {code}")
continue
role_id = int(role[0])
await cursor.execute(
"SELECT 1 FROM sys_role_permission WHERE role_id = %s AND permission_id = %s",
(role_id, permission_id),
)
if await cursor.fetchone() is not None:
continue
await cursor.execute(
"INSERT INTO sys_role_permission (role_id, permission_id, created_at)"
" VALUES (%s, %s, %s)",
(role_id, permission_id, now),
)
granted += 1
print(f"[绑定] 授予 {role_code} → {code}")
await connection.commit()
print(f"\n本次新增绑定 {granted} 条")
return 0
finally:
connection.close()
sys.exit(asyncio.run(main()))