games-development-ai/deploy/manage-dogfood-reviewers.py

363 lines
16 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""通过 staging 管理 API 创建或轮换内部狗粮审核账号。"""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import secrets
import socket
import stat
import string
import sys
from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
BASE_URL = "http://127.0.0.1:48080/admin-api"
EXPECTED_HOSTNAME = "mini-desktop"
EXPECTED_ACK = "ROTATE_DOGFOOD_REVIEWERS"
DEFAULT_ADMIN_USERNAME = "admin"
DEFAULT_PUBLIC_ADMIN_PASSWORD = "admin123"
ADMIN_LOGIN_REJECTION_CODES = frozenset({1_002_000_000, 1_002_000_001})
REVIEWER_PERMISSIONS = frozenset({
"project:review:query",
"project:review:approve",
"telemetry:game-stat:query",
})
READONLY_PERMISSIONS = frozenset({
"project:review:query",
"telemetry:game-stat:query",
})
ACCOUNT_SPECS = (
("dogfood_reviewer", "狗粮审核员", "dogfood_reviewer"),
("dogfood_readonly", "狗粮只读员", "dogfood_readonly"),
)
class ReviewerProvisionError(RuntimeError):
"""受控审核账号操作不满足安全前提。"""
def generate_password() -> str:
"""生成符合管理端 4-16 位约束的高熵随机密码。"""
alphabet = string.ascii_letters + string.digits
while True:
password = "".join(secrets.choice(alphabet) for _ in range(16))
if (any(char.islower() for char in password)
and any(char.isupper() for char in password)
and any(char.isdigit() for char in password)):
return password
class ApiClient:
"""固定访问本机 staging 管理 APIBearer token 只保存在进程内存。"""
def __init__(self, admin_token: str, timeout: float = 10.0):
if not admin_token:
raise ReviewerProvisionError("管理员 token 不能为空")
self.admin_token = admin_token
self.timeout = timeout
def _request(self, method: str, path: str, *, query: dict[str, Any] | None = None,
payload: dict[str, Any] | None = None) -> Any:
url = BASE_URL + path
if query:
url += "?" + urlencode(query)
body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
request = Request(url, data=body, method=method, headers={
"Authorization": f"Bearer {self.admin_token}",
"Content-Type": "application/json",
})
try:
with urlopen(request, timeout=self.timeout) as response:
result = json.loads(response.read().decode("utf-8"))
except (HTTPError, URLError, TimeoutError, json.JSONDecodeError) as exc:
# 不回显响应体,避免认证中间件或代理错误页带出敏感请求信息。
raise ReviewerProvisionError(f"管理 API 调用失败 method={method} path={path}") from exc
if result.get("code") != 0:
raise ReviewerProvisionError(
f"管理 API 拒绝操作 method={method} path={path} code={result.get('code')}")
return result.get("data")
def verify_rbac(self) -> dict[str, dict[str, Any]]:
"""校验两个专用角色存在且权限集合精确,作为角色权限验收入口。"""
roles = self._request("GET", "/system/role/simple-list")
# simple-list 不返回 permission必须走受 system:menu:query 保护的完整菜单接口。
menus = self._request("GET", "/system/menu/list")
menu_permissions = {item["id"]: item.get("permission") for item in menus}
expected = {
"dogfood_reviewer": REVIEWER_PERMISSIONS,
"dogfood_readonly": READONLY_PERMISSIONS,
}
verified: dict[str, dict[str, Any]] = {}
for role_code, expected_permissions in expected.items():
matches = [role for role in roles if role.get("code") == role_code]
if len(matches) != 1:
raise ReviewerProvisionError(f"角色必须唯一存在 role={role_code} actual={len(matches)}")
role_id = matches[0]["id"]
menu_ids = self._request("GET", "/system/permission/list-role-menus",
query={"roleId": role_id})
actual_permissions = {menu_permissions.get(menu_id) for menu_id in menu_ids}
if (len(menu_ids) != len(expected_permissions)
or None in actual_permissions
or actual_permissions != set(expected_permissions)):
raise ReviewerProvisionError(
f"角色权限不精确 role={role_code} actual={sorted(map(str, actual_permissions))}")
verified[role_code] = {"id": role_id, "permissions": actual_permissions}
return verified
def find_user(self, username: str) -> dict[str, Any] | None:
page = self._request("GET", "/system/user/page", query={
"pageNo": 1,
"pageSize": 10,
"username": username,
})
matches = [user for user in page["list"] if user.get("username") == username]
if len(matches) > 1:
raise ReviewerProvisionError(f"审核账号不唯一 username={username}")
return matches[0] if matches else None
def create_user(self, username: str, nickname: str, password: str) -> int:
return int(self._request("POST", "/system/user/create", payload={
"username": username,
"nickname": nickname,
"password": password,
"remark": "staging 内部狗粮专用;账号密码由受控工具轮换",
"postIds": [],
"email": "",
"mobile": "",
"sex": 0,
"status": 0,
}))
def update_password(self, user_id: int, password: str) -> None:
self._request("PUT", "/system/user/update-password",
payload={"id": user_id, "password": password})
def assign_user_roles(self, user_id: int, role_ids: list[int]) -> None:
self._request("POST", "/system/permission/assign-user-role",
payload={"userId": user_id, "roleIds": role_ids})
def update_user_status(self, user_id: int, status: int) -> None:
self._request("PUT", "/system/user/update-status",
payload={"id": user_id, "status": status})
def get_user_role_ids(self, user_id: int) -> set[int]:
role_ids = self._request("GET", "/system/permission/list-user-roles",
query={"userId": user_id})
return {int(role_id) for role_id in role_ids}
def verify_password_login_rejected(self, username: str, password: str) -> int:
"""匿名验证公开默认密码已失效,只接受密码错误或账号禁用两个确定结果。"""
body = json.dumps({"username": username, "password": password}).encode("utf-8")
request = Request(BASE_URL + "/system/auth/login", data=body, method="POST", headers={
"Content-Type": "application/json",
})
try:
with urlopen(request, timeout=self.timeout) as response:
result = json.loads(response.read().decode("utf-8"))
except HTTPError as exc:
try:
result = json.loads(exc.read().decode("utf-8"))
except json.JSONDecodeError as decode_error:
raise ReviewerProvisionError("默认超管负例登录返回非 JSON") from decode_error
except (URLError, TimeoutError, json.JSONDecodeError) as exc:
raise ReviewerProvisionError("默认超管负例登录调用失败") from exc
code = result.get("code")
if code not in ADMIN_LOGIN_REJECTION_CODES:
raise ReviewerProvisionError(f"默认公开超管密码未被确定拒绝 code={code}")
return int(code)
def rotate_reviewers(api: ApiClient, password_factory: Callable[[], str],
on_password: Callable[[str, str], None] | None = None) -> dict[str, Any]:
"""轮换审核账号并封禁默认公开超管;任一步失败均不签发通过证据。"""
roles = api.verify_rbac()
accounts: dict[str, str] = {}
evidence_accounts: list[dict[str, Any]] = []
for username, nickname, role_code in ACCOUNT_SPECS:
password = password_factory()
accounts[username] = password
if on_password is not None:
# 先持久化待应用密码,防止进程中断后已轮换账号的凭据永久丢失。
on_password(username, password)
user = api.find_user(username)
user_id = api.create_user(username, nickname, password) if user is None else int(user["id"])
# 禁用动作由后端同步删除该管理员的 MySQL 与 Redis OAuth2 token失败时保持禁用。
api.update_user_status(user_id, 1)
if user is not None:
api.update_password(user_id, password)
role_id = int(roles[role_code]["id"])
api.assign_user_roles(user_id, [role_id])
if api.get_user_role_ids(user_id) != {role_id}:
raise ReviewerProvisionError(f"账号角色未收敛 username={username}")
api.update_user_status(user_id, 0)
evidence_accounts.append({
"username": username,
"userId": user_id,
"roleCode": role_code,
"roleId": role_id,
"permissions": sorted(roles[role_code]["permissions"]),
"oldTokensInvalidatedByStatusCycle": True,
"status": "ENABLED",
})
# 默认公开超管必须最后处理:禁用会立即使当前及历史 token 失效,之后不能再依赖该 token 调特权接口。
admin_password = password_factory()
accounts[DEFAULT_ADMIN_USERNAME] = admin_password
if on_password is not None:
on_password(DEFAULT_ADMIN_USERNAME, admin_password)
admin_user = api.find_user(DEFAULT_ADMIN_USERNAME)
if admin_user is None:
raise ReviewerProvisionError("staging 默认 admin 账号不存在,无法完成公开凭据收口")
admin_user_id = int(admin_user["id"])
api.update_password(admin_user_id, admin_password)
api.update_user_status(admin_user_id, 1)
rejection_code = api.verify_password_login_rejected(
DEFAULT_ADMIN_USERNAME, DEFAULT_PUBLIC_ADMIN_PASSWORD)
evidence_accounts.append({
"username": DEFAULT_ADMIN_USERNAME,
"userId": admin_user_id,
"accountType": "DEFAULT_BOOTSTRAP_ADMIN",
"passwordRotated": True,
"oldTokensInvalidatedByDisable": True,
"defaultPasswordLoginCode": rejection_code,
"status": "DISABLED",
})
return {"accounts": accounts, "evidenceAccounts": evidence_accounts}
def build_rbac_evidence(roles: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
"""把权限验收结果转换为不含 token、密码的稳定证据结构。"""
return [{
"roleCode": role_code,
"roleId": int(role["id"]),
"permissions": sorted(role["permissions"]),
} for role_code, role in sorted(roles.items())]
def require_private_file(path: Path) -> str:
"""读取 0400/0600 普通文件,拒绝符号链接。"""
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, flags)
except OSError as exc:
raise ReviewerProvisionError(f"敏感输入无法安全打开 path={path}") from exc
with os.fdopen(descriptor, "r", encoding="utf-8") as handle:
file_stat = os.fstat(handle.fileno())
if not stat.S_ISREG(file_stat.st_mode):
raise ReviewerProvisionError(f"敏感输入必须是普通文件 path={path}")
if stat.S_IMODE(file_stat.st_mode) not in (0o400, 0o600):
raise ReviewerProvisionError(f"敏感输入权限必须是 0400 或 0600 path={path}")
value = handle.read().strip()
if not value:
raise ReviewerProvisionError(f"敏感输入不能为空 path={path}")
return value
def reserve_private_file(path: Path) -> None:
"""以 O_EXCL 预留 0600 输出,拒绝覆盖旧证据或跟随符号链接。"""
path.parent.mkdir(parents=True, exist_ok=True)
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
descriptor = os.open(path, flags, 0o600)
os.close(descriptor)
def write_private_json(path: Path, payload: dict[str, Any]) -> None:
"""只重写本次预留的 0600 普通文件。"""
flags = os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, flags)
except OSError as exc:
raise ReviewerProvisionError(f"输出无法安全打开 path={path}") from exc
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
file_stat = os.fstat(handle.fileno())
if not stat.S_ISREG(file_stat.st_mode):
raise ReviewerProvisionError(f"输出不再是普通文件 path={path}")
if stat.S_IMODE(file_stat.st_mode) != 0o600:
raise ReviewerProvisionError(f"输出权限必须保持 0600 path={path}")
# 通过文件描述符复核类型和权限后再截断,避免检查前破坏被替换的目标。
os.ftruncate(handle.fileno(), 0)
json.dump(payload, handle, ensure_ascii=False, indent=2)
handle.write("\n")
def main() -> int:
parser = argparse.ArgumentParser(description="受控创建或轮换 staging 狗粮审核账号")
parser.add_argument("--ack", required=True, help=f"必须精确填写 {EXPECTED_ACK}")
parser.add_argument("--admin-token-file", type=Path, required=True)
parser.add_argument("--credentials-out", type=Path)
parser.add_argument("--evidence-out", type=Path, required=True)
parser.add_argument("--verify-only", action="store_true", help="只校验角色权限并输出脱敏证据")
args = parser.parse_args()
if args.ack != EXPECTED_ACK:
raise ReviewerProvisionError(f"ack 必须精确设为 {EXPECTED_ACK}")
hostname = socket.gethostname().split(".", 1)[0]
if hostname != EXPECTED_HOSTNAME:
raise ReviewerProvisionError(f"只允许在 {EXPECTED_HOSTNAME} 执行 actual={hostname}")
if not args.verify_only and args.credentials_out is None:
raise ReviewerProvisionError("轮换模式必须提供 --credentials-out")
if args.credentials_out is not None and args.credentials_out == args.evidence_out:
raise ReviewerProvisionError("凭据与证据必须写入不同文件")
admin_token = require_private_file(args.admin_token_file)
api = ApiClient(admin_token)
if args.verify_only:
reserve_private_file(args.evidence_out)
roles = api.verify_rbac()
write_private_json(args.evidence_out, {
"status": "PASS",
"baseUrl": BASE_URL,
"roles": build_rbac_evidence(roles),
})
print(f"DOGFOOD_REVIEWER_RBAC_PASS roles={len(roles)} evidence={args.evidence_out}")
return 0
credentials_out = args.credentials_out
if credentials_out is None:
raise ReviewerProvisionError("轮换模式缺少凭据输出路径")
reserve_private_file(credentials_out)
try:
reserve_private_file(args.evidence_out)
except Exception:
credentials_out.unlink(missing_ok=True)
raise
generated_accounts: dict[str, str] = {}
def persist_pending(username: str, password: str) -> None:
generated_accounts[username] = password
write_private_json(credentials_out, {
"status": "PENDING",
"baseUrl": BASE_URL,
"accounts": generated_accounts,
})
result = rotate_reviewers(api, generate_password, persist_pending)
write_private_json(credentials_out, {
"status": "COMPLETE",
"baseUrl": BASE_URL,
"accounts": result["accounts"],
})
write_private_json(args.evidence_out, {
"status": "PASS",
"baseUrl": BASE_URL,
"accounts": result["evidenceAccounts"],
})
print(f"DOGFOOD_REVIEWER_PASS accounts={len(result['evidenceAccounts'])} evidence={args.evidence_out}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (ReviewerProvisionError, FileExistsError, FileNotFoundError, PermissionError) as exc:
print(f"DOGFOOD_REVIEWER_FAIL {exc}", file=sys.stderr)
raise SystemExit(1)