fix(release): harden dogfood identities and provisioning lease

This commit is contained in:
lili 2026-07-22 23:14:16 -07:00
parent 410d35dc64
commit 65aecc56aa
19 changed files with 1079 additions and 127 deletions

View File

@ -0,0 +1,362 @@
#!/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)

View File

@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""内部狗粮审核账号与 RBAC 安全边界测试。"""
from pathlib import Path
import importlib.util
import os
import stat
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[2]
TOOL = ROOT / "deploy" / "manage-dogfood-reviewers.py"
STAGING_DB = ROOT / "game-cloud" / "huijing-server" / "src" / "main" / "resources" / "db" / "staging"
STAGING_CONFIG = ROOT / "game-cloud" / "huijing-server" / "src" / "main" / "resources" / "application-staging.yaml"
class ReviewerSecurityTest(unittest.TestCase):
@staticmethod
def load_module():
spec = importlib.util.spec_from_file_location("dogfood_reviewers", TOOL)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_staging_explicitly_disables_mock_authentication(self):
config = STAGING_CONFIG.read_text(encoding="utf-8")
self.assertIn("mock-enable: false", config)
self.assertNotIn("mock-enable: true", config)
def test_rbac_seed_is_repeatable_and_contains_no_login_credentials(self):
self.assertFalse((STAGING_DB / "V34.0.0__seed_dogfood_review_rbac.sql").exists())
migration = STAGING_DB / "R__seed_dogfood_review_rbac.sql"
sql = migration.read_text(encoding="utf-8")
self.assertNotIn("INSERT INTO system_users", sql)
self.assertNotIn("$2a$", sql)
self.assertIn("dogfood_reviewer", sql)
self.assertIn("dogfood_readonly", sql)
def test_rotation_uses_exact_roles_and_invalidates_old_tokens(self):
module = self.load_module()
api = FakeApi(module)
result = module.rotate_reviewers(api, module.generate_password)
self.assertEqual({"dogfood_reviewer", "dogfood_readonly", "admin"}, set(result["accounts"]))
self.assertEqual([201], api.assigned_roles[101])
self.assertEqual([202], api.assigned_roles[102])
self.assertEqual([1, 0], api.status_updates[101])
self.assertEqual([1, 0], api.status_updates[102])
self.assertEqual([1], api.status_updates[1])
self.assertEqual(("status", 1, 1), api.events[-2])
self.assertEqual(("login-rejected", "admin", "admin123"), api.events[-1])
self.assertNotEqual(result["accounts"]["dogfood_reviewer"],
result["accounts"]["dogfood_readonly"])
for password in result["accounts"].values():
self.assertEqual(16, len(password))
self.assertNotEqual("admin123", password)
self.assertTrue(any(char.islower() for char in password))
self.assertTrue(any(char.isupper() for char in password))
self.assertTrue(any(char.isdigit() for char in password))
def test_rotation_creates_missing_accounts_before_fail_closed_status_cycle(self):
module = self.load_module()
api = FakeApi(module, existing=False)
result = module.rotate_reviewers(api, module.generate_password)
self.assertEqual(["dogfood_reviewer", "dogfood_readonly"], api.created_users)
self.assertEqual([1], api.updated_password_users)
self.assertEqual([1, 0], api.status_updates[101])
self.assertEqual([1, 0], api.status_updates[102])
self.assertEqual([1], api.status_updates[1])
self.assertEqual(3, len(result["evidenceAccounts"]))
def test_api_rbac_verification_reads_full_menu_permissions(self):
module = self.load_module()
responses = FakeRbacResponses()
api = module.ApiClient("test-admin-token")
api._request = responses.request
roles = api.verify_rbac()
self.assertEqual(set(module.REVIEWER_PERMISSIONS), roles["dogfood_reviewer"]["permissions"])
self.assertIn(("GET", "/system/menu/list"), responses.calls)
self.assertNotIn(("GET", "/system/menu/simple-list"), responses.calls)
evidence = module.build_rbac_evidence(roles)
self.assertEqual(["dogfood_readonly", "dogfood_reviewer"],
[item["roleCode"] for item in evidence])
def test_sensitive_files_require_private_permissions_and_no_overwrite(self):
module = self.load_module()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
token_file = root / "admin.token"
token_file.write_text("secret-token\n", encoding="utf-8")
os.chmod(token_file, 0o600)
self.assertEqual("secret-token", module.require_private_file(token_file))
output = root / "credentials.json"
module.reserve_private_file(output)
self.assertEqual(0o600, stat.S_IMODE(output.stat().st_mode))
with self.assertRaises(FileExistsError):
module.reserve_private_file(output)
os.chmod(token_file, 0o644)
with self.assertRaises(module.ReviewerProvisionError):
module.require_private_file(token_file)
symlink = root / "admin-token-link"
symlink.symlink_to(token_file)
with self.assertRaises(module.ReviewerProvisionError):
module.require_private_file(symlink)
def test_default_admin_negative_login_is_anonymous_and_fail_closed(self):
module = self.load_module()
captured_requests = []
original_urlopen = module.urlopen
def rejected_urlopen(request, timeout):
captured_requests.append(request)
return FakeHttpResponse({"code": 1_002_000_001, "msg": "账号被禁用", "data": None})
module.urlopen = rejected_urlopen
try:
api = module.ApiClient("bootstrap-token")
self.assertEqual(1_002_000_001,
api.verify_password_login_rejected("admin", "admin123"))
self.assertIsNone(captured_requests[0].get_header("Authorization"))
module.urlopen = lambda request, timeout: FakeHttpResponse({
"code": 0,
"msg": "",
"data": {"accessToken": "unexpected"},
})
with self.assertRaises(module.ReviewerProvisionError):
api.verify_password_login_rejected("admin", "admin123")
finally:
module.urlopen = original_urlopen
class FakeApi:
"""仅模拟账号轮换所需接口,断言调用结果而非 HTTP 实现细节。"""
def __init__(self, module, existing=True):
self.module = module
self.existing = existing
self.assigned_roles = {}
self.status_updates = {1: [], 101: [], 102: []}
self.created_users = []
self.updated_password_users = []
self.events = []
def verify_rbac(self):
return {
"dogfood_reviewer": {"id": 201, "permissions": set(self.module.REVIEWER_PERMISSIONS)},
"dogfood_readonly": {"id": 202, "permissions": set(self.module.READONLY_PERMISSIONS)},
}
def find_user(self, username):
if username == "admin":
return {"id": 1, "username": username}
if not self.existing:
return None
return {"id": 101 if username == "dogfood_reviewer" else 102, "username": username}
def create_user(self, username, nickname, password):
if self.existing:
raise AssertionError("测试账号已存在,不应走创建分支")
self.assert_password(password)
self.created_users.append(username)
return 101 if username == "dogfood_reviewer" else 102
def update_password(self, user_id, password):
self.assert_password(password)
self.updated_password_users.append(user_id)
self.events.append(("password", user_id))
def assign_user_roles(self, user_id, role_ids):
self.assigned_roles[user_id] = role_ids
def update_user_status(self, user_id, status):
self.status_updates[user_id].append(status)
self.events.append(("status", user_id, status))
def get_user_role_ids(self, user_id):
return set(self.assigned_roles[user_id])
def verify_password_login_rejected(self, username, password):
self.events.append(("login-rejected", username, password))
return 1_002_000_000
@staticmethod
def assert_password(password):
if len(password) != 16:
raise AssertionError("随机密码长度不符合约束")
class FakeRbacResponses:
"""模拟管理 API 响应,校验工具使用真实包含 permission 的菜单入口。"""
def __init__(self):
self.calls = []
def request(self, method, path, **kwargs):
self.calls.append((method, path))
if path == "/system/role/simple-list":
return [
{"id": 201, "code": "dogfood_reviewer"},
{"id": 202, "code": "dogfood_readonly"},
]
if path == "/system/menu/list":
return [
{"id": 301, "permission": "project:review:query"},
{"id": 302, "permission": "project:review:approve"},
{"id": 303, "permission": "telemetry:game-stat:query"},
]
role_id = int(kwargs["query"]["roleId"])
return [301, 302, 303] if role_id == 201 else [301, 303]
class FakeHttpResponse:
"""提供 urllib 响应所需的最小上下文管理协议。"""
def __init__(self, payload):
import json
self.body = json.dumps(payload).encode("utf-8")
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
return False
def read(self):
return self.body
if __name__ == "__main__":
unittest.main()

View File

@ -3,6 +3,10 @@ package com.wanxiang.huijing.framework.env.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Clock;
import java.time.Instant;
import java.time.format.DateTimeParseException;
/**
* 内部狗粮环境的后端安全开关
*
@ -23,4 +27,54 @@ public class DogfoodProperties {
*/
private boolean feedEnabled = true;
/** 是否开启仅供账号预置使用的密码注册窄租约。 */
private boolean provisioningEnabled = false;
/**
* 账号预置租约的 UTC 到期时间格式为 ISO-8601 Instant例如 {@code 2026-07-22T12:05:00Z}
*/
private String provisioningExpiresAt;
/**
* 使用调用方提供的时钟判定预置租约确保启动门运行时边界和测试共享同一时间语义
*
* @param clock UTC 判定时钟
* @return 精确租约状态只有 {@link ProvisioningLeaseStatus#ACTIVE} 可以放行狗粮态密码注册
*/
public ProvisioningLeaseStatus getProvisioningLeaseStatus(Clock clock) {
if (!enabled) {
return ProvisioningLeaseStatus.DOGFOOD_DISABLED;
}
if (!provisioningEnabled) {
return ProvisioningLeaseStatus.PROVISIONING_DISABLED;
}
if (provisioningExpiresAt == null || provisioningExpiresAt.trim().isEmpty()) {
return ProvisioningLeaseStatus.EXPIRES_AT_MISSING;
}
final Instant expiresAt;
try {
expiresAt = Instant.parse(provisioningExpiresAt.trim());
} catch (DateTimeParseException ex) {
return ProvisioningLeaseStatus.EXPIRES_AT_INVALID;
}
return clock.instant().isBefore(expiresAt)
? ProvisioningLeaseStatus.ACTIVE
: ProvisioningLeaseStatus.EXPIRED;
}
/** 使用明确时钟判断预置租约是否有效。 */
public boolean isProvisioningLeaseActive(Clock clock) {
return getProvisioningLeaseStatus(clock) == ProvisioningLeaseStatus.ACTIVE;
}
/** 预置租约状态;日志只记录状态,不记录租约原值。 */
public enum ProvisioningLeaseStatus {
ACTIVE,
DOGFOOD_DISABLED,
PROVISIONING_DISABLED,
EXPIRES_AT_MISSING,
EXPIRES_AT_INVALID,
EXPIRED
}
}

View File

@ -9,6 +9,8 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import java.time.Clock;
/**
* 多环境的 Web 组件的自动配置
*
@ -25,6 +27,14 @@ public class HuijingEnvWebAutoConfiguration {
return new DogfoodAccessGuard(properties);
}
/**
* 狗粮预置租约统一使用 UTC 系统时钟测试可按 Bean 名替换为固定时钟
*/
@Bean(name = "dogfoodProvisioningClock")
public Clock dogfoodProvisioningClock() {
return Clock.systemUTC();
}
/**
* 创建 {@link EnvWebFilter} Bean
*/

View File

@ -67,6 +67,16 @@
<artifactId>bizlog-sdk</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,37 @@
package com.wanxiang.huijing.framework.security.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.env.Environment;
/**
* 内部狗粮认证启动不变量校验
*
* <p>mock token 会绕过真实 OAuth2 token 校验因此只要狗粮总开关开启
* mock 认证也必须关闭该校验在应用接流量前 fail-closed防止 Nacos 或命令行
* 覆盖 staging 文件后重新打开 {@code test<userId>} 身份通道</p>
*/
@Slf4j
@RequiredArgsConstructor
public class DogfoodSecurityStartupValidator implements ApplicationRunner {
private final SecurityProperties securityProperties;
private final Environment environment;
@Override
public void run(ApplicationArguments args) {
boolean dogfoodEnabled = environment.getProperty("huijing.dogfood.enabled", Boolean.class, false);
if (!dogfoodEnabled) {
return;
}
if (Boolean.TRUE.equals(securityProperties.getMockEnable())) {
// 只记录配置键和安全期望不记录 mockSecret 等敏感配置值
log.error("[dogfoodSecurityStartup] 狗粮态认证配置拒绝启动 key=huijing.security.mock-enable expected=false");
throw new IllegalStateException("狗粮态要求 huijing.security.mock-enable=false");
}
log.info("[dogfoodSecurityStartup] 狗粮态 mock 认证已关闭");
}
}

View File

@ -15,6 +15,7 @@ import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigureOrder;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.core.env.Environment;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@ -73,6 +74,14 @@ public class HuijingSecurityAutoConfiguration {
return new TokenAuthenticationFilter(securityProperties, globalExceptionHandler, oauth2TokenApi);
}
/**
* 注册狗粮态认证启动校验确保任何外部配置覆盖都不能重新打开 mock token
*/
@Bean
public DogfoodSecurityStartupValidator dogfoodSecurityStartupValidator(Environment environment) {
return new DogfoodSecurityStartupValidator(securityProperties, environment);
}
@Bean("ss") // 使用 Spring Security 的缩写方便使用
public SecurityFrameworkService securityFrameworkService(PermissionCommonApi permissionApi) {
return new SecurityFrameworkServiceImpl(permissionApi);

View File

@ -28,7 +28,8 @@ public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) {
log.debug("[commence][访问 URL({}) 时,没有登录]", request.getRequestURI(), e);
// 返回 401
// HTTP 状态与业务错误码都返回 401避免网关或客户端把未认证误判成成功响应
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
ServletUtils.writeJSON(response, CommonResult.error(UNAUTHORIZED));
}

View File

@ -0,0 +1,33 @@
package com.wanxiang.huijing.framework.security.config;
import org.junit.jupiter.api.Test;
import org.springframework.mock.env.MockEnvironment;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
/** 内部狗粮认证启动不变量测试。 */
class DogfoodSecurityStartupValidatorTest {
@Test
void shouldRejectMockAuthenticationWhenDogfoodIsEnabled() {
SecurityProperties properties = new SecurityProperties();
properties.setMockEnable(true);
MockEnvironment environment = new MockEnvironment()
.withProperty("huijing.dogfood.enabled", "true");
assertThrows(IllegalStateException.class,
() -> new DogfoodSecurityStartupValidator(properties, environment).run(null));
}
@Test
void shouldAllowMockAuthenticationOutsideDogfood() {
SecurityProperties properties = new SecurityProperties();
properties.setMockEnable(true);
MockEnvironment environment = new MockEnvironment()
.withProperty("huijing.dogfood.enabled", "false");
assertDoesNotThrow(() -> new DogfoodSecurityStartupValidator(properties, environment).run(null));
}
}

View File

@ -0,0 +1,64 @@
package com.wanxiang.huijing.framework.security.core.filter;
import com.wanxiang.huijing.framework.common.biz.system.oauth2.OAuth2TokenCommonApi;
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
import com.wanxiang.huijing.framework.security.config.SecurityProperties;
import com.wanxiang.huijing.framework.security.core.handler.AuthenticationEntryPointImpl;
import com.wanxiang.huijing.framework.security.core.util.SecurityFrameworkUtils;
import com.wanxiang.huijing.framework.web.config.WebProperties;
import com.wanxiang.huijing.framework.web.core.handler.GlobalExceptionHandler;
import com.wanxiang.huijing.framework.web.core.util.WebFrameworkUtils;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/** Token 认证过滤器的狗粮安全回归测试。 */
class TokenAuthenticationFilterTest {
@BeforeEach
void initializeWebApiPrefixes() {
// 生产环境由 Web 自动配置完成该初始化单元测试显式复现同一前置条件
new WebFrameworkUtils(new WebProperties());
}
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Test
void testTokenShouldRemainUnauthenticatedAndReturn401WhenMockIsDisabled() throws Exception {
SecurityProperties properties = new SecurityProperties();
properties.setMockEnable(false);
OAuth2TokenCommonApi tokenApi = mock(OAuth2TokenCommonApi.class);
when(tokenApi.checkAccessToken("test1")).thenReturn(CommonResult.error(401, "访问令牌不存在"));
TokenAuthenticationFilter filter = new TokenAuthenticationFilter(
properties, mock(GlobalExceptionHandler.class), tokenApi);
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/app-api/protected");
request.addHeader("Authorization", "Bearer test1");
MockHttpServletResponse response = new MockHttpServletResponse();
AuthenticationEntryPointImpl authenticationEntryPoint = new AuthenticationEntryPointImpl();
filter.doFilter(request, response, (currentRequest, currentResponse) -> {
// 复用生产认证入口过滤器未建立身份时必须同时返回 HTTP 401 与业务码 401
assertNull(SecurityFrameworkUtils.getLoginUser());
authenticationEntryPoint.commence(request, response,
new InsufficientAuthenticationException("未认证"));
});
assertEquals(401, response.getStatus());
assertTrue(response.getContentAsString().contains("\"code\":401"));
assertNull(SecurityFrameworkUtils.getLoginUser());
}
}

View File

@ -0,0 +1,26 @@
package com.wanxiang.huijing.framework.security.core.handler;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** 未认证请求 HTTP 语义测试。 */
class AuthenticationEntryPointImplTest {
@Test
void shouldReturnHttp401AndBusinessCode401() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/app-api/protected");
MockHttpServletResponse response = new MockHttpServletResponse();
new AuthenticationEntryPointImpl().commence(
request, response, new InsufficientAuthenticationException("未认证"));
assertEquals(401, response.getStatus());
assertTrue(response.getContentAsString().contains("\"code\":401"));
}
}

View File

@ -5,6 +5,7 @@ import cn.hutool.core.util.StrUtil;
import com.wanxiang.huijing.framework.common.enums.CommonStatusEnum;
import com.wanxiang.huijing.framework.common.enums.UserTypeEnum;
import com.wanxiang.huijing.framework.common.exception.enums.GlobalErrorCodeConstants;
import com.wanxiang.huijing.framework.env.config.DogfoodProperties;
import com.wanxiang.huijing.framework.env.core.DogfoodAccessGuard;
import com.wanxiang.huijing.framework.security.config.SecurityProperties;
import com.wanxiang.huijing.module.system.api.passport.dto.PlayerRespDTO;
@ -35,6 +36,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import jakarta.annotation.Resource;
import java.time.Clock;
import java.time.LocalDateTime;
import java.time.ZoneId;
@ -90,6 +92,10 @@ public class PassportServiceImpl implements PassportService {
private PassportRegisterOutboxMapper registerOutboxMapper; // 注册成功事件 outbox 生产者§3.6
@Resource
private DogfoodAccessGuard dogfoodAccessGuard; // 狗粮态短信信任边界服务层兜住直调与未来控制器变更
@Resource
private DogfoodProperties dogfoodProperties; // 狗粮预置租约配置运行时每次注册都重新判定到期时间
@Resource(name = "dogfoodProvisioningClock")
private Clock dogfoodProvisioningClock; // 与启动门共享 UTC 时钟语义测试可替换为固定时钟
/**
* 惰性缓存的固定假 BCrypt hash防枚举时序对齐用首次登录查无用户名时以本类 encoder 编出cost 与真实 hash 一致
@ -265,6 +271,15 @@ public class PassportServiceImpl implements PassportService {
log.warn("[passwordRegister] 用户名密码注册通道已关闭,拒绝新建账号 clientIp={}", clientIp);
throw exception(PASSWORD_REGISTER_DISABLED);
}
// dogfood 只允许受控预置租约临时开放密码注册 dogfood 保留原有注册语义
if (dogfoodProperties.isEnabled()
&& !dogfoodProperties.isProvisioningLeaseActive(dogfoodProvisioningClock)) {
DogfoodProperties.ProvisioningLeaseStatus leaseStatus =
dogfoodProperties.getProvisioningLeaseStatus(dogfoodProvisioningClock);
log.warn("[passwordRegister] 狗粮态预置租约无效,拒绝新建账号 leaseStatus={} clientIp={}",
leaseStatus, clientIp);
throw exception(PASSWORD_REGISTER_DISABLED);
}
// 2) 应用层纯 IP 计数防刷scene=register先增后判越限 429IP 取连接层 remoteAddr controller 传入不采信 XFF
checkIpLimit(PassportSmsIpCounterRedisDAO.SCENE_REGISTER, clientIp, registerIpLimitPerHour, registerIpLimitPerDay);
// 3) 密码 BCrypt 编码复用 admin 范式 passwordEncoder.encode§3.2明文用后即弃绝不落库/落日志

View File

@ -2,6 +2,7 @@ package com.wanxiang.huijing.module.system.service.passport;
import com.wanxiang.huijing.framework.common.enums.UserTypeEnum;
import com.wanxiang.huijing.framework.common.exception.ServiceException;
import com.wanxiang.huijing.framework.env.config.DogfoodProperties;
import com.wanxiang.huijing.framework.security.config.SecurityProperties;
import com.wanxiang.huijing.framework.env.core.DogfoodAccessGuard;
import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest;
@ -29,6 +30,7 @@ import org.mockito.Mock;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Clock;
import java.time.LocalDateTime;
import static com.wanxiang.huijing.framework.common.exception.enums.GlobalErrorCodeConstants.TOO_MANY_REQUESTS;
@ -74,6 +76,10 @@ class PassportServiceImplTest extends BaseMockitoUnitTest {
private PassportRegisterOutboxMapper registerOutboxMapper;
@Mock
private DogfoodAccessGuard dogfoodAccessGuard;
@Mock
private DogfoodProperties dogfoodProperties;
@Mock
private Clock dogfoodProvisioningClock;
@BeforeEach
void setUp() {
@ -524,6 +530,44 @@ class PassportServiceImplTest extends BaseMockitoUnitTest {
verify(smsIpCounterRedisDAO, never()).incrHourAndGet(any(), any());
}
@Test
@DisplayName("狗粮用户名注册:租约无效时在 IP 计数、加密和建号前拒绝")
void testPasswordRegister_dogfoodProvisioningLeaseRequired() {
when(dogfoodProperties.isEnabled()).thenReturn(true);
when(dogfoodProperties.isProvisioningLeaseActive(dogfoodProvisioningClock)).thenReturn(false);
PasswordRegisterReqVO reqVO = new PasswordRegisterReqVO();
reqVO.setUsername("seedalice");
reqVO.setPassword("secret6");
ServiceException ex = assertThrows(ServiceException.class,
() -> passportService.passwordRegister(reqVO, "9.9.9.9"));
assertEquals(PASSWORD_REGISTER_DISABLED.getCode(), ex.getCode());
verifyNoInteractions(smsIpCounterRedisDAO, passwordEncoder, playerMapper, registerOutboxMapper);
}
@Test
@DisplayName("狗粮用户名注册:有效租约允许进入既有注册流程")
void testPasswordRegister_dogfoodActiveProvisioningLeaseAllowed() {
when(dogfoodProperties.isEnabled()).thenReturn(true);
when(dogfoodProperties.isProvisioningLeaseActive(dogfoodProvisioningClock)).thenReturn(true);
PasswordRegisterReqVO reqVO = new PasswordRegisterReqVO();
reqVO.setUsername("seedalice");
reqVO.setPassword("secret6");
when(passwordEncoder.encode("secret6")).thenReturn("$2a$10$encodedhash");
doAnswer(invocation -> {
PlayerDO player = invocation.getArgument(0);
player.setId(2048L);
return 1;
}).when(playerMapper).insert(any(PlayerDO.class));
stubToken(2048L);
LoginRespVO response = passportService.passwordRegister(reqVO, "9.9.9.9");
assertEquals(2048L, response.getUserId());
verify(playerMapper).insert(any(PlayerDO.class));
}
@Test
@DisplayName("用户名注册:同 IP 越注册档小时阈值 → 抛 429 且不建号")
void testPasswordRegister_ipLimitExceeded() {

View File

@ -1,13 +1,14 @@
package com.wanxiang.huijing.server.config;
import com.wanxiang.huijing.framework.env.config.DogfoodProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.env.Environment;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.util.ArrayList;
import java.util.List;
@ -21,11 +22,19 @@ import java.util.List;
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DogfoodStartupValidator implements ApplicationRunner {
private final DogfoodProperties dogfoodProperties;
private final Environment environment;
private final Clock dogfoodProvisioningClock;
public DogfoodStartupValidator(DogfoodProperties dogfoodProperties,
Environment environment,
@Qualifier("dogfoodProvisioningClock") Clock dogfoodProvisioningClock) {
this.dogfoodProperties = dogfoodProperties;
this.environment = environment;
this.dogfoodProvisioningClock = dogfoodProvisioningClock;
}
@Override
public void run(ApplicationArguments args) {
@ -38,7 +47,7 @@ public class DogfoodStartupValidator implements ApplicationRunner {
log.error("[dogfoodStartup] 狗粮态安全配置校验失败 violations={}", violations);
throw new IllegalStateException("狗粮态安全配置不满足启动条件: " + String.join(", ", violations));
}
log.info("[dogfoodStartup] 狗粮态安全配置校验通过,注册/短信/广告/收付款边界已锁定");
log.info("[dogfoodStartup] 狗粮态安全配置校验通过,注册租约/短信/广告/收付款边界已锁定");
}
/**
@ -48,7 +57,7 @@ public class DogfoodStartupValidator implements ApplicationRunner {
List<String> violations = new ArrayList<>();
requireFalse("wanxiang.passport.sms-mock-enabled", violations);
requireFalse("wanxiang.passport.invite-register-enabled", violations);
requireFalse("wanxiang.passport.password-register-enabled", violations);
validatePasswordRegistrationLease(violations);
requireTrue("wanxiang.passport.password-login-enabled", violations);
requireTrue("ad.force-mock", violations);
requireValue("trade.charge-channel", "mock", violations);
@ -56,6 +65,18 @@ public class DogfoodStartupValidator implements ApplicationRunner {
return violations;
}
/** dogfood 下密码注册只能由有效的短时预置租约放行,租约原值不进入日志。 */
private void validatePasswordRegistrationLease(List<String> violations) {
if (!environment.getProperty("wanxiang.passport.password-register-enabled", Boolean.class, false)) {
return;
}
DogfoodProperties.ProvisioningLeaseStatus status =
dogfoodProperties.getProvisioningLeaseStatus(dogfoodProvisioningClock);
if (status != DogfoodProperties.ProvisioningLeaseStatus.ACTIVE) {
violations.add("wanxiang.passport.password-register-enabled 仅允许有效预置租约leaseStatus=" + status);
}
}
private void requireFalse(String key, List<String> violations) {
if (environment.getProperty(key, Boolean.class, false)) {
violations.add(key + " 必须为 false");

View File

@ -1,5 +1,5 @@
# 绘境AI game-staging 单体启动 profile仅 mini-desktop 隔离环境)
# 基于 application-local.yaml差异DB/Redis 指向隔离容器(13306/16379)+强密码(经 .env 注入占位符nacos 指 mini-infra、xxl 关;mock-enable=true
# 基于 application-local.yaml差异DB/Redis 指向隔离容器(13306/16379)+强密码(经 .env 注入占位符nacos 指 mini-infra、xxl 关;认证 mock 强制关闭
server:
port: 48080
@ -59,7 +59,7 @@ spring:
password: ${MYSQL_ROOT_PASSWORD}
flyway:
enabled: true
locations: classpath:db/migration,classpath:db/staging # staging 额外加载内部狗粮账号与最小 RBAC 种子
locations: classpath:db/migration,classpath:db/staging # staging 额外加载内部狗粮最小 RBAC 种子;账号由受控工具轮换
url: jdbc:mysql://127.0.0.1:13306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true
user: root
password: ${MYSQL_ROOT_PASSWORD}
@ -161,12 +161,14 @@ huijing:
dogfood:
enabled: true # 内部狗粮后端可信边界总开关
feed-enabled: true # 初始开闸;事故止损时由 Nacos 改 false读路径与发布入池同时拒绝
provisioning-enabled: false # 仅 Nacos 受控事务在短时账号预置窗口内开启
provisioning-expires-at: "" # Nacos 开窗时写 ISO-8601 UTC 到期时间;缺失/非法/过期均拒绝注册
tenant:
enable: false # staging 单租户隔离环境:关闭多租户校验拦截器(避免 framework tenant valid 走 Feign
captcha:
enable: false
security:
mock-enable: true
mock-enable: false # 狗粮态禁止 test<userId> 模拟身份,启动校验会对覆盖配置 fail-closed
permit-all-urls: # staging 放行 actuator 健康检查(无需鉴权,便于探活)
- /actuator/**
access-log:

View File

@ -0,0 +1,84 @@
-- 内部狗粮 staging 专用最小 RBAC repeatable 种子。
-- 这里只定义权限与角色,绝不创建账号或写入密码;账号由 deploy/manage-dogfood-reviewers.py 受控创建和轮换。
-- 使用 repeatable migration避免 staging 私有种子占用全局 Flyway 版本号。
-- 权限串是稳定业务键,不依赖各环境的菜单自增 ID。
INSERT INTO system_menu
(name, permission, type, sort, parent_id, path, icon, component, component_name,
status, visible, keep_alive, always_show, creator, updater, deleted)
SELECT '项目审核查询', 'project:review:query', 3, 1, 0, '', '#', '', NULL,
0, b'0', b'0', b'0', 'dogfood-seed', 'dogfood-seed', b'0'
WHERE NOT EXISTS (
SELECT 1 FROM system_menu WHERE permission = 'project:review:query' AND deleted = b'0'
);
INSERT INTO system_menu
(name, permission, type, sort, parent_id, path, icon, component, component_name,
status, visible, keep_alive, always_show, creator, updater, deleted)
SELECT '项目审核决策', 'project:review:approve', 3, 2, 0, '', '#', '', NULL,
0, b'0', b'0', b'0', 'dogfood-seed', 'dogfood-seed', b'0'
WHERE NOT EXISTS (
SELECT 1 FROM system_menu WHERE permission = 'project:review:approve' AND deleted = b'0'
);
INSERT INTO system_menu
(name, permission, type, sort, parent_id, path, icon, component, component_name,
status, visible, keep_alive, always_show, creator, updater, deleted)
SELECT '游戏遥测查询', 'telemetry:game-stat:query', 3, 3, 0, '', '#', '', NULL,
0, b'0', b'0', b'0', 'dogfood-seed', 'dogfood-seed', b'0'
WHERE NOT EXISTS (
SELECT 1 FROM system_menu WHERE permission = 'telemetry:game-stat:query' AND deleted = b'0'
);
-- 两个角色均为 tenant_id=1 的普通自定义角色,不具备 super_admin 绕过能力。
INSERT INTO system_role
(name, code, sort, data_scope, data_scope_dept_ids, status, type, remark,
creator, updater, deleted, tenant_id)
SELECT '狗粮审核员', 'dogfood_reviewer', 90, 1, '', 0, 2, '项目审核查询与决策、遥测只读,无配置权限',
'dogfood-seed', 'dogfood-seed', b'0', 1
WHERE NOT EXISTS (
SELECT 1 FROM system_role WHERE code = 'dogfood_reviewer' AND tenant_id = 1 AND deleted = b'0'
);
INSERT INTO system_role
(name, code, sort, data_scope, data_scope_dept_ids, status, type, remark,
creator, updater, deleted, tenant_id)
SELECT '狗粮只读员', 'dogfood_readonly', 91, 1, '', 0, 2, '项目审核与遥测只读,无任何写权限',
'dogfood-seed', 'dogfood-seed', b'0', 1
WHERE NOT EXISTS (
SELECT 1 FROM system_role WHERE code = 'dogfood_readonly' AND tenant_id = 1 AND deleted = b'0'
);
-- 历史环境若已存在同名角色repeatable 每次都恢复其安全属性,避免停用或角色类型漂移。
UPDATE system_role
SET name = '狗粮审核员', sort = 90, data_scope = 1, data_scope_dept_ids = '', status = 0, type = 2,
remark = '项目审核查询与决策、遥测只读,无配置权限', updater = 'dogfood-seed'
WHERE code = 'dogfood_reviewer' AND tenant_id = 1 AND deleted = b'0';
UPDATE system_role
SET name = '狗粮只读员', sort = 91, data_scope = 1, data_scope_dept_ids = '', status = 0, type = 2,
remark = '项目审核与遥测只读,无任何写权限', updater = 'dogfood-seed'
WHERE code = 'dogfood_readonly' AND tenant_id = 1 AND deleted = b'0';
-- repeatable 重跑时将专用角色收敛到精确权限集,防止历史误授权残留。
DELETE rm
FROM system_role_menu rm
JOIN system_role r ON r.id = rm.role_id
WHERE r.code IN ('dogfood_reviewer', 'dogfood_readonly')
AND r.tenant_id = 1 AND r.deleted = b'0';
INSERT INTO system_role_menu (role_id, menu_id, creator, updater, deleted, tenant_id)
SELECT r.id, m.id, 'dogfood-seed', 'dogfood-seed', b'0', 1
FROM system_role r
JOIN system_menu m
ON m.permission IN ('project:review:query', 'project:review:approve', 'telemetry:game-stat:query')
AND m.deleted = b'0'
WHERE r.code = 'dogfood_reviewer' AND r.tenant_id = 1 AND r.deleted = b'0';
INSERT INTO system_role_menu (role_id, menu_id, creator, updater, deleted, tenant_id)
SELECT r.id, m.id, 'dogfood-seed', 'dogfood-seed', b'0', 1
FROM system_role r
JOIN system_menu m
ON m.permission IN ('project:review:query', 'telemetry:game-stat:query')
AND m.deleted = b'0'
WHERE r.code = 'dogfood_readonly' AND r.tenant_id = 1 AND r.deleted = b'0';

View File

@ -1,113 +0,0 @@
-- 内部狗粮 staging 专用最小 RBAC 种子V34避开正式迁移 V33
-- 目标reviewer 可查询/审核但不可改配置readonly 只可查询,任何写接口由 @PreAuthorize 返回 403。
-- 本脚本只由 application-staging.yaml 的 db/staging location 加载,不进入其它环境。
-- 使用权限串作为稳定业务键,避免依赖不同数据库中的自增菜单 ID。
INSERT INTO system_menu
(name, permission, type, sort, parent_id, path, icon, component, component_name,
status, visible, keep_alive, always_show, creator, updater, deleted)
SELECT '项目审核查询', 'project:review:query', 3, 1, 0, '', '#', '', NULL,
0, b'0', b'0', b'0', 'dogfood-seed', 'dogfood-seed', b'0'
WHERE NOT EXISTS (
SELECT 1 FROM system_menu WHERE permission = 'project:review:query' AND deleted = b'0'
);
INSERT INTO system_menu
(name, permission, type, sort, parent_id, path, icon, component, component_name,
status, visible, keep_alive, always_show, creator, updater, deleted)
SELECT '项目审核决策', 'project:review:approve', 3, 2, 0, '', '#', '', NULL,
0, b'0', b'0', b'0', 'dogfood-seed', 'dogfood-seed', b'0'
WHERE NOT EXISTS (
SELECT 1 FROM system_menu WHERE permission = 'project:review:approve' AND deleted = b'0'
);
INSERT INTO system_menu
(name, permission, type, sort, parent_id, path, icon, component, component_name,
status, visible, keep_alive, always_show, creator, updater, deleted)
SELECT '游戏遥测查询', 'telemetry:game-stat:query', 3, 3, 0, '', '#', '', NULL,
0, b'0', b'0', b'0', 'dogfood-seed', 'dogfood-seed', b'0'
WHERE NOT EXISTS (
SELECT 1 FROM system_menu WHERE permission = 'telemetry:game-stat:query' AND deleted = b'0'
);
-- 角色按 tenant_id=1 隔离;均为普通自定义角色,不具备 super_admin 绕过能力。
INSERT INTO system_role
(name, code, sort, data_scope, data_scope_dept_ids, status, type, remark,
creator, updater, deleted, tenant_id)
SELECT '狗粮审核员', 'dogfood_reviewer', 90, 1, '', 0, 2, '项目审核查询与决策、遥测只读,无配置权限',
'dogfood-seed', 'dogfood-seed', b'0', 1
WHERE NOT EXISTS (
SELECT 1 FROM system_role WHERE code = 'dogfood_reviewer' AND tenant_id = 1 AND deleted = b'0'
);
INSERT INTO system_role
(name, code, sort, data_scope, data_scope_dept_ids, status, type, remark,
creator, updater, deleted, tenant_id)
SELECT '狗粮只读员', 'dogfood_readonly', 91, 1, '', 0, 2, '项目审核与遥测只读,无任何写权限',
'dogfood-seed', 'dogfood-seed', b'0', 1
WHERE NOT EXISTS (
SELECT 1 FROM system_role WHERE code = 'dogfood_readonly' AND tenant_id = 1 AND deleted = b'0'
);
-- staging 预置管理账号。密码沿用本项目本地初始化账号的 BCrypt 测试密码,首次真机验收后由 ops 轮换。
INSERT INTO system_users
(username, password, nickname, remark, dept_id, post_ids, email, mobile, sex, avatar,
status, login_ip, creator, updater, deleted, tenant_id)
SELECT 'dogfood_reviewer', '$2a$04$.vd8nPeLwxt6hnSzmAoAyul8BOLX7Cib6QhcxRe30rfvrIPQHH1OG',
'狗粮审核员', 'staging 内部狗粮专用,禁止赋 super_admin', NULL, '[]', '', '', 0, '',
0, '', 'dogfood-seed', 'dogfood-seed', b'0', 1
WHERE NOT EXISTS (
SELECT 1 FROM system_users WHERE username = 'dogfood_reviewer' AND tenant_id = 1 AND deleted = b'0'
);
INSERT INTO system_users
(username, password, nickname, remark, dept_id, post_ids, email, mobile, sex, avatar,
status, login_ip, creator, updater, deleted, tenant_id)
SELECT 'dogfood_readonly', '$2a$04$.vd8nPeLwxt6hnSzmAoAyul8BOLX7Cib6QhcxRe30rfvrIPQHH1OG',
'狗粮只读员', 'staging 内部狗粮专用,禁止赋 super_admin', NULL, '[]', '', '', 0, '',
0, '', 'dogfood-seed', 'dogfood-seed', b'0', 1
WHERE NOT EXISTS (
SELECT 1 FROM system_users WHERE username = 'dogfood_readonly' AND tenant_id = 1 AND deleted = b'0'
);
-- reviewer = 审核 query/approve + 遥测 queryreadonly = 审核 query + 遥测 query。
-- NOT EXISTS 保证脚本可重复执行且不产生重复授权。
INSERT INTO system_role_menu (role_id, menu_id, creator, updater, deleted, tenant_id)
SELECT r.id, m.id, 'dogfood-seed', 'dogfood-seed', b'0', 1
FROM system_role r
JOIN system_menu m ON m.permission IN ('project:review:query', 'project:review:approve', 'telemetry:game-stat:query') AND m.deleted = b'0'
WHERE r.code = 'dogfood_reviewer' AND r.tenant_id = 1 AND r.deleted = b'0'
AND NOT EXISTS (
SELECT 1 FROM system_role_menu rm
WHERE rm.role_id = r.id AND rm.menu_id = m.id AND rm.deleted = b'0'
);
INSERT INTO system_role_menu (role_id, menu_id, creator, updater, deleted, tenant_id)
SELECT r.id, m.id, 'dogfood-seed', 'dogfood-seed', b'0', 1
FROM system_role r
JOIN system_menu m ON m.permission IN ('project:review:query', 'telemetry:game-stat:query') AND m.deleted = b'0'
WHERE r.code = 'dogfood_readonly' AND r.tenant_id = 1 AND r.deleted = b'0'
AND NOT EXISTS (
SELECT 1 FROM system_role_menu rm
WHERE rm.role_id = r.id AND rm.menu_id = m.id AND rm.deleted = b'0'
);
INSERT INTO system_user_role (user_id, role_id, creator, updater, deleted, tenant_id)
SELECT u.id, r.id, 'dogfood-seed', 'dogfood-seed', b'0', 1
FROM system_users u
JOIN system_role r ON r.code = 'dogfood_reviewer' AND r.tenant_id = 1 AND r.deleted = b'0'
WHERE u.username = 'dogfood_reviewer' AND u.tenant_id = 1 AND u.deleted = b'0'
AND NOT EXISTS (
SELECT 1 FROM system_user_role ur
WHERE ur.user_id = u.id AND ur.role_id = r.id AND ur.deleted = b'0'
);
INSERT INTO system_user_role (user_id, role_id, creator, updater, deleted, tenant_id)
SELECT u.id, r.id, 'dogfood-seed', 'dogfood-seed', b'0', 1
FROM system_users u
JOIN system_role r ON r.code = 'dogfood_readonly' AND r.tenant_id = 1 AND r.deleted = b'0'
WHERE u.username = 'dogfood_readonly' AND u.tenant_id = 1 AND u.deleted = b'0'
AND NOT EXISTS (
SELECT 1 FROM system_user_role ur
WHERE ur.user_id = u.id AND ur.role_id = r.id AND ur.deleted = b'0'
);

View File

@ -17,7 +17,7 @@ class DogfoodRbacSeedTest {
@Test
void reviewerAndReadonlyShouldHaveRequiredQueriesWithoutConfigWrites() throws Exception {
String sql = new ClassPathResource("db/staging/V34.0.0__seed_dogfood_review_rbac.sql")
String sql = new ClassPathResource("db/staging/R__seed_dogfood_review_rbac.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertTrue(sql.contains("'telemetry:game-stat:query'"), "必须创建遥测查询权限");
@ -26,9 +26,10 @@ class DogfoodRbacSeedTest {
assertTrue(sql.contains("IN ('project:review:query', 'telemetry:game-stat:query')"),
"readonly 必须具备 smoke 所需的两个查询权限");
assertFalse(sql.contains("aigc:config:"), "狗粮角色不得获得配置控制面权限");
assertFalse(sql.contains("INSERT INTO system_users"), "RBAC migration 不得创建可登录账号");
assertFalse(sql.contains("$2a$"), "RBAC migration 不得写入公开 BCrypt 密码");
String readonlyGrant = sql.substring(sql.indexOf("r.code = 'dogfood_readonly'"),
sql.indexOf("INSERT INTO system_user_role"));
String readonlyGrant = sql.substring(sql.lastIndexOf("WHERE r.code = 'dogfood_readonly'"));
assertFalse(readonlyGrant.contains("project:review:approve"), "readonly 不得获得审核写权限");
assertEquals("@ss.hasPermission('project:review:approve')", AdminProjectController.class

View File

@ -7,6 +7,10 @@ import com.wanxiang.huijing.framework.env.core.DogfoodAccessGuard;
import org.junit.jupiter.api.Test;
import org.springframework.mock.env.MockEnvironment;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@ -15,6 +19,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
class DogfoodStartupValidatorTest {
private static final ErrorCode TEST_ERROR = new ErrorCode(403, "测试拒绝");
private static final Instant NOW = Instant.parse("2026-07-22T12:00:00Z");
private static final Clock FIXED_CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);
@Test
void guardShouldRejectDogfoodRestrictionAndClosedFeed() {
@ -36,7 +42,7 @@ class DogfoodStartupValidatorTest {
DogfoodProperties properties = enabledDogfood();
MockEnvironment environment = safeEnvironment();
assertDoesNotThrow(() -> new DogfoodStartupValidator(properties, environment).run(null));
assertDoesNotThrow(() -> new DogfoodStartupValidator(properties, environment, FIXED_CLOCK).run(null));
}
@Test
@ -47,7 +53,7 @@ class DogfoodStartupValidatorTest {
.withProperty("wanxiang.passport.password-register-enabled", "true");
assertThrows(IllegalStateException.class,
() -> new DogfoodStartupValidator(properties, environment).run(null));
() -> new DogfoodStartupValidator(properties, environment, FIXED_CLOCK).run(null));
}
@Test
@ -59,7 +65,52 @@ class DogfoodStartupValidatorTest {
.withProperty("trade.payout-channel", "alipay");
assertThrows(IllegalStateException.class,
() -> new DogfoodStartupValidator(properties, environment).run(null));
() -> new DogfoodStartupValidator(properties, environment, FIXED_CLOCK).run(null));
}
@Test
void shouldAllowPasswordRegistrationWithActiveProvisioningLease() {
DogfoodProperties properties = enabledDogfood();
properties.setProvisioningEnabled(true);
properties.setProvisioningExpiresAt("2026-07-22T12:05:00Z");
MockEnvironment environment = safeEnvironment()
.withProperty("wanxiang.passport.password-register-enabled", "true");
assertDoesNotThrow(() -> new DogfoodStartupValidator(properties, environment, FIXED_CLOCK).run(null));
}
@Test
void shouldRejectExpiredProvisioningLease() {
DogfoodProperties properties = enabledDogfood();
properties.setProvisioningEnabled(true);
properties.setProvisioningExpiresAt("2026-07-22T12:00:00Z");
MockEnvironment environment = safeEnvironment()
.withProperty("wanxiang.passport.password-register-enabled", "true");
assertThrows(IllegalStateException.class,
() -> new DogfoodStartupValidator(properties, environment, FIXED_CLOCK).run(null));
}
@Test
void shouldRejectInvalidProvisioningLease() {
DogfoodProperties properties = enabledDogfood();
properties.setProvisioningEnabled(true);
properties.setProvisioningExpiresAt("not-an-instant");
MockEnvironment environment = safeEnvironment()
.withProperty("wanxiang.passport.password-register-enabled", "true");
assertThrows(IllegalStateException.class,
() -> new DogfoodStartupValidator(properties, environment, FIXED_CLOCK).run(null));
}
@Test
void provisioningLeaseShouldBeInactiveWhenDogfoodIsDisabled() {
DogfoodProperties properties = new DogfoodProperties();
properties.setProvisioningEnabled(true);
properties.setProvisioningExpiresAt("2026-07-22T12:05:00Z");
assertEquals(DogfoodProperties.ProvisioningLeaseStatus.DOGFOOD_DISABLED,
properties.getProvisioningLeaseStatus(FIXED_CLOCK));
}
private static DogfoodProperties enabledDogfood() {