Files
group_fqcd_jr/tools/seed_test_rbac.py
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +08:00

376 lines
23 KiB
Python
Raw Permalink 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
import sys
from datetime import UTC, datetime, timedelta
from pathlib import Path
from sqlalchemy import text
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from app.core.contracts import RequestContext # noqa: E402
from app.infrastructure.db import SessionFactory # noqa: E402
from app.service.identity_service import IdentityService # noqa: E402
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"),
# ---- 9047-9050:风控模块四个权限码 ----
# 风控 Service 和 Agent 工具都按这四个权限码失败关闭。此前依赖
# `grant_risk_permissions.py` 临时补种,重跑本脚本时会删除 9001-9099 号段内的
# 风控权限,导致登录成功后风控接口全部 403。这里纳入唯一权限定义源。
(9047, "risk:alert:read", "risk", "alert", "all"),
(9048, "risk:alert:write", "risk", "alert", "all"),
(9049, "risk:alert:scan", "risk", "alert", "all"),
(9050, "risk:report:mail", "risk", "report", "all"),
# ---- 9051-9056:代码早就要求、但**从未建过**的 6 个权限码 ----
# 症状同样是 `AGENT_PERMISSION_DENIED: 缺少操作权限`:看着像角色配错,其实是
# **权限码根本不存在**,于是任何角色都过不去。对账工具见
# `tools/check_permission_coverage.py`(把"代码声明的权限"与"库里各角色拥有的"比一遍)。
# 推广材料:`promotion_material_service.py` 用这四个;第 164 行专门判
# `"advisor" in context.roles`,说明投顾本来就在这条业务线上。
(9051, "promotion:read", "promotion", "read", "all"),
(9052, "promotion:write", "promotion", "write", "all"),
(9053, "promotion:review", "promotion", "review", "all"),
(9054, "promotion:deliver", "promotion", "deliver", "all"),
# NL2SQL 工具声明该权限(`bootstrap.py`);`financial_nl2sql_service.py:272` 的角色
# 白名单是 {advisor, operator, admin, super_admin} —— 投顾与运营都要用它。
(9055, "financial:nl2sql:read", "financial", "nl2sql", "all"),
# 平台验证探针工具(`platform_probe.py`),只给 admin。
(9056, "probe:read", "probe", "read", "all"),
# ---- 9057-9059:`investment-goal:{action}:customer` 三个变体 ----
# 这三个是**动态拼出来的**:`investment_goal_service.py:270-286` 的
# `_assert_customer_access` 按 `customer_id == 自己` 决定拼 `:self` 还是 `:customer`,
# 所以对账工具抓不到字面量,一直以为权限齐了 —— 实际投顾查/建/确认**客户**的目标
# 全部 403。action 取值来自调用点:`write`(L43) / `confirm`(L110) / `read`(L138,155)。
#
# `data_scope` 必须是 `own_customers`:那段代码在后面还会校一次
# `scope != "all" and (scope != "own_customers" or customer_id not in context.customer_ids)`
# ⇒ 只有 `own_customers` 且客户确在投顾名下才放行。这是**最小权限**的正确形态:
# 投顾只看自己服务的客户,而不是全量客户。
(9057, "investment-goal:read:customer", "investment-goal", "read", "own_customers"),
(9058, "investment-goal:write:customer", "investment-goal", "write", "own_customers"),
(9059, "investment-goal:confirm:customer", "investment-goal", "confirm", "own_customers"),
# ---- 9060-9065:账户看板 + 场内模拟交易(T 段 9 端点) ----
# ZSY_develop §T 段(docs/05 §19)用户自助端点的权限码。
# Controller 当前不主动 require,但为与"业务契约 = 权限码声明"一致仍登记进
# `PERMISSIONS`,同时挂在 `CUSTOMER_PERMISSIONS` 让 customer 角色自带。
# 号段续 9060(避开 9047-9059 qyqy 风险/推广/探针/投资目标号段):与 9041-9046 客服二期间隔 1,避免与既有迁移/种子冲突。
(9060, "account:read:self", "account", "read", "self"),
# ⚠️ 第 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", "self"),
# ---- 9066-9068:投顾**代客**三项(`*:customer` 变体) ----
# 与 9057-9059 是**同一个坑的第二批**:这三个码同样是服务层按
# `customer_id == context.user_id` **动态拼**出来的,对账工具抓不到字面量,
# 于是库里从来没有它们,投顾一在投顾工作台对**客户**做操作就整片 403:
# · `product_recommendation_service.py:71` `product-recommendation:generate:customer`
# · `asset_allocation_service.py:57` `asset-allocation:generate:customer`
# · `portfolio_analysis_service.py:46` `portfolio-analysis:read:customer`
# `data_scope` 必须是 `own_customers`:`AuthorizationService.require_customer_scope`
# 只放行 `all`,或 `own_customers` 且客户确在 `context.customer_ids`(= 归属客户)内。
# 这正是投顾该有的最小权限 —— 只对自己服务的客户出方案。
(9066, "product-recommendation:generate:customer", "product-recommendation",
"generate", "own_customers"),
(9067, "asset-allocation:generate:customer", "asset-allocation", "generate",
"own_customers"),
(9068, "portfolio-analysis:read:customer", "portfolio-analysis", "read",
"own_customers"),
# ---- 9069:客服转人工工单**处置**(分配/接单/解决/关闭/取消) ----
# 原先只有 9046 `handover:read`(只读队列),于是"工单只能看、不能推进":
# 库里 40 张单子全部停在 pending(`docs/02` §7.2 的状态机一个动作也没有入口)。
# 状态机与审计见 `app/service/customer_service_handover_action_service.py`。
# data_scope 取 `all`:工单队列本身就是全平台视图,不按归属客户切。
(9069, "handover:write", "handover", "write", "all"),
)
# 客户:业务侧自助能力(自己的会话、反馈、转人工、自己的记忆画像)。
CUSTOMER_PERMISSIONS = (
9001, 9002, 9003, 9004, 9005, 9006, 9007, 9008, 9009, 9011, 9018,
# 客服二期:客户确认/拒绝**自己**的画像候选(服务层按 customer_id 过滤,不越权)。
9044,
# ZSY §T:账户看板与场内模拟交易(首版仅 customer 角色可用,留 admin 全量)
9060, 9061, 9062, 9063, 9064, 9065,
)
# 风控专员:业务侧只读 + 跨客户记忆 + 审计只读,不含配置写权限。
RISK_PERMISSIONS = (
9001, 9002, 9003, 9010, 9011, 9012,
9047, 9048, 9049, 9050,
)
# 平台管理员:管理面全套(配置发布四态 + 模型端点 + 审计)。
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"),
)
#: `sys_user.user_type` → `fund_account_status`。口径与 `tools/create_test_user.py`
#: 的 `FUND_ACCOUNT_STATUS` **必须一致**:客户只有"已开户"才能访问账户域接口,
#: 员工账号没有基金账户。此前这里(以及 INSERT 语句)硬编码写死 `'closed'`,
#: 于是种子建的客户永远"未开户"—— `GET /api/v1/users/me/account/dashboard` 与
#: `GET /api/v1/users/me/cash-ledger` 会返回 404「客户未开户」,前端对应页面直接打不开。
FUND_ACCOUNT_STATUS_BY_USER_TYPE: dict[str, str] = {
"customer": "已开户",
"employee": "closed",
}
#: 用户 → 角色绑定,**显式列出**而不是按位置配对。
#:
#: 原先写的是 `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)"))
# 风控权限在旧环境中可能由 `grant_risk_permissions.py` 自动分配了任意主键。
# 先按 permission_code 清掉角色绑定和旧主键,保证本次能用固定号段重建;
# 否则 `risk:report:mail` 已存在于 9001-9099 之外时会触发 permission_code 唯一键冲突。
await session.execute(text("""
DELETE rp FROM sys_role_permission rp
JOIN sys_permission p ON p.id = rp.permission_id
WHERE p.permission_code IN (
'risk:alert:read', 'risk:alert:write',
'risk:alert:scan', 'risk:report:mail'
)
"""))
await session.execute(text("""
DELETE FROM sys_permission
WHERE permission_code IN (
'risk:alert:read', 'risk:alert:write',
'risk:alert:scan', 'risk:report:mail'
)
"""))
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:
# 开户状态按角色区分,与 `tools/create_test_user.py` 的 FUND_ACCOUNT_STATUS 口径一致:
# **客户必须"已开户"**,否则账户域接口会一律 404「客户未开户」——
# 实测受影响的是 `GET /api/v1/users/me/account/dashboard`(前端 T001)与
# `GET /api/v1/users/me/cash-ledger`(T009),页面直接打不开。
# 员工账号没有基金账户,保持 closed。
fund_status = FUND_ACCOUNT_STATUS_BY_USER_TYPE.get(user_type, "closed")
updated = await session.execute(
text(
"UPDATE sys_user SET user_no=:no, username=:name, user_type=:type,"
" professional_investor_status='none', fund_account_status=:fund,"
" status='正常', updated_at=:now WHERE id=:id"
),
{"id": user_id, "no": user_no, "name": user_name, "type": user_type,
"fund": fund_status, "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',:fund,'正常',:now,:now)"
),
{"id": user_id, "no": user_no, "name": user_name,
"type": user_type, "fund": fund_status, "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())