## 投顾角色
投顾线合并后,bootstrap.py 有 10 处 allowed_roles 引用 advisor,
financial_nl2sql_service.py:272 还硬编码检查 {"advisor","operator","admin","super_admin"},
promotion_material_service.py:164 按 "advisor" in context.roles 走业务分支 ——
但 sys_role 里没有这个角色、sys_permission 里也没有投顾那 16 个权限码(种子只建到 9019)。
表现是所有投顾接口 403,而报错看起来像"权限配错了",实际是角色根本不存在。
- tools/grant_advisor_role.py:建 advisor 角色(id=9004,避开种子的 9001-9003 重建范围)
+ 16 个投顾权限(id 9020-9035)+ 授权(advisor 拿 10 项工作流、admin 补齐 16 项)。
只增不删、可重复执行、带 --dry-run。
- tools/create_test_user.py:ROLE_IDS 加 advisor。
- 先跑 alembic upgrade head:补 21 张 advisor_* 表,业务表 68 → 89,审计通过。
权限划分:投顾工作流 10 项(read:self / generate:self / review / publish)给 advisor;
治理类 6 项(product-governance:*、profile-governance:*、asset-allocation:backtest)只给 admin。
review/publish 也给 advisor,与既有决策一致(此前已裁定不做双人复核)。
验证:advisor_t 登录 200,roles=['advisor'] data_scope=all 权限 10 项;
用它查 RBAC 清单得 403(没有 audit:read),边界正确。
⚠️ 与种子的冲突:seed_test_rbac.py 是 DELETE 重建语义,其
DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099 会清掉本脚本建的权限。
要把投顾权限固化,应并进 seed_test_rbac.py 的 PERMISSIONS 常量。
## 修正登录测试的一个错误假设
test_issued_token_actually_works_on_a_real_endpoint 原本用客户的
/users/me/memory-profile 验证令牌可用,投顾合并后它返回 403。追下去发现**与令牌无关**:
那个接口对客户有业务前置"请先完成开户风险测评问卷",而演示客户 9001 没有测评记录。
是我的测试选错了验证端点,把"业务前置未满足"误判成"令牌坏了"。
- 改用管理员令牌调 /api/v1/admin/roles(需要 audit:read,走完整鉴权链路),
并补一条反向对照:不带令牌必须 401,否则那个 200 说明不了令牌有效。
- 把那个业务前置单独写成一个用例,让后来者一眼看到条件,而不是反复怀疑令牌。
过程里我先按控制台乱码猜了两次失败原因,都不对;最后把响应抓成 UTF-8 文件才看到真实
消息。教训记下:不要读乱码猜消息。
## 修投顾带入的 2 处文档重号
21-投顾Agent迁移TODO.md → 30-…、22-投顾Agent灰度与回滚操作手册.md → 31-…
(沿用 NL 那次让号的先例:既有文档更早、引用更多;且这两份新文档没有被任何地方引用。)
文档守卫:40 份无编号冲突。
验证:ruff 干净 / mypy 228 文件 0 错 / 文档守卫 40 份无冲突 /
unit+contract 1207 passed(0 failed)/ integration 99 passed / 业务表 89 张。
225 lines
9.5 KiB
Python
225 lines
9.5 KiB
Python
"""建立投顾(`advisor`)角色并授权。
|
||
|
||
## 为什么需要它
|
||
|
||
投顾这条线合并进来后,`bootstrap.py` 有 **10 处 `allowed_roles` 引用了 `advisor`**,
|
||
`financial_nl2sql_service.py:272` 还硬编码检查 `{"advisor","operator","admin","super_admin"}`,
|
||
`promotion_material_service.py:164` 直接按 `"advisor" in context.roles` 走业务分支 ——
|
||
但 `sys_role` 里**没有这个角色**,`sys_permission` 里也没有投顾那 16 个权限码
|
||
(`seed_test_rbac.py` 只建到 9019)。结果是:投顾登录后拿不到任何投顾权限,
|
||
所有投顾接口一律 403,而报错看起来像"权限配错了",实际是**角色根本不存在**。
|
||
|
||
## 权限怎么分
|
||
|
||
| 类别 | 权限码 | 给谁 |
|
||
|---|---|---|
|
||
| 投顾工作流(10) | `asset-allocation:generate:self`、`investment-goal:read:self` / `:review` / `:publish`、`portfolio-analysis:read:self`、`product-comparison:read:self`、`product-recommendation:read:self` / `:generate:self` / `:review` / `:publish` | `advisor` + `admin` |
|
||
| 治理类(6) | `asset-allocation:backtest`、`product-governance:read` / `:review` / `:sync`、`profile-governance:read` / `:review` | **只给 `admin`** |
|
||
|
||
`review` / `publish` 也给投顾,与项目既有决策一致 —— 此前已裁定**不做双人复核**
|
||
(`admin` 发布配置时也是"创建人自审")。治理类不给投顾:那是平台侧的活。
|
||
|
||
## ⚠️ 与 `seed_test_rbac.py` 的冲突
|
||
|
||
那个脚本是 **DELETE 重建**语义,它的
|
||
`DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099` 会**清掉本脚本建的权限**
|
||
(本脚本用 9020-9035)。将来若要在种子里固化投顾权限,请把它并进
|
||
`seed_test_rbac.py` 的 `PERMISSIONS` 常量,而不是只跑本脚本。
|
||
|
||
本脚本自身**只增不删**:重复执行只补齐缺失项,不动任何已有绑定。
|
||
|
||
用法:
|
||
|
||
python tools/grant_advisor_role.py --dry-run # 只打印将写入什么
|
||
python tools/grant_advisor_role.py
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import asyncio
|
||
import sys
|
||
from datetime import UTC, datetime
|
||
|
||
from sqlalchemy import text
|
||
|
||
from app.infrastructure.db import SessionFactory
|
||
|
||
if hasattr(sys.stdout, "reconfigure"):
|
||
sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]
|
||
|
||
#: 角色 id 用 9004:`seed_test_rbac.py` 只重建 9001-9003,不会碰它。
|
||
ADVISOR_ROLE_ID = 9004
|
||
ADVISOR_ROLE_CODE = "advisor"
|
||
ADVISOR_ROLE_NAME = "投资顾问"
|
||
|
||
#: 权限 id 从 9020 起,避开种子已用的 9001-9019。
|
||
#: (id, permission_code, resource, action, data_scope)
|
||
ADVISOR_PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = (
|
||
(9020, "asset-allocation:generate:self", "asset-allocation", "generate", "self"),
|
||
(9021, "asset-allocation:backtest", "asset-allocation", "backtest", "all"),
|
||
(9022, "investment-goal:read:self", "investment-goal", "read", "self"),
|
||
(9023, "investment-goal:review", "investment-goal", "review", "all"),
|
||
(9024, "investment-goal:publish", "investment-goal", "publish", "all"),
|
||
(9025, "portfolio-analysis:read:self", "portfolio-analysis", "read", "self"),
|
||
(9026, "product-comparison:read:self", "product-comparison", "read", "self"),
|
||
(9027, "product-recommendation:read:self", "product-recommendation", "read", "self"),
|
||
(9028, "product-recommendation:generate:self", "product-recommendation", "generate", "self"),
|
||
(9029, "product-recommendation:review", "product-recommendation", "review", "all"),
|
||
(9030, "product-recommendation:publish", "product-recommendation", "publish", "all"),
|
||
(9031, "product-governance:read", "product-governance", "read", "all"),
|
||
(9032, "product-governance:review", "product-governance", "review", "all"),
|
||
(9033, "product-governance:sync", "product-governance", "sync", "all"),
|
||
(9034, "profile-governance:read", "profile-governance", "read", "all"),
|
||
(9035, "profile-governance:review", "profile-governance", "review", "all"),
|
||
)
|
||
|
||
#: 投顾拿哪些 —— 工作流那 10 个;治理类 6 个只给 admin。
|
||
ADVISOR_GRANTED_CODES: tuple[str, ...] = (
|
||
"asset-allocation:generate:self",
|
||
"investment-goal:read:self",
|
||
"investment-goal:review",
|
||
"investment-goal:publish",
|
||
"portfolio-analysis:read:self",
|
||
"product-comparison:read:self",
|
||
"product-recommendation:read:self",
|
||
"product-recommendation:generate:self",
|
||
"product-recommendation:review",
|
||
"product-recommendation:publish",
|
||
)
|
||
|
||
#: admin 角色 id(`seed_test_rbac.py` 建的)。
|
||
ADMIN_ROLE_ID = 9003
|
||
|
||
|
||
async def apply(*, dry_run: bool) -> int:
|
||
now = datetime.now(UTC).replace(tzinfo=None)
|
||
async with SessionFactory() as session, session.begin():
|
||
existing_codes = set(
|
||
(await session.scalars(
|
||
text("SELECT permission_code FROM sys_permission")
|
||
)).all()
|
||
)
|
||
to_create = [p for p in ADVISOR_PERMISSIONS if p[1] not in existing_codes]
|
||
print(f"权限:已存在 {len(existing_codes)} 个,本次新增 {len(to_create)} 个")
|
||
for _, code, resource, action, scope in to_create:
|
||
print(f" + {code:<44} {resource}:{action} scope={scope}")
|
||
|
||
role_exists = await session.scalar(
|
||
text("SELECT id FROM sys_role WHERE role_code = :code"),
|
||
{"code": ADVISOR_ROLE_CODE},
|
||
)
|
||
print(f"角色 {ADVISOR_ROLE_CODE}:{'已存在' if role_exists else '将新建(id=9004)'}")
|
||
|
||
if dry_run:
|
||
print("\n[dry-run] 未写入任何数据。")
|
||
return 0
|
||
|
||
for perm_id, code, resource, action, scope in to_create:
|
||
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": perm_id, "code": code, "resource": resource,
|
||
"action": action, "scope": scope, "now": now},
|
||
)
|
||
|
||
if role_exists is None:
|
||
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": ADVISOR_ROLE_ID, "code": ADVISOR_ROLE_CODE,
|
||
"name": ADVISOR_ROLE_NAME, "now": now},
|
||
)
|
||
role_id = int(
|
||
await session.scalar(
|
||
text("SELECT id FROM sys_role WHERE role_code = :code"),
|
||
{"code": ADVISOR_ROLE_CODE},
|
||
)
|
||
)
|
||
|
||
permission_ids = dict(
|
||
(await session.execute(
|
||
text("SELECT permission_code, id FROM sys_permission")
|
||
)).all()
|
||
)
|
||
|
||
# 授权:先查已有绑定,只补缺失的(只增不删)。
|
||
async def grant(role: int, codes: tuple[str, ...]) -> int:
|
||
have = set(
|
||
(await session.scalars(
|
||
text("SELECT permission_id FROM sys_role_permission WHERE role_id = :r"),
|
||
{"r": role},
|
||
)).all()
|
||
)
|
||
added = 0
|
||
for code in codes:
|
||
perm_id = permission_ids.get(code)
|
||
if perm_id is None or int(perm_id) in have:
|
||
continue
|
||
await session.execute(
|
||
text(
|
||
"INSERT INTO sys_role_permission (role_id, permission_id, created_at)"
|
||
" VALUES (:r, :p, :now)"
|
||
),
|
||
{"r": role, "p": int(perm_id), "now": now},
|
||
)
|
||
added += 1
|
||
return added
|
||
|
||
all_codes = tuple(code for _, code, _, _, _ in ADVISOR_PERMISSIONS)
|
||
advisor_added = await grant(role_id, ADVISOR_GRANTED_CODES)
|
||
admin_added = await grant(ADMIN_ROLE_ID, all_codes)
|
||
print(f"授权:advisor 新增 {advisor_added} 项(共 {len(ADVISOR_GRANTED_CODES)} 项)")
|
||
print(f" admin 新增 {admin_added} 项(共 {len(all_codes)} 项)")
|
||
|
||
await verify()
|
||
return 0
|
||
|
||
|
||
async def verify() -> None:
|
||
"""用真实链路验证:角色与权限能被解析出来。"""
|
||
async with SessionFactory() as session:
|
||
rows = (
|
||
await session.execute(
|
||
text(
|
||
"""
|
||
SELECT r.role_code, COUNT(rp.permission_id) AS n
|
||
FROM sys_role r
|
||
LEFT JOIN sys_role_permission rp ON rp.role_id = r.id
|
||
GROUP BY r.id, r.role_code ORDER BY r.role_code
|
||
"""
|
||
)
|
||
)
|
||
).mappings().all()
|
||
print("\n各角色权限数(实测):")
|
||
for row in rows:
|
||
print(f" {str(row['role_code']):<16} {int(row['n'])} 项")
|
||
if not any(str(row["role_code"]) == ADVISOR_ROLE_CODE for row in rows):
|
||
print("[失败] advisor 角色没有建成功")
|
||
raise SystemExit(1)
|
||
print(
|
||
"\n下一步:给某个人绑这个角色 ——\n"
|
||
" python tools/create_test_user.py --id 9020 --username advisor_t "
|
||
"--role advisor --password abc12345"
|
||
)
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description="建立投顾角色并授权")
|
||
parser.add_argument("--dry-run", action="store_true", help="只打印将写入什么")
|
||
args = parser.parse_args()
|
||
return asyncio.run(apply(dry_run=args.dry_run))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|