feat: 统一登录门户(按角色分流五套工作台)+ 补齐权限覆盖缺口

- tools/portal.py:客户/风控/运营/管理员/投顾五个工作台,走真实登录与真实接口
- 新增 6 个此前从未建过的权限码(promotion:* 4 个 / financial:nl2sql:read / probe:read),
  它们让推广材料与金融 NL2SQL 两条线对所有角色都是 403
- 投顾权限 10 → 25 项:补投资目标创建确认、agent:run、行情、知识检索、客户画像、推广材料
- 运营补 financial:nl2sql:read;create_test_user.py 不再硬编码角色 id(改按 role_code 查库)
- portal 的写请求补 Idempotency-Key 头(漏了会被 AGENT_INPUT_INVALID 拒)
- 新增 tools/check_permission_coverage.py 做权限对账
This commit is contained in:
2026-09-12 15:21:34 +08:00
parent 4c00db5636
commit bfd622b592
6 changed files with 1479 additions and 16 deletions
+149
View File
@@ -0,0 +1,149 @@
"""对账:代码声明的权限码 vs 库里存在的 vs 各角色实际拥有的(只读)。
## 为什么需要它
权限缺失的表现一律是 `AGENT_PERMISSION_DENIED: 缺少操作权限`,但底下有**三种完全不同的原因**,
报错区分不出来,排查时极易往错的方向找:
1. **权限码在库里根本不存在**(代码写了、没人建)→ 任何角色都过不去,
看起来却像"这个角色没授权";
2. 权限码存在,但**没授给这个角色**(例如投顾缺 `investment-goal:write:self`);
3. 角色本身没有对应的**接口范围或界面**(例如运营只有 `offsite:write`,
而它要用的 `financial:nl2sql:read` 从没建过)。
这类问题**服务层测试测不出来** —— 那层直接构造 `RequestContext`,权限是测试自己塞的。
只有把"代码声明"和"库里数据"放一起比才看得见。2026-09-12 用它在主干上查出
6 个不存在的权限码(`promotion:*` 四个导致推广材料整条线 403、`financial:nl2sql:read`、
`probe:read`)。
只读:不连业务逻辑、不改任何数据。
用法:
python tools/check_permission_coverage.py
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.core.config import get_settings # noqa: E402
PROJECT_ROOT = Path(__file__).resolve().parents[1]
#: 代码里声明权限码的几种写法。
PATTERNS: tuple[str, ...] = (
r'require\(\s*context\s*,\s*"([^"]+)"',
r'^\s*permission\s*[:=]\s*"([^"]+)"',
r'^[A-Z_]*PERMISSION[A-Z_]*\s*[:=]\s*"([^"]+)"',
r'required_permission\s*=\s*"([^"]+)"',
)
CODE_SHAPE = re.compile(r"^[a-z][a-z0-9_-]*(?::[a-z0-9_-]+)+$")
def collect_required(root: Path = PROJECT_ROOT) -> dict[str, set[str]]:
found: dict[str, set[str]] = {}
for path in (root / "app").rglob("*.py"):
text = path.read_text(encoding="utf-8", errors="replace")
for pattern in PATTERNS:
for match in re.finditer(pattern, text, re.MULTILINE):
code = match.group(1)
if CODE_SHAPE.match(code):
found.setdefault(code, set()).add(path.relative_to(root).as_posix())
return found
def load_from_db() -> tuple[dict[str, int], dict[str, set[str]]]:
"""返回 `(权限码 → id, 角色 → 权限码集合)`。"""
settings = get_settings()
dsn = settings.mysql_dsn.split("://", 1)[1]
credentials, location = dsn.split("@", 1)
user, password = credentials.split(":", 1)
host_port, database = location.split("/", 1)
host, _, port = host_port.partition(":")
return _query(host, int(port or 3306), user, password, database)
def _query(host: str, port: int, user: str, password: str, database: str):
import asyncio
import asyncmy
async def run():
connection = await asyncmy.connect(
host=host, port=port, user=user, password=password, db=database
)
try:
cursor = connection.cursor()
await cursor.execute("SELECT id, permission_code FROM sys_permission")
permissions = {str(code): int(pid) for pid, code in await cursor.fetchall()}
await cursor.execute(
"""
SELECT r.role_code, p.permission_code
FROM sys_role r
LEFT JOIN sys_role_permission rp ON rp.role_id = r.id
LEFT JOIN sys_permission p ON p.id = rp.permission_id
"""
)
roles: dict[str, set[str]] = {}
for role_code, permission_code in await cursor.fetchall():
roles.setdefault(str(role_code), set())
if permission_code:
roles[str(role_code)].add(str(permission_code))
return permissions, roles
finally:
connection.close()
return asyncio.run(run())
def main() -> int:
required = collect_required()
permissions, roles = load_from_db()
print(f"代码里声明的权限码:{len(required)} 个")
print(f"库里已有的权限码:{len(permissions)} 个")
missing = sorted(code for code in required if code not in permissions)
print()
print(f"【一】代码要求、但库里不存在(任何角色都过不去):{len(missing)} 个")
for code in missing:
where = sorted(required[code])[:3]
print(f" ✗ {code:<44} 用于 {', '.join(where)}")
unused = sorted(code for code in permissions if code not in required)
if unused:
print()
print(f"【二】库里有、代码里没直接搜到(可能是变量拼接或历史遗留):{len(unused)} 个")
for code in unused:
holders = sorted(r for r, s in roles.items() if code in s)
print(f" · {code:<44} 持有角色 {holders or '无'}")
print()
print("【三】各角色的权限数,以及「代码要求却没拿到」的码:")
for role, codes in sorted(roles.items()):
lacks = sorted(c for c in required if c in permissions and c not in codes)
print(f" {role:<16} 共 {len(codes):>2} 项;代码要求但未授予 {len(lacks)} 个")
for code in lacks[:12]:
print(f" - {code}")
if len(lacks) > 12:
print(f" … 另有 {len(lacks) - 12} 个")
if missing:
print()
print("处置:把这几个码并进 `tools/seed_test_rbac.py` 的 `PERMISSIONS`(那是唯一定义源),")
print(" 再按角色在对应的 `grant_*.py` 里授权。")
return 1
print()
print("结论:代码要求的权限码在库里都存在。")
return 0
if __name__ == "__main__":
sys.exit(main())
+18 -15
View File
@@ -45,15 +45,14 @@ from app.service.identity_service import IdentityService # noqa: E402
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]
#: 库里现成的角色。新用户复用它们。
#: 要引入**新角色**得同时定义它的权限集合(`sys_role_permission`)——
#: `advisor` 就是由 `tools/grant_advisor_role.py` 建立的。
ROLE_IDS: dict[str, int] = {
"customer": 9001,
"risk_operator": 9002,
"admin": 9003,
"advisor": 9004,
}
#: 可创建的角色。**不再硬编码角色 id** —— 角色 id 是环境数据
#: (`operator` 就是自增出来的 `1986594485028610`,各环境不同),硬编码会让脚本换个
#: 环境就失效。现在运行时按 `role_code` 查库。
#: 新角色得先有权限集合(`sys_role_permission`):`advisor` 由
#: `tools/grant_advisor_role.py` 建,`operator` 由 `tools/grant_operator_role.py` 补。
KNOWN_ROLES: tuple[str, ...] = (
"customer", "risk_operator", "admin", "advisor", "operator",
)
#: 角色 → `sys_user.user_type`。注意这是 `user_type`,与 `employee_role` 不是一回事。
ROLE_USER_TYPE: dict[str, str] = {
@@ -61,6 +60,7 @@ ROLE_USER_TYPE: dict[str, str] = {
"risk_operator": "employee",
"admin": "employee",
"advisor": "employee",
"operator": "employee",
}
#: 客户的开户状态。风控扫描等链路会读它,写成 `closed` 会让部分规则不成立。
@@ -107,18 +107,21 @@ async def upsert_user(
*, user_id: int, username: str, role: str, password: str
) -> int:
"""建/更新账号并绑定角色,最后验证权限能解析出来。"""
role_id = ROLE_IDS[role]
user_type = ROLE_USER_TYPE[role]
now = datetime.now(UTC).replace(tzinfo=None)
assigned_at = now - timedelta(seconds=ASSIGN_BACKDATE_SECONDS)
async with SessionFactory() as session, session.begin():
role_exists = await session.scalar(
text("SELECT id FROM sys_role WHERE id = :role_id"), {"role_id": role_id}
role_id = await session.scalar(
text("SELECT id FROM sys_role WHERE role_code = :code"), {"code": role}
)
if role_exists is None:
print(f"[失败] 角色 {role}(id={role_id})不存在,先跑 tools/seed_test_rbac.py")
if role_id is None:
available = (
await session.scalars(text("SELECT role_code FROM sys_role ORDER BY id"))
).all()
print(f"[失败] 库里没有角色 {role}。现有角色:{list(available)}")
return 1
role_id = int(role_id)
# 覆盖语义:同一个 id 重跑不会堆出第二行。
await session.execute(
@@ -192,7 +195,7 @@ async def main() -> int:
parser.add_argument("--list", action="store_true", help="列出所有账号与角色")
parser.add_argument("--id", type=int, help="用户 id(9001-9003 已被演示账号占用)")
parser.add_argument("--username", help="登录用户名")
parser.add_argument("--role", choices=sorted(ROLE_IDS), help="角色")
parser.add_argument("--role", choices=KNOWN_ROLES, help="角色(库里需已存在该角色)")
parser.add_argument("--password", help="登录密码(仅限演示环境)")
args = parser.parse_args()
+22 -1
View File
@@ -71,10 +71,15 @@ ADVISOR_PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = (
(9043, "product-governance:sync", "product-governance", "sync", "all"),
)
#: 投顾拿哪些 —— 工作流那 10 个;治理类 6 个只给 admin。
#: 投顾拿哪些。2026-09-12 补齐:此前只给了 10 项工作流权限,结果投顾**用不了**
#: 投资目标创建/确认、跑不了 Agent、查不了行情与知识、看不了所服务客户的画像 ——
#: 表现出来就是一片 `AGENT_PERMISSION_DENIED`。下面每一项都对应代码里真实用到的地方。
ADVISOR_GRANTED_CODES: tuple[str, ...] = (
# 投顾工作流(定义在种子的 9020-9034)
"asset-allocation:generate:self",
"investment-goal:read:self",
"investment-goal:write:self", # 新建投资目标
"investment-goal:confirm:self", # 与客户确认目标
"investment-goal:review",
"investment-goal:publish",
"portfolio-analysis:read:self",
@@ -83,6 +88,22 @@ ADVISOR_GRANTED_CODES: tuple[str, ...] = (
"product-recommendation:generate:self",
"product-recommendation:review",
"product-recommendation:publish",
# 平台通用:投顾同样要跑 Agent、查行情、检索知识、看所服务客户的画像
"agent:run",
"suitability:read",
"fund:quote:read",
"knowledge:query",
"knowledge:reference:read",
"memory:read:customer",
"conversation:create",
"conversation:close",
"conversation:feedback",
# 推广材料(`promotion_material_service.py:164` 专门判 `advisor`)
"promotion:read",
"promotion:write",
"promotion:deliver",
# 金融数据(`financial_nl2sql_service.py` 的角色白名单含 advisor)
"financial:nl2sql:read",
)
#: admin 角色 id(`seed_test_rbac.py` 建的)。
+155
View File
@@ -0,0 +1,155 @@
"""补齐运营(`operator`)角色的权限,并按需创建该角色。
## 为什么需要它
`operator` 是场外/推广线建的角色,**不在 `seed_test_rbac.py` 的 9001-9003 里** ——
所以种子既不会创建它,也不会清理它的绑定。库里这个角色长期**只有 1 项权限**
(`offsite:write`),但两条线实际要求的并不一样:
| 功能 | 门槛类型 | 位置 |
|---|---|---|
| 场外基金运营(邮件、单据、确认、通知、结算) | **角色门槛** `{"operator","risk_operator","admin","super_admin"}` | `offsite_fund_service.py:2600` |
| 金融 NL2SQL | **权限码** `financial:nl2sql:read`(角色白名单含 `operator`) | `financial_nl2sql_service.py:272` |
也就是说:场外主体功能**本来就该能用**(靠角色),运营真正缺的是 NL2SQL 那一个码;
而"看不到运营界面"是前端没做,不是权限问题。
## 给哪些 —— 按代码真实要求,不多给
运营不做治理、不看审计、不发布配置,因此**不给** `audit:read` / `config:*` /
`product-governance:*` / `knowledge:manage`。需要排查权限缺口时跑
`python tools/check_permission_coverage.py`。
本脚本**只增不删**,可重复执行。
用法:
python tools/grant_operator_role.py --dry-run
python tools/grant_operator_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]
OPERATOR_ROLE_CODE = "operator"
OPERATOR_ROLE_NAME = "运营专员"
#: 运营该有的权限码。`offsite:write` 由场外线创建;其余在本种子的 9051-9056 号段里定义。
OPERATOR_GRANTED_CODES: tuple[str, ...] = (
# 场外运营(角色门槛之外,这个码是场外线自己声明的)
"offsite:write",
# 金融 NL2SQL:角色白名单 {advisor, operator, admin, super_admin} 含 operator
"financial:nl2sql:read",
)
async def apply(*, dry_run: bool) -> int:
now = datetime.now(UTC).replace(tzinfo=None)
async with SessionFactory() as session, session.begin():
role_id = await session.scalar(
text("SELECT id FROM sys_role WHERE role_code = :code"), {"code": OPERATOR_ROLE_CODE}
)
print(f"角色 {OPERATOR_ROLE_CODE}:{'已存在 id=' + str(role_id) if role_id else '将新建(自动分配 id)'}")
permission_ids = dict(
(await session.execute(text("SELECT permission_code, id FROM sys_permission"))).all()
)
missing = [code for code in OPERATOR_GRANTED_CODES if code not in permission_ids]
print(f"权限码:库里已有 {len(permission_ids)} 个;本脚本需要的 {len(OPERATOR_GRANTED_CODES)} 个中缺 {len(missing)} 个")
for code in missing:
print(f" ✗ 缺失:{code}(应先跑 tools/seed_test_rbac.py)")
if dry_run:
print("\n[dry-run] 未写入任何数据。")
return 0
if role_id is None:
await session.execute(
text(
"INSERT INTO sys_role (role_code, role_name, status, created_at, updated_at)"
" VALUES (:code, :name, 'active', :now, :now)"
),
{"code": OPERATOR_ROLE_CODE, "name": OPERATOR_ROLE_NAME, "now": now},
)
role_id = await session.scalar(
text("SELECT id FROM sys_role WHERE role_code = :code"),
{"code": OPERATOR_ROLE_CODE},
)
role_id = int(role_id)
have = set(
(await session.scalars(
text("SELECT permission_id FROM sys_role_permission WHERE role_id = :r"),
{"r": role_id},
)).all()
)
added = 0
for code in OPERATOR_GRANTED_CODES:
permission_id = permission_ids.get(code)
if permission_id is None or int(permission_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_id, "p": int(permission_id), "now": now},
)
added += 1
print(f"授权:{OPERATOR_ROLE_CODE} 新增 {added} 项(目标共 {len(OPERATOR_GRANTED_CODES)} 项)")
await verify()
return 0
async def verify() -> None:
"""用真实链路验证:按权限码列出该角色最终拥有什么。"""
async with SessionFactory() as session:
rows = (
await session.execute(
text(
"""
SELECT p.permission_code
FROM sys_role r
JOIN sys_role_permission rp ON rp.role_id = r.id
JOIN sys_permission p ON p.id = rp.permission_id
WHERE r.role_code = :code
ORDER BY p.permission_code
"""
),
{"code": OPERATOR_ROLE_CODE},
)
).all()
codes = [str(row[0]) for row in rows]
print(f"\n{OPERATOR_ROLE_CODE} 实测权限 {len(codes)} 项:{codes}")
lacked = [c for c in OPERATOR_GRANTED_CODES if c not in codes]
if lacked:
print(f"[失败] 仍未绑定的码:{lacked}")
raise SystemExit(1)
print(
"\n下一步:给运营账号绑这个角色 ——\n"
" python tools/create_test_user.py --id 9006 --username offsite_t "
"--role operator --password offsite123"
)
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())
+1120
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -108,6 +108,21 @@ PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = (
(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"),
)
# 客户:业务侧自助能力(自己的会话、反馈、转人工、自己的记忆画像)。