178 lines
8.7 KiB
Python
178 lines
8.7 KiB
Python
"""Configure a backend integration secret or verify an existing DingTalk operator."""
|
||
|
||
import argparse
|
||
import asyncio
|
||
import json
|
||
import re
|
||
import secrets
|
||
from pathlib import Path
|
||
|
||
from app.core.exceptions import AppException
|
||
from app.database.session import SessionFactory, engine
|
||
from app.models.entities import UserAccount
|
||
from app.repositories.users import UserRepository
|
||
from app.schemas.integration_risk import IntegrationOperator
|
||
from app.services.integration_auth import authorize_operator
|
||
from dotenv import dotenv_values
|
||
from sqlalchemy import select
|
||
|
||
|
||
def configure_client(env_file: Path, client_id: str) -> bool:
|
||
"""Keep existing secrets and unrelated settings; never print secret material."""
|
||
if not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", client_id):
|
||
raise ValueError("client-id 仅允许字母、数字、下划线和短横线,最长 64 位")
|
||
text = env_file.read_text(encoding="utf-8-sig") if env_file.exists() else ""
|
||
raw = dotenv_values(env_file).get("RISK_INTEGRATION_CLIENTS") if env_file.exists() else None
|
||
clients = json.loads(raw or "{}")
|
||
if not isinstance(clients, dict) or any(
|
||
not isinstance(key, str)
|
||
or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", key)
|
||
or not isinstance(value, str)
|
||
or len(value) < 32
|
||
for key, value in clients.items()
|
||
):
|
||
raise ValueError("已有 RISK_INTEGRATION_CLIENTS 配置无效,请检查 JSON 和密钥长度")
|
||
if client_id in clients:
|
||
return False
|
||
clients[client_id] = secrets.token_urlsafe(32)
|
||
# Prefer readable single-quoted JSON; double-escape if an existing secret has an apostrophe.
|
||
encoded = json.dumps(clients, ensure_ascii=True, separators=(",", ":"))
|
||
value = "'" + encoded + "'" if "'" not in encoded else json.dumps(encoded, ensure_ascii=True)
|
||
line = f"RISK_INTEGRATION_CLIENTS={value}"
|
||
lines = text.splitlines()
|
||
indexes = [
|
||
i for i, current in enumerate(lines) if re.match(r"^\s*(?:export\s+)?RISK_INTEGRATION_CLIENTS\s*=", current)
|
||
]
|
||
if indexes:
|
||
lines[indexes[0]] = line
|
||
lines = [current for i, current in enumerate(lines) if i not in indexes[1:]]
|
||
else:
|
||
lines.extend(["", "# 外部系统调用密钥(仅保存在双方后端)", line])
|
||
env_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||
return True
|
||
|
||
|
||
async def check_operator(union_id: str, user_id: str | None) -> None:
|
||
try:
|
||
async with SessionFactory() as session:
|
||
user = await authorize_operator(
|
||
session,
|
||
IntegrationOperator(
|
||
dingtalk_union_id=union_id,
|
||
dingtalk_user_id=user_id,
|
||
),
|
||
)
|
||
print(f"检查通过:本地用户 {user.id},姓名 {user.name},角色 {user.role},账号启用,有风险评估操作权限。")
|
||
finally:
|
||
await engine.dispose()
|
||
|
||
|
||
def read_operator_mappings(env_file: Path) -> dict:
|
||
raw = dotenv_values(env_file).get("RISK_INTEGRATION_OPERATOR_MAPPINGS") if env_file.exists() else None
|
||
mappings = json.loads(raw or "{}")
|
||
if not isinstance(mappings, dict) or any(
|
||
not isinstance(client, str)
|
||
or not isinstance(accounts, dict)
|
||
or any(
|
||
not isinstance(external, str) or not external or not isinstance(local, str) or not local or len(local) > 20
|
||
for external, local in accounts.items()
|
||
)
|
||
for client, accounts in mappings.items()
|
||
):
|
||
raise ValueError("外部账号映射配置无效")
|
||
return mappings
|
||
|
||
|
||
def write_operator_mapping(env_file: Path, client_id: str, external_id: str, employee_no: str) -> None:
|
||
mappings = read_operator_mappings(env_file)
|
||
accounts = mappings.setdefault(client_id, {})
|
||
if external_id in accounts and accounts[external_id] != employee_no:
|
||
raise ValueError("外部账号已映射到其他本地账号,不能自动覆盖")
|
||
accounts[external_id] = employee_no
|
||
encoded = json.dumps(mappings, ensure_ascii=True, separators=(",", ":"))
|
||
value = "'" + encoded + "'" if "'" not in encoded else json.dumps(encoded)
|
||
key = "RISK_INTEGRATION_OPERATOR_MAPPINGS"
|
||
lines = env_file.read_text(encoding="utf-8-sig").splitlines() if env_file.exists() else []
|
||
indexes = [i for i, line in enumerate(lines) if re.match(r"^\s*(?:export\s+)?" + key + r"\s*=", line)]
|
||
if indexes:
|
||
lines[indexes[0]] = f"{key}={value}"
|
||
lines = [line for i, line in enumerate(lines) if i not in indexes[1:]]
|
||
else:
|
||
lines.extend(["", "# 已批准的外部操作人映射", f"{key}={value}"])
|
||
env_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||
|
||
|
||
async def allow_external_user(env_file: Path, client_id: str, external_id: str, employee_no: str, name: str) -> None:
|
||
if not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", client_id):
|
||
raise ValueError("调用系统格式错误")
|
||
if not external_id.strip() or len(external_id) > 128 or not name.strip() or len(name) > 50:
|
||
raise ValueError("账号标识或姓名格式错误")
|
||
if not re.fullmatch(r"[A-Za-z0-9_-]{1,20}", employee_no):
|
||
raise ValueError("本地账号编号格式错误")
|
||
mappings = read_operator_mappings(env_file)
|
||
existing = mappings.get(client_id, {}).get(external_id)
|
||
if existing and existing != employee_no:
|
||
raise ValueError("已有映射不能自动覆盖")
|
||
clients = json.loads(dotenv_values(env_file).get("RISK_INTEGRATION_CLIENTS") or "{}")
|
||
if client_id not in clients:
|
||
raise ValueError("请先配置调用系统密钥")
|
||
try:
|
||
async with SessionFactory() as session:
|
||
user = await session.scalar(select(UserAccount).where(UserAccount.emp_no == employee_no))
|
||
if user is None:
|
||
permissions = await UserRepository(session).permissions_for_role("user", operate=True)
|
||
if not any(item.func_key == "risk.assess" for item in permissions):
|
||
raise ValueError("普通角色无评估权限,请由管理员配置")
|
||
user = UserAccount(emp_no=employee_no, name=name, role="user", status="启用")
|
||
session.add(user)
|
||
await session.flush()
|
||
elif user.name != name or user.status != "启用":
|
||
raise ValueError("已有本地账号姓名不一致或已停用,不能自动修改")
|
||
user_id = user.id
|
||
await session.commit()
|
||
write_operator_mapping(env_file, client_id, external_id, employee_no)
|
||
print(f"已配置外部用户:{name}({external_id}),调用系统 {client_id},本地用户编号 {user_id}。")
|
||
print("保持本地账号权限;新建账号为普通用户,无密码及钉钉绑定。请重启后端。")
|
||
finally:
|
||
await engine.dispose()
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
actions = parser.add_subparsers(dest="action", required=True)
|
||
configure = actions.add_parser("configure", help="Generate a missing client secret without printing it")
|
||
configure.add_argument("--client-id", default="dieya-plm")
|
||
configure.add_argument("--env-file", type=Path, default=Path(".env"))
|
||
check = actions.add_parser("check-user", help="Check UnionId, optional UserId and local permission")
|
||
check.add_argument("--union-id", required=True)
|
||
check.add_argument("--dingtalk-user-id")
|
||
allow = actions.add_parser("allow-user", help="Explicitly register a non-DingTalk operator for one client")
|
||
allow.add_argument("--client-id", default="dieya-plm")
|
||
allow.add_argument("--external-user-id", required=True)
|
||
allow.add_argument("--employee-no", required=True)
|
||
allow.add_argument("--name", required=True)
|
||
allow.add_argument("--env-file", type=Path, default=Path(".env"))
|
||
args = parser.parse_args()
|
||
try:
|
||
if args.action == "configure":
|
||
changed = configure_client(args.env_file, args.client_id)
|
||
print(f"{args.client_id}:" + ("密钥已生成并写入配置。" if changed else "已配置,保留现有密钥。"))
|
||
print("密钥不会打印。请重启后端,并通过安全渠道向调用方提供该 clientId 对应的密钥。")
|
||
elif args.action == "allow-user":
|
||
asyncio.run(
|
||
allow_external_user(args.env_file, args.client_id, args.external_user_id, args.employee_no, args.name)
|
||
)
|
||
else:
|
||
asyncio.run(check_operator(args.union_id, args.dingtalk_user_id))
|
||
except AppException as exc:
|
||
print(f"检查不通过({exc.status_code}):{exc.message}")
|
||
return 1
|
||
except (ValueError, OSError):
|
||
print("配置或参数无效,请检查文件、JSON、标识格式及密钥长度;未显示敏感内容。")
|
||
return 1
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|