diff --git a/tools/grant_risk_alert_read_permission.py b/tools/grant_risk_alert_read_permission.py deleted file mode 100644 index 6f96316..0000000 --- a/tools/grant_risk_alert_read_permission.py +++ /dev/null @@ -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())) diff --git a/tools/grant_risk_permissions.py b/tools/grant_risk_permissions.py new file mode 100644 index 0000000..9685b13 --- /dev/null +++ b/tools/grant_risk_permissions.py @@ -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()))