Files
group_fqcd_jr/tools/seed_test_rbac.py
T
lzf_0626 076d786bc6 补齐客服转人工工单流:从"只能看"到"能推进"(基线状态机,不自行发明)
## 问题
`svc_handover_ticket` 的 DDL 与状态机在 `docs/02` §7.2 早就定好了
(pending → assigned → processing → resolved → closed,未解决可 cancelled),
但平台**只有 handover:read(只读队列)**:没有任何入口能改状态、assigned_to /
accepted_at / resolved_at / closed_at / resolution 五列**全库 0 非空**,
于是 40 张工单永远停在 pending —— 用户看到的就是"工单全都长一样"。

## 改了什么
后端:
- 新增 `app/service/customer_service_handover_action_service.py`:五个动作
  (分配/接单/解决/关闭/取消),`SELECT ... FOR UPDATE` 锁单后判状态;
  接单允许从 pending 自助接管(同时记受理人);取消不写 closed_at(该列属 closed 状态);
  每次流转写一条 interaction_audit(handover.assigned/accepted/resolved/closed/cancelled);
  非法流转 409、坐席不存在 422、工单不存在 404;回包不含 customer_id/session_id。
- 只读服务保持只读(读侧与写侧是两条边界,单测守着"读侧不许长出写方法"),
  但列表支持 `?status=` 六态筛选、详情补上受理人与流转时间(坐席侧路由信息,非客户数据)。
- `app/api/controllers/admin.py`:五个 action 端点 A049–A053
  (assignments / acceptances / resolutions / closures / cancellations),
  走 `ApiTransactionService.execute_in` —— 幂等记录与业务写入同事务、重复键回放。
- 权限:新增 `handover:write`(9069,只授 admin),已并进种子
  `tools/seed_test_rbac.py`;配套幂等脚本 `tools/grant_handover_write_permission.py`。

前端(管理员工作台 · 转人工工单页):
- 按状态给按钮(待处理→分配/直接接单、已分配→接单、处理中→解决、已解决→关闭、
  未解决都可取消),加了状态筛选与"刷新";摘要弹窗补上受理人与四个时间点、处置结论。
- api-client 注册五个端点;workspace.js 的 api-client 引用与页面自身的 ?v= 一并升版,
  避免浏览器拿旧缓存(旧缓存里没有这些端点)。

冒烟与测试:
- `tools/e2e_smoke_test.py`:B 段建的测试工单由 F 段走完 分配→接单→解决→关闭 收尾
  —— 既不再把测试件堆在 pending 队列里(此前每次冒烟攒一张),又让每次冒烟都覆盖一遍状态机。
  总数 40 → 44 项,实测 44/44 全绿。
- 新增单测 24 条(状态机合法/非法路径、越权、坐席不存在、审计、视图不泄漏客户标识)
  与一条真机集成用例(HTTP 十步 + 数据库侧审计证据 + 自动清理)。
- 读侧那条"详情不得返回 assigned_to"的旧断言按新口径更新,并写清为什么。

## 验证
- `pytest tests/unit tests/contract` → 1489 passed, 2 skipped, 0 failed
- 新增集成用例通过;`tests/integration` 全量跑时
  `test_memory_extraction` / `test_run_cancellation_mysql` 两条偶发红 —— 单独跑都通过,
  是 AGENTS.md 已登记的"常驻 Worker 抢队列"(跑验收前须先停 Worker)
- `tools/portal_api_check.py` → 41 项通过 39、失败 0
- `tools/e2e_smoke_test.py` → 44/44 全通过
- `python tools/check_rbac_seed_consistency.py` → 通过(种子 63 条权限)
- 真机 HTTP 实测:分配→接单→解决→关闭四步 200 且时间戳齐全;取消路径 200 且 closed_at 为空;
  同键重发回放不二次推进;对已关闭工单再分配 409;风控账号处置 403

## 文档
`docs/44-演示流程.md`(场景 4/8 + 命令 + 44 项)、`docs/演示用/后端接口文档`(新增 §11.4b 与
A049–A053)、`docs/演示用/全功能流程-大白话版.md`(工单页签改"读写"+ 已知偏差)、
`AGENTS.md`(9066-9069 号段演进 + 冒烟 44 项)
2026-09-15 00:41:59 +08:00

377 lines
23 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
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,
9020, 9021, 9022, 9023, 9024, 9025, 9026, 9034,
# 客服二期:客户确认/拒绝**自己**的画像候选(服务层按 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())