diff --git a/docs/29-Agent组员登录接口使用说明.md b/docs/29-Agent组员登录接口使用说明.md index c44b8e2..77d599c 100644 --- a/docs/29-Agent组员登录接口使用说明.md +++ b/docs/29-Agent组员登录接口使用说明.md @@ -25,7 +25,8 @@ |---|---|---|---|---| | 9001 | **`cust_t`** | `123456` | `customer` | 客户界面:自己的会话、适当性、知识问答 | | 9002 | **`risk_t`** | `666666` | `risk_operator` | 风控界面:预警队列、证据、日报 | -| 9003 | **`admin_t`** | `88888888` | `admin` | 管理界面:配置发布、模型端点、审计 | +| 9003 | **`admin_t`** | `88888888` | `admin` | 管理界面:配置发布、模型端点、审计、RBAC 清单 | +| 9020 | **`advisor_t`** | `abc12345` | `advisor` | 投顾界面:组合分析、产品对比、投资目标、方案发布 | > 密码由 `tools/set_user_password.py` 设置。**这三种弱口令仅用于演示**, > 上线前必须全部更换。 @@ -135,9 +136,9 @@ python tools/create_test_user.py --id 9010 --username test_cust --role customer python tools/create_test_user.py --id 9011 --username test_risk --role risk_operator --password abc12345 ``` -**角色只有三个可选值**:`customer` / `risk_operator` / `admin`(`sys_role` 里现成的三个)。 -要引入**新角色**得同时定义它的权限集合(`sys_role_permission`),那超出这个脚本的范围, -找平台侧。 +**角色可选值**:`customer` / `risk_operator` / `admin` / `advisor`(投顾)。 +要引入**新角色**得同时定义它的权限集合(`sys_role_permission`)—— +`advisor` 就是由 `tools/grant_advisor_role.py` 建立的,可参照它。 ### 脚本会做三件事,并**验证第四件** diff --git a/docs/21-投顾Agent迁移TODO.md b/docs/30-投顾Agent迁移TODO.md similarity index 100% rename from docs/21-投顾Agent迁移TODO.md rename to docs/30-投顾Agent迁移TODO.md diff --git a/docs/22-投顾Agent灰度与回滚操作手册.md b/docs/31-投顾Agent灰度与回滚操作手册.md similarity index 100% rename from docs/22-投顾Agent灰度与回滚操作手册.md rename to docs/31-投顾Agent灰度与回滚操作手册.md diff --git a/tests/integration/test_auth_login_mysql.py b/tests/integration/test_auth_login_mysql.py index 21c7699..dc87c74 100644 --- a/tests/integration/test_auth_login_mysql.py +++ b/tests/integration/test_auth_login_mysql.py @@ -126,22 +126,56 @@ async def test_each_role_can_login_with_its_own_role( async def test_issued_token_actually_works_on_a_real_endpoint() -> None: """签出来的令牌必须能真的用 —— 这是本文件不用替身的理由。 - `GET /api/v1/users/me/memory-profile` 需要 `memory:read:self`(客户角色有), - 走的是 `build_request_context` → `JwtAuthenticator` → `IdentityService.resolve` - 这条真实链路:令牌只带 `sub`,角色与权限全部查库解析。 + 用管理员令牌调 `/api/v1/admin/roles`(需要 `audit:read`),走的是 + `build_request_context` → `JwtAuthenticator` → `IdentityService.resolve` 全链路, + 与真实请求完全一致。再补一条**反向对照**:不带令牌必须 401 —— + 否则"返回 200"也可能只是这个端点根本没鉴权。 + + ⚠️ 这里**刻意不用** `/users/me/memory-profile`:它对客户有"必须先完成开户风险测评 + 问卷"的业务前置,而演示客户 9001 没有测评记录,于是返回 403 —— + 那看起来像令牌坏了,实际与令牌无关(见下面那条用例)。 + """ + async with client() as http: + response = await login(http, "admin_t", "88888888") + assert response.status_code == 200, response.text + token = response.json()["data"]["access_token"] + + authorized = await http.get( + "/api/v1/admin/roles", headers={"Authorization": f"Bearer {token}"} + ) + assert authorized.status_code == 200, authorized.text + assert isinstance(authorized.json()["data"], list) + + anonymous = await http.get("/api/v1/admin/roles") + assert anonymous.status_code == 401, "不带令牌必须 401,否则上面的 200 说明不了令牌有效" + + +@pytest.mark.asyncio +async def test_customer_profile_requires_completed_assessment() -> None: + """画像接口对客户有业务前置:必须先完成开户风险测评问卷。 + + 演示客户 9001 目前**没有测评记录**(`fin_risk_assessment` 没有它的行),所以拿它的 + 令牌调自己的画像会得到 `403 请先完成开户风险测评问卷`。这不是令牌或 RBAC 的问题 + —— 同一个接口用管理员令牌是 200。 + + 把这个条件写成用例,是为了让后来者一眼看到它,而不是像这次的集成测试那样 + 反复怀疑"是不是登录/令牌坏了"。要演示客户画像,先给 9001 补一条测评记录。 """ async with client() as http: response = await login(http, "cust_t", "123456") assert response.status_code == 200, response.text token = response.json()["data"]["access_token"] - authorized = await http.get( + profile = await http.get( "/api/v1/users/me/memory-profile", headers={"Authorization": f"Bearer {token}"}, ) - # 200=有画像,404=该客户还没有画像行;两者都说明**令牌被接受并通过了 RBAC**。 - # 401/403 则说明令牌或身份解析链有问题。 - assert authorized.status_code in (200, 404), authorized.text + + # 令牌是被接受的(不是 401);被拒的原因是业务前置而非鉴权。 + assert profile.status_code in (200, 403), profile.text + if profile.status_code == 403: + assert profile.json()["error"]["code"] == "AGENT_PERMISSION_DENIED" + assert "测评" in profile.json()["error"]["message"] @pytest.mark.asyncio diff --git a/tools/create_test_user.py b/tools/create_test_user.py index 4dd99f4..6d20ae7 100644 --- a/tools/create_test_user.py +++ b/tools/create_test_user.py @@ -40,12 +40,14 @@ from app.service.identity_service import IdentityService if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr] -#: 库里现成的三个角色(`tools/seed_test_rbac.py` 建的)。新用户复用它们。 -#: 要引入**新角色**得同时定义它的权限集合(`sys_role_permission`),超出本脚本范围。 +#: 库里现成的角色。新用户复用它们。 +#: 要引入**新角色**得同时定义它的权限集合(`sys_role_permission`)—— +#: `advisor` 就是由 `tools/grant_advisor_role.py` 建立的。 ROLE_IDS: dict[str, int] = { "customer": 9001, "risk_operator": 9002, "admin": 9003, + "advisor": 9004, } #: 角色 → `sys_user.user_type`。注意这是 `user_type`,与 `employee_role` 不是一回事。 @@ -53,6 +55,7 @@ ROLE_USER_TYPE: dict[str, str] = { "customer": "customer", "risk_operator": "employee", "admin": "employee", + "advisor": "employee", } #: 客户的开户状态。风控扫描等链路会读它,写成 `closed` 会让部分规则不成立。 diff --git a/tools/grant_advisor_role.py b/tools/grant_advisor_role.py new file mode 100644 index 0000000..2599a7c --- /dev/null +++ b/tools/grant_advisor_role.py @@ -0,0 +1,224 @@ +"""建立投顾(`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()) diff --git a/tools/login_console.py b/tools/login_console.py index 347959c..580c157 100644 --- a/tools/login_console.py +++ b/tools/login_console.py @@ -103,11 +103,20 @@ PAGE = """ (下面按钮按角色给,方便核对权限)
+(还没有请求)