Files
group_fqcd_jr/tools/seed_test_rbac.py
T

251 lines
14 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 测试数据,验证真实链路身份加载与越权拦截。
数据使用 9001 起的号段(角色 9001-9003、权限 9001-9046),便于清理,不影响业务数据。
权限码是几条线叠加出来的:9001-9019 基础能力、9020-9034 投顾线、9041-9046 投顾治理类与客服二期。
⚠️ 本脚本对**权限、角色、角色绑定**是 **DELETE 重建**:`DELETE FROM sys_permission WHERE
id BETWEEN 9001 AND 9099` 会删掉该号段内**所有**权限(包括别的线用 `grant_*.py` 建的),
再按 `PERMISSIONS` 重建,并且**只清 role_id 9001-9003 的角色绑定**。三个已知后果:
1. 新增权限码必须并进本文件的 `PERMISSIONS`,否则重建一次就没了(表现为"接口突然 403");
2. `advisor`(9004)这类不在 `ROLES` 里的角色,其绑定**不会被清** —— 若它们引用的
permission_id 在重建时换了语义,绑定就会指向错误的权限码。改 id→code 映射前先确认;
3. **`sys_user` 不再是 DELETE 重建**(2026-09-12 改):投顾域的 `advisor_profile_tag` 等表用
FK 引用 `sys_user`,一旦库里有数据引用 9001-9003,`DELETE FROM sys_user` 会被外键拒绝
(MySQL 1451),**种子整个跑不完**;而 `commit()` 在最后,所以外部表现是"什么都没发生"。
现已改为「存在则 UPDATE 非密码字段、不存在才 INSERT」,且**不覆盖 `password_hash`**,
因此重跑种子不会再弄丢演示密码,也不需要事后补 `set_user_password.py`。
为什么需要这份种子:权限码必须与**代码实际声明**一致——工具声明写在
`app/service/agent/bootstrap.py`(`check_suitability` 需 `suitability:read`、
`query_fund_quote` 需 `fund:quote:read`),接口校验写在各 Service 的
`AuthorizationService.require(...)`。种子缺权限的后果不是"少测一项",而是
"功能明明实现了却一直 403",让验收与集成测试得出错误结论。
"""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime, timedelta
from sqlalchemy import text
from app.core.contracts import RequestContext
from app.infrastructure.db import SessionFactory
from app.service.identity_service import IdentityService
CUSTOMER_USER = "9001"
RISK_USER = "9002"
ADMIN_USER = "9003"
# (权限 id, 权限码, resource, action, data_scope)
PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = (
(9001, "agent:run", "agent", "run", "self"),
(9002, "suitability:read", "suitability", "read", "self"),
(9003, "fund:quote:read", "fund", "quote", "self"),
(9004, "conversation:create", "conversation", "create", "self"),
(9005, "conversation:close", "conversation", "close", "self"),
(9006, "conversation:feedback", "conversation", "feedback", "self"),
(9007, "agent:cancel", "agent", "cancel", "self"),
(9008, "handover:create", "handover", "create", "self"),
(9009, "memory:read:self", "memory", "read", "self"),
(9010, "memory:read:customer", "memory", "read", "own_customers"),
(9011, "knowledge:reference:read", "knowledge", "reference", "self"),
(9012, "audit:read", "audit", "read", "all"),
(9013, "config:read", "config", "read", "all"),
(9014, "config:write", "config", "write", "all"),
(9015, "config:review", "config", "review", "all"),
(9016, "config:activate", "config", "activate", "all"),
(9017, "model-endpoint:manage", "model-endpoint", "manage", "all"),
# `query_knowledge` 工具声明该权限(`app/service/knowledge_tool.py` 的
# `REQUIRED_PERMISSION`);缺失会让客服 Agent 调工具时被拒(缺少工具权限)。
# 补入种子以保证"重跑种子 / 换环境"后权限仍存在。
(9018, "knowledge:query", "knowledge", "query", "self"),
# 知识库**管理**面(Task 11):上传/列表/删除三个端点统一要这个权限码
# (`app/service/knowledge_management_service.py` 的 `REQUIRED_PERMISSION`)。
# 刻意不把 `knowledge:query`(客户角色也有)复用成管理权限:那会让客户看到整库文档清单。
# 归属:只给 admin(ADMIN_PERMISSIONS 是全量元组,自动包含本行)。
(9019, "knowledge:manage", "knowledge", "manage", "all"),
(9020, "investment-goal:write:self", "investment-goal", "write", "self"),
(9021, "investment-goal:confirm:self", "investment-goal", "confirm", "self"),
(9022, "investment-goal:read:self", "investment-goal", "read", "self"),
(9023, "product-recommendation:generate:self", "product-recommendation", "generate", "self"),
(9024, "product-recommendation:read:self", "product-recommendation", "read", "self"),
(9025, "portfolio-analysis:read:self", "portfolio-analysis", "read", "self"),
(9026, "asset-allocation:generate:self", "asset-allocation", "generate", "self"),
(9027, "profile-governance:read", "profile-governance", "read", "all"),
(9028, "profile-governance:review", "profile-governance", "review", "all"),
(9029, "investment-goal:review", "investment-goal", "review", "all"),
(9030, "investment-goal:publish", "investment-goal", "publish", "all"),
(9031, "product-recommendation:review", "product-recommendation", "review", "all"),
(9032, "product-recommendation:publish", "product-recommendation", "publish", "all"),
(9033, "asset-allocation:backtest", "asset-allocation", "backtest", "all"),
(9034, "product-comparison:read:self", "product-comparison", "read", "self"),
# ---- 9041-9046:投顾治理类 3 个 + 客服二期 3 个 ----
# 投顾线(`bbf623a`)已把 9020-9034 放进本种子,但漏了这 3 个治理类权限码,
# 而 `app/service/` 的治理接口直接要求它们 —— 缺了就是"实现了却一直 403"。
# 号段从 9041 起:库里曾有一批 9020-9035 是 `grant_advisor_role.py` 用旧号段建的,
# 与种子的 9020-9034 **id→code 映射不同**,挪到 9041 之后与两侧都不冲突。
(9041, "product-governance:read", "product-governance", "read", "all"),
(9042, "product-governance:review", "product-governance", "review", "all"),
(9043, "product-governance:sync", "product-governance", "sync", "all"),
# 客服二期(画像候选 + 转人工工单)的 3 个权限码。此前不在任何种子或迁移里,
# 属"环境数据":代码合过来后若库里缺这几行,候选确认/审核与工单查看 5 个接口一律 403。
(9044, "memory:candidate:confirm", "memory:candidate", "confirm", "self"),
(9045, "memory:candidate:review", "memory:candidate", "review", "all"),
(9046, "handover:read", "handover", "read", "all"),
)
# 客户:业务侧自助能力(自己的会话、反馈、转人工、自己的记忆画像)。
CUSTOMER_PERMISSIONS = (
9001, 9002, 9003, 9004, 9005, 9006, 9007, 9008, 9009, 9011, 9018,
9020, 9021, 9022, 9023, 9024, 9025, 9026, 9034,
# 客服二期:客户确认/拒绝**自己**的画像候选(服务层按 customer_id 过滤,不越权)。
9044,
)
# 风控专员:业务侧只读 + 跨客户记忆 + 审计只读,不含配置写权限。
RISK_PERMISSIONS = (9001, 9002, 9003, 9010, 9011, 9012)
# 平台管理员:管理面全套(配置发布四态 + 模型端点 + 审计)。
ADMIN_PERMISSIONS = tuple(row[0] for row in PERMISSIONS)
ROLES: tuple[tuple[int, str, str], ...] = (
(9001, "customer", "客户"),
(9002, "risk_operator", "风控专员"),
(9003, "admin", "平台管理员"),
)
USERS: tuple[tuple[int, str, str, str], ...] = (
(9001, "T-CUST", "cust_t", "customer"),
(9002, "T-RISK", "risk_t", "employee"),
(9003, "T-ADMIN", "admin_t", "employee"),
# 9004 review_t:**不绑任何角色、不设密码**的账号,用于两个边界用例:
# - `test_rbac_read_mysql`:账号**存在但无权限** → 应返回 200 + 空权限集,而不是 404(错误码语义);
# - `test_auth_login_mysql` 的 `PLACEHOLDER_ACCOUNTS`:`password_hash` 是占位符 → 必须 401 而非 500。
# 2026-09-12 补:此前这两个测试都引用它,但**种子从未创建**它 —— 后者因"账号不存在也返回 401"
# 而恰好蒙过,前者则一直红(`/admin/users/9004/roles` 返回 404 "用户不存在")。
(9004, "T-REVIEW", "review_t", "employee"),
)
#: 用户 → 角色绑定,**显式列出**而不是按位置配对。
#:
#: 原先写的是 `zip(user_ids, role_ids, strict=True)`,隐含假设「USERS 与 ROLES 一一对应」;
#: 9004 review_t 是**故意不绑角色**的账号,一加进来 zip 就会
#: `ValueError: zip() argument 2 is shorter than argument 1`,整个种子跑不完
#: (而 commit 在最后,外部表现是"什么都没发生")。显式列表让"谁绑什么"一眼可见,
#: 也不受两侧顺序/长度变化影响。
USER_ROLES: tuple[tuple[int, int], ...] = (
(9001, 9001), # cust_t → customer
(9002, 9002), # risk_t → risk_operator
(9003, 9003), # admin_t → admin
# 9004 review_t 不在此列:「账号存在但无权限」正是它要覆盖的场景。
)
GRANTS: tuple[tuple[int, tuple[int, ...]], ...] = (
(9001, CUSTOMER_PERMISSIONS),
(9002, RISK_PERMISSIONS),
(9003, ADMIN_PERMISSIONS),
)
async def seed() -> None:
now = datetime.now(UTC).replace(tzinfo=None)
# MySQL 的 DATETIME(0) 会把微秒四舍五入到秒:若用"当前时间"写 assigned_at,
# 进位后可能落在未来,使紧随其后的 `assigned_at <= now` 授权校验判定
# "尚未生效",于是刚种好的账号一个权限都拿不到(表现为 roles=() 而非报错)。
# 往前留 5 秒,彻底避开这个舍入窗口。
effective_at = now - timedelta(seconds=5)
async with SessionFactory() as session:
await session.execute(
text("DELETE FROM sys_role_permission WHERE role_id IN (9001,9002,9003)")
)
await session.execute(text("DELETE FROM sys_user_role WHERE user_id IN (9001,9002,9003)"))
await session.execute(text("DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099"))
await session.execute(text("DELETE FROM sys_role WHERE id IN (9001,9002,9003)"))
# 不再 DELETE sys_user:投顾域的 advisor_profile_tag 等表用 FK 引用它,库里有数据引用
# 9001-9003 时删除会被外键拒绝(1451)导致整个种子跑不完,而且删了用户等于把演示密码
# 一起弄丢。改为下面循环里的「更新或插入」,且不覆盖 password_hash。
for user_id, user_no, user_name, user_type in USERS:
updated = await session.execute(
text(
"UPDATE sys_user SET user_no=:no, username=:name, user_type=:type,"
" professional_investor_status='none', fund_account_status='closed',"
" status='正常', updated_at=:now WHERE id=:id"
),
{"id": user_id, "no": user_no, "name": user_name, "type": user_type, "now": now},
)
if updated.rowcount == 0:
await session.execute(
text(
"INSERT INTO sys_user (id, user_no, username, password_hash, user_type,"
" professional_investor_status, fund_account_status, status,"
" created_at, updated_at)"
" VALUES (:id,:no,:name,'x',:type,'none','closed','正常',:now,:now)"
),
{"id": user_id, "no": user_no, "name": user_name,
"type": user_type, "now": now},
)
for role_id, role_code, role_name in ROLES:
await session.execute(
text(
"INSERT INTO sys_role (id, role_code, role_name, status, created_at, updated_at)"
" VALUES (:id,:code,:name,'active',:now,:now)"
),
{"id": role_id, "code": role_code, "name": role_name, "now": now},
)
for permission_id, code, resource, action, scope in PERMISSIONS:
await session.execute(
text(
"INSERT INTO sys_permission"
" (id, permission_code, resource, action, data_scope, created_at, updated_at)"
" VALUES (:id,:code,:resource,:action,:scope,:now,:now)"
),
{"id": permission_id, "code": code, "resource": resource, "action": action,
"scope": scope, "now": now},
)
for user_id, role_id in USER_ROLES:
await session.execute(
text(
"INSERT INTO sys_user_role (user_id, role_id, assigned_at)"
" VALUES (:user,:role,:now)"
),
{"user": user_id, "role": role_id, "now": effective_at},
)
for role_id, permission_ids in GRANTS:
for permission_id in permission_ids:
await session.execute(
text(
"INSERT INTO sys_role_permission (role_id, permission_id, created_at)"
" VALUES (:role,:perm,:now)"
),
{"role": role_id, "perm": permission_id, "now": now},
)
await session.commit()
async def verify() -> None:
service = IdentityService()
for label, user_id in (("customer", CUSTOMER_USER), ("risk_operator", RISK_USER),
("admin", ADMIN_USER)):
context = await service.resolve(RequestContext(user_id=user_id, trace_id="verify-trace"))
print(
f"{label:14s} roles={context.roles} permissions={sorted(context.permissions)} "
f"portal={context.portal} data_scope={context.data_scope}"
)
customer = await service.resolve(
RequestContext(user_id=CUSTOMER_USER, trace_id="verify-trace")
)
missing = {"agent:run", "suitability:read", "fund:quote:read"} - set(customer.permissions)
if missing:
raise SystemExit(f"客户角色缺少公共工具所需权限:{sorted(missing)}")
print("verified: customer can pass public tool authorization checks")
async def main() -> None:
await seed()
await verify()
if __name__ == "__main__":
asyncio.run(main())