- 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 做权限对账
156 lines
6.0 KiB
Python
156 lines
6.0 KiB
Python
"""补齐运营(`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())
|