Files
group_fqcd_jr/tools/grant_operator_role.py
T

174 lines
6.9 KiB
Python
Raw Normal View History

"""补齐运营(`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
2026-09-14 18:13:10 +08:00
from pathlib import Path
from sqlalchemy import text
2026-09-14 18:13:10 +08:00
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.infrastructure.db import SessionFactory # noqa: E402
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, ...] = (
2026-09-14 18:13:10 +08:00
# Agent 公共入口:没有该权限,请求会在 Agent 类型和工具权限校验之前被拒绝。
"agent:run",
# 场外运营(角色门槛之外,这个码是场外线自己声明的)
"offsite:write",
# 金融 NL2SQL:角色白名单 {advisor, operator, admin, super_admin} 含 operator。
# ⚠️ 全库与 NL2SQL 相关的权限码**只有这一个**(`offsite:nl2sql` 在
# `sys_permission` 里并不存在,是 `offsite_fund_service` 里 any-of 校验的死值),
# 所以"NL2SQL 的全部权限"就是它。
"financial:nl2sql:read",
# 产品推介材料(生成/查看/审核/发送):`promotion_material_service.py` 的八处
# `_require` 恰好只用到这四个码(72/91/112/136 要 write,150/161 要 read,
# 211 要 review,229 要 deliver)。运营要"生成推介材料"就必须四个齐全 ——
# 只给 write 会在查看详情(read)那一步 403。
"promotion:write",
"promotion:read",
"promotion:review",
"promotion:deliver",
)
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())