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:
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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` 建的)。
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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"),
|
||||
)
|
||||
|
||||
# 客户:业务侧自助能力(自己的会话、反馈、转人工、自己的记忆画像)。
|
||||
|
||||
Reference in New Issue
Block a user