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 里已记的一项。
This commit is contained in:
2026-09-11 13:37:30 +08:00
parent 24de6c34f7
commit deeee9bb01
2 changed files with 121 additions and 112 deletions
-112
View File
@@ -1,112 +0,0 @@
"""补齐风控工具所需的 RBAC 权限 `risk:alert:read`。
**为什么需要这个脚本**(实测证据):
风控的三个工具声明了 `required_permission="risk:alert:read"`
(`bootstrap.py:209` / `:216` / `:223`),但 `sys_permission` 表里**没有这条权限**——
连 `risk_operator` 角色也没有。于是任何身份调用风控工具都会拿到
`ForbiddenAgentError: 缺少工具权限`(`tool_executor.py:78`)。
组员交付的 `20-Agent工具白名单与意图配置.md` 第 116 行也把它列为接入前置条件:
「确认 `risk_operator` 拥有 `agent:run` 和 `risk:alert:read`」。
**字段怎么填**(照现有数据推断,不是自创):
- 权限匹配实际用的是 `permission_code` 全串(`identity_repository.py:25-37` 直接读
`p.permission_code`),`resource`/`action` 只是元数据;
- 参照 `fund:quote:read`(`resource=fund`、`action=quote`):`resource` 取第一段、
`action` 取第二段,故本权限为 `resource=risk`、`action=alert`;
- `data_scope` 取 `all`:风控要查**全部客户**的预警,而 `risk_query_service.py:194`、
`risk_analysis_service.py:126`、`risk_evidence_archive_service.py:218` 都按
`context.data_scope == "all"` 决定是否放行全量数据。`risk_operator` 已有
`audit:read`(scope=all),所以它的 data_scope 本来就是 all。
**只授权 `risk_operator`,不动 `admin`**:交付文档只要求前者;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 = "risk:alert:read"
ROLE_CODE = "risk_operator"
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()
try:
cursor = connection.cursor()
now = dt.datetime.now(dt.UTC).replace(tzinfo=None)
await cursor.execute(
"SELECT id, data_scope FROM sys_permission WHERE permission_code = %s",
(PERMISSION_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)",
(PERMISSION_CODE, "risk", "alert", "all", now, now),
)
await connection.commit()
await cursor.execute(
"SELECT id FROM sys_permission WHERE permission_code = %s", (PERMISSION_CODE,)
)
permission_id = int((await cursor.fetchone())[0])
print(f"[权限] 已创建 {PERMISSION_CODE}(id={permission_id}, scope=all)")
else:
permission_id = int(row[0])
print(f"[权限] {PERMISSION_CODE} 已存在(id={permission_id}, scope={row[1]})")
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},请先建角色")
return 1
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:
print(f"[绑定] {ROLE_CODE} 已拥有 {PERMISSION_CODE},无需变更")
return 0
await cursor.execute(
"INSERT INTO sys_role_permission (role_id, permission_id, created_at)"
" VALUES (%s, %s, %s)",
(role_id, permission_id, now),
)
await connection.commit()
print(f"[绑定] 已授予 {ROLE_CODE}(role_id={role_id})权限 {PERMISSION_CODE}")
return 0
finally:
connection.close()
sys.exit(asyncio.run(main()))
+121
View File
@@ -0,0 +1,121 @@
"""补齐风控模块需要的 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()))