Merge pull request 'Add the RDM domain-admin ledger recovery tooling and honest roles-panel rejected state' (#498) from feature/rdm-admin-lockout-recovery into main
ci / svc-realcombo (push) Successful in 1m47s
ci / test-pg (push) Successful in 2m51s
ci / test (push) Successful in 5m3s

This commit was merged in pull request #498.
This commit is contained in:
2026-10-13 01:56:29 +00:00
4 changed files with 629 additions and 6 deletions
File diff suppressed because one or more lines are too long
+196 -5
View File
@@ -181,6 +181,8 @@ svc.sh —— 本机服务管理:后端(agent) / 前端(static) / 数据库(po
scripts/svc.sh recover [name] [--quiet] 自恢复:拉回 autostart=1 但已死的服务
scripts/svc.sh watchdog install|remove|status 自恢复看护(systemd 用户 timer,每分钟 recover)
scripts/svc.sh <name> user <subcommand> [...] 账户 CLI(绑定实例;见 help accounts)
scripts/svc.sh <name> rdm-admin [show] RDM 域管理员台账查看(含基座用户核验)
scripts/svc.sh <name> rdm-admin transfer --to <id> RDM 域管理员受信移交(锁死恢复通道)
帮助:--help 完整帮助 | help [主题] 分主题(model deploy lifecycle accounts db dbops config recovery examples safety)
文档:docs/svc.md
@@ -188,7 +190,7 @@ EOF
}
# HELP_TOPICS:help 的主题清单(cmd_help 与提示共用)。
HELP_TOPICS="model deploy lifecycle accounts db dbops config recovery examples safety"
HELP_TOPICS="model deploy lifecycle accounts rdm-admin db dbops config recovery examples safety"
cmd_help() {
local topic="${1:-}"
@@ -201,6 +203,7 @@ svc.sh 分主题帮助(scripts/svc.sh help <主题>;完整 --help;文档 d
deploy 部署:三来源(local/dev@sha/release@v)、--pick、--dry-run、static --dir
lifecycle 生命周期:start/stop/restart/logs/status、set 变更、rm 注销
accounts 账户 CLI:create/list/reset-password/issue-login-key/revoke-login-key/bootstrap-admin
rdm-admin RDM 域管理员台账:查看 / 受信移交(锁死恢复通道)
db 数据库服务:create/start/dsn/ls/set(换端口)
dbops 数据库运维:backup/backups/restore/clone/copy
config 配置分层:优先级、--config 文件、SVC_* 环境变量、播种参数
@@ -408,6 +411,28 @@ EOF
凭据只在 stdout 展示一次;日志与 history 只记凭据 ID。后端原子开户/改密/审计
补齐见 Documents 方案 U7(本帮助主题不替代该增量)。
更完整说明:docs/svc.md、docs/svc-deploy/u1-instance-account-binding.md
EOF
return 0 ;;
rdm-admin)
cat >&2 <<'EOF'
【rdm-admin】RDM 域管理员台账(rdm.db rdm_admins 单行;受信运维通道)
scripts/svc.sh <env> rdm-admin [show] 查看当前 RDM 域管理员(台账行 + 基座用户核验;
台账管理员不在基座 users 表时警示——典型为
验收/引导残留占用共享环境台账)
scripts/svc.sh <env> rdm-admin transfer --to <owner>
受信移交(锁死恢复通道;与 serve 二进制
-bootstrap-admin 同级信任,镜像 rdm.perm.transfer
的单行改写语义)
背景(2026-10-13 8788 dev 实测):U7 验收在共享 dev runtime 执行
-bootstrap-admin u7-admin 后,操作者 weichen 在系统管理「RDM 角色权限」被拒
(权限拒绝:不是系统管理员)——KTD4 下基座 isAdmin 不自动获得 RDM 域管理权。
KTD4 裁定不变:正式移交仍走 rdm.perm.transfer(有审计);本通道仅限锁死恢复。
边界:绑定合同同 accounts(flock/manifest data_root/既有库,mock 拒绝);仅内嵌
SQLite 档(PG 档请以现任管理员走 rdm.perm.transfer);transfer 目标必须在基座
users 表(typo 防线);空台账不适用本命令(用 -bootstrap-admin 引导)。
EOF
return 0 ;;
db)
@@ -2362,6 +2387,153 @@ agent_do_user() {
AGENT_SYSTEM_DSN="${DB_DSN:-}" "$ENV_BIN" user "$@" "${bin_args[@]}"
}
# ---- agent:RDM 域管理员台账(rdm_admins 单行;受信运维通道,见 help rdm-admin) ----
# rdm_admin_read DB:只读台账行 "owner|added_by|added_at"(无库/无表/空表 → 空串)。
rdm_admin_read() {
python3 -c 'import sqlite3,sys
try:
con = sqlite3.connect("file:" + sys.argv[1] + "?mode=ro", uri=True)
row = con.execute("select owner,added_by,added_at from rdm_admins where id=1").fetchone()
con.close()
print("|".join(row) if row else "")
except Exception:
pass' "$1" 2>/dev/null
}
# rdm_admin_base_owners DB:基座 users 表 owner 逗号清单(无库/无表 → 空串)。
# 台账核验与 transfer typo 防线共用。
rdm_admin_base_owners() {
python3 -c 'import sqlite3,sys
try:
con = sqlite3.connect("file:" + sys.argv[1] + "?mode=ro", uri=True)
rows = con.execute("select owner from users").fetchall()
con.close()
print(",".join(r[0] for r in rows))
except Exception:
pass' "$1" 2>/dev/null
}
# rdm_admin_transfer_write DB TO OLD:受信单行改写(BEGIN IMMEDIATE + 回读核验;
# added_by 记 svc:rdm-admin:<old> 保留前任可追溯)。失败非零退出。
rdm_admin_transfer_write() {
python3 -c 'import sqlite3,sys
db, to, old = sys.argv[1], sys.argv[2], sys.argv[3]
con = sqlite3.connect(db, timeout=10, isolation_level=None)
con.execute("BEGIN IMMEDIATE")
con.execute("update rdm_admins set owner=?, added_by=?, added_at=strftime(\"%Y-%m-%dT%H:%M:%fZ\",\"now\") where id=1",
(to, "svc:rdm-admin:" + old))
row = con.execute("select owner from rdm_admins where id=1").fetchone()
ok = row is not None and row[0] == to
con.execute("COMMIT")
con.close()
sys.exit(0 if ok else 1)' "$1" "$2" "$3" 2>/dev/null
}
# agent_do_rdm_admin:show(默认)/transfer --to <owner>。绑定合同同 agent_do_user
#(flock、manifest data_root 权威、禁回落 ./data、mock 拒绝);仅内嵌 SQLite 档。
agent_do_rdm_admin() {
local sub="${1:-show}"
case "$sub" in
show|transfer) if [ $# -ge 1 ]; then shift; fi ;;
*)
c_err "[$ENV_NAME] rdm-admin 用法:show | transfer --to <owner>(help rdm-admin)"
return 2 ;;
esac
svc_lock wait || { c_err "[$ENV_NAME] 无法获取生命周期锁(与启停互斥)"; return 1; }
# 有 manifest 时以 identity.data_root 为权威,拒绝 ENV_DATA 漂移到默认 ./data。
if [ -f "$ENV_DIR/manifest.json" ] && command -v python3 >/dev/null 2>&1; then
local mf_root
mf_root="$(python3 -c 'import json,sys; m=json.load(open(sys.argv[1])); print(m["identity"]["data_root"])' \
"$ENV_DIR/manifest.json" 2>/dev/null || true)"
if [ -n "$mf_root" ]; then
ENV_DATA="$mf_root"
fi
fi
case "${ENV_DATA:-}" in
""|./data|data)
c_err "[$ENV_NAME] 未绑定数据目录:拒绝 RDM 台账操作(不回落默认 ./data)"
return 1 ;;
esac
resolve_db_config || {
c_err "[$ENV_NAME] 数据库绑定无效:拒绝 RDM 台账操作(不回落默认 ./data)"
return 1
}
if [ "$DB_MODE" = mock ]; then
c_err "[$ENV_NAME] mock 档不支持 RDM 台账操作"
return 1
fi
if [ "$DB_MODE" = pg ]; then
c_err "[$ENV_NAME] PostgreSQL 档不走受信直写:以现任管理员经 rdm.perm.transfer 移交"
return 1
fi
local rdm_db="$ENV_DATA/rdm.db" base_db="$ENV_DATA/agent-system.db"
if [ ! -f "$base_db" ]; then
c_err "[$ENV_NAME] 既有库不存在:$base_db(先 deploy/启动初始化)"
return 1
fi
if [ ! -f "$rdm_db" ]; then
c_err "[$ENV_NAME] 无 RDM 域库:$rdm_db(未装 RDM 插件或域未初始化)"
return 1
fi
local row owner added_by added_at owners
row="$(rdm_admin_read "$rdm_db")"
owners="$(rdm_admin_base_owners "$base_db")"
case "$sub" in
show)
if [ -z "$row" ]; then
c_info "[$ENV_NAME] RDM 域管理员台账为空(空或未迁移)——经 serve 二进制 -bootstrap-admin <owner> 受信引导"
else
IFS='|' read -r owner added_by added_at <<<"$row"
printf ' RDM 域管理员 %s(由 %s 于 %s 登记)\n' "$owner" "$added_by" "$added_at"
fi
if [ -n "$row" ] && [ -n "$owner" ]; then
case ",$owners," in
*",$owner,"*) ;;
*)
c_warn "[$ENV_NAME] 台账管理员 $owner 不在基座用户表——验收/引导残留占用共享台账的典型特征;恢复:scripts/svc.sh $ENV_NAME rdm-admin transfer --to <操作者账号>" ;;
esac
fi
;;
transfer)
local to=""
while [ $# -gt 0 ]; do
case "$1" in
--to) [ -n "${2:-}" ] || { c_err "transfer --to 需要账号参数"; return 2; }; to="$2"; shift 2 ;;
*) c_err "[$ENV_NAME] rdm-admin transfer 用法:--to <owner>(help rdm-admin)"; return 2 ;;
esac
done
[ -n "$to" ] || { c_err "[$ENV_NAME] rdm-admin transfer 用法:--to <owner>"; return 2; }
if [ -z "$row" ]; then
c_err "[$ENV_NAME] 台账为空:不适用 transfer(经 serve 二进制 -bootstrap-admin $to 受信引导)"
return 1
fi
IFS='|' read -r owner added_by added_at <<<"$row"
if [ "$to" = "$owner" ]; then
c_info "[$ENV_NAME] 台账管理员已是 $to(无需移交)"
return 0
fi
case ",$owners," in
*",$to,"*) ;;
*)
c_err "[$ENV_NAME] 目标账号 $to 不在基座用户表($base_db users):拒绝——防 typo 锁死;先 user create 建号"
return 1 ;;
esac
c_warn "[$ENV_NAME] 受信部署通道:改写 rdm_admins 单行 $owner → $to(与 -bootstrap-admin 同级信任;正式移交建议经 rdm.perm.transfer 保留审计)"
printf ' 移交前 %s(由 %s 于 %s 登记)\n' "$owner" "$added_by" "$added_at"
if ! rdm_admin_transfer_write "$rdm_db" "$to" "$owner"; then
c_err "[$ENV_NAME] 台账写入或回读核验失败(服务占用锁时稍后重试)"
return 1
fi
row="$(rdm_admin_read "$rdm_db")"
IFS='|' read -r owner added_by added_at <<<"$row"
printf ' 移交后 %s(由 %s 于 %s 登记)\n' "$owner" "$added_by" "$added_at"
c_info "[$ENV_NAME] RDM 域管理员移交完成:$to(服务下一命令即生效,无需重启)"
;;
esac
return 0
}
# ---- agent:部署与启动 ----
# source_kind_prefix LABEL:取 source= 的种类前缀(dev@sha → dev;空 → 空)。
@@ -4575,6 +4747,24 @@ env_status_detail() {
printf ' 工作台 无入口(伴生 %s 未登记或未运行;scripts/svc.sh %s deploy --kind static --dir %s/htdocs)\n' \
"$(rdm_companion_name "$ENV_NAME")" "$(rdm_companion_name "$ENV_NAME")" "$ENV_DIR"
fi
# RDM 域管理员台账行(rdm.db 存在才显示):验收/引导残留占用共享台账时,
# status 即刻可见(2026-10-13 8788 dev 锁死复现后的巡检要求)。
if [ -f "$ENV_DATA/rdm.db" ]; then
local rdm_row rdm_owner _rb _ra
rdm_row="$(rdm_admin_read "$ENV_DATA/rdm.db")"
if [ -n "$rdm_row" ]; then
IFS='|' read -r rdm_owner _rb _ra <<<"$rdm_row"
printf ' RDM 域管理员 %s\n' "$rdm_owner"
local rdm_owners
rdm_owners="$(rdm_admin_base_owners "$ENV_DATA/agent-system.db")"
case ",$rdm_owners," in
*",$rdm_owner,"*) ;;
*) c_warn " [$ENV_NAME] RDM 域管理员 $rdm_owner 不在基座用户表(验收/引导残留?);scripts/svc.sh $ENV_NAME rdm-admin 查看与移交" ;;
esac
else
printf ' RDM 域管理员 无(台账为空;-bootstrap-admin 受信引导)\n'
fi
fi
;;
postgres)
printf ' 库 %s 用户 %s\n' "$(svc_get dbname)" "$(svc_get user)"
@@ -5375,19 +5565,20 @@ main() {
# 动作词可省略(省略 = deploy);flags 可直接跟随服务名。
local action="deploy"
case "${1:-}" in
deploy|stop|start|restart|status|logs|db|config|init|adopt|user|backup|backups) action="$1"; shift ;;
deploy|stop|start|restart|status|logs|db|config|init|adopt|user|rdm-admin|backup|backups) action="$1"; shift ;;
esac
if [ "$action" = logs ] && [ "${1:-}" != "" ] && [[ "$1" =~ ^[0-9]+$ ]]; then
CLI_LOGS_N="$1"; shift
fi
# db / config / user / backup / backups:自带参数解析(位置参数不走 parse_args;
# 未知 flags 由其自行拒绝)。
if [ "$action" = db ] || [ "$action" = config ] || [ "$action" = user ]; then
# db / config / user / rdm-admin / backup / backups:自带参数解析(位置参数不走
# parse_args;未知 flags 由其自行拒绝)。
if [ "$action" = db ] || [ "$action" = config ] || [ "$action" = user ] || [ "$action" = rdm-admin ]; then
env_config "$ENV_NAME"
case "$action" in
db) svc_db_bind "$@" || exit $? ;;
config) svc_config "$@" || exit $? ;;
user) agent_do_user "$@" || exit $? ;;
rdm-admin) agent_do_rdm_admin "$@" || exit $? ;;
esac
exit 0
fi
+167
View File
@@ -0,0 +1,167 @@
"""svc.sh rdm-admin:RDM 域管理员台账查看/受信移交(2026-10-13 8788 dev 锁死复现后)。
背景:U7 验收在共享 dev runtime 执行 -bootstrap-admin u7-admin,把 rdm.db rdm_admins
单行台账登记为测试账号,操作者(基座超管)依 KTD4 不自动获得 RDM 域管理权——
系统管理「RDM 角色权限」全 tab 400。本套覆盖:
- show:台账行输出;台账管理员不在基座 users 表时警示(残留占用可见性)
- transfer:受信单行改写(added_by=svc:rdm-admin:<old>);幂等;typo 防线
(目标不在基座 users 表拒绝);空台账拒绝(引导走 -bootstrap-admin)
- 绑定合同:无 agent-system.db/rdm.db 拒绝;未知子命令 exit 2
- status:rdm.db 存在时输出 RDM 域管理员行与残留警示
夹具复用 tests/test_svc_recover.py(桩不需要运行——台账操作是 SQLite 直读直写)。
"""
import importlib.util
import shutil
import sqlite3
import subprocess
import unittest
from pathlib import Path
SOURCE = Path(__file__).resolve().parents[1]
_spec = importlib.util.spec_from_file_location("tsr", SOURCE / "tests" / "test_svc_recover.py")
tsr = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(tsr)
def sqlite_exec(path, statements):
con = sqlite3.connect(path)
for stmt in statements:
con.execute(stmt)
con.commit()
con.close()
class RdmAdminTests(tsr.SvcAutoRecoverTests):
def seed_dbs(self, name, ledger_owner="u7-admin", base_owners=("weichen", "u7-admin")):
data = self.runtime(name) / "data"
data.mkdir(parents=True, exist_ok=True)
base = data / "agent-system.db"
sqlite_exec(base, [
"CREATE TABLE users (id TEXT PRIMARY KEY, owner TEXT NOT NULL UNIQUE,"
" name TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL,"
" password_hash TEXT NOT NULL DEFAULT '', password_set_at TEXT NOT NULL DEFAULT '',"
" must_change_password INTEGER NOT NULL DEFAULT 0, is_admin INTEGER NOT NULL DEFAULT 0)",
] + ["INSERT INTO users(id,owner,created_at) VALUES('usr_%d','%s','2026-10-12T00:00:00Z')" % (i, o)
for i, o in enumerate(base_owners)])
rdm = data / "rdm.db"
ddl = [
"CREATE TABLE rdm_admins (id INTEGER PRIMARY KEY CHECK(id=1), owner TEXT NOT NULL UNIQUE,"
" added_by TEXT NOT NULL DEFAULT '', added_at TEXT NOT NULL)",
]
if ledger_owner is not None:
ddl.append("INSERT INTO rdm_admins(id,owner,added_by,added_at)"
" VALUES(1,'%s','bootstrap','2026-10-12T14:06:02Z')" % ledger_owner)
sqlite_exec(rdm, ddl)
return rdm
def ledger_owner(self, name):
con = sqlite3.connect("file:%s?mode=ro" % (self.runtime(name) / "data" / "rdm.db"), uri=True)
row = con.execute("select owner, added_by from rdm_admins where id=1").fetchone()
con.close()
return row
# ---- show ----
def test_show_prints_ledger_and_flags_unknown_owner(self):
self.seed_agent("ra1", 9461)
self.seed_dbs("ra1", ledger_owner="u7-admin", base_owners=("weichen",))
out = self.svc("ra1", "rdm-admin", "show").stdout
self.assertIn("RDM 域管理员 u7-admin", out)
self.assertIn("不在基座用户表", out) # u7-admin 不在 users → 残留警示
def test_show_known_owner_no_warning(self):
self.seed_agent("ra2", 9462)
self.seed_dbs("ra2", ledger_owner="weichen")
out = self.svc("ra2", "rdm-admin").stdout # 缺省子命令 = show
self.assertIn("RDM 域管理员 weichen", out)
self.assertNotIn("不在基座用户表", out)
def test_show_empty_ledger_hint(self):
self.seed_agent("ra3", 9463)
self.seed_dbs("ra3", ledger_owner=None)
out = self.svc("ra3", "rdm-admin").stdout
self.assertIn("台账为空", out)
self.assertIn("-bootstrap-admin", out)
# ---- transfer ----
def test_transfer_rewrites_single_row_with_provenance(self):
self.seed_agent("ra4", 9464)
self.seed_dbs("ra4", ledger_owner="u7-admin")
out = self.svc("ra4", "rdm-admin", "transfer", "--to", "weichen").stdout
self.assertIn("受信部署通道", out)
self.assertIn("移交后 weichen", out)
owner, added_by = self.ledger_owner("ra4")
self.assertEqual(owner, "weichen")
self.assertEqual(added_by, "svc:rdm-admin:u7-admin") # 前任可追溯
def test_transfer_idempotent_when_already_owner(self):
self.seed_agent("ra5", 9465)
self.seed_dbs("ra5", ledger_owner="weichen")
out = self.svc("ra5", "rdm-admin", "transfer", "--to", "weichen").stdout
self.assertIn("已是 weichen", out)
def test_transfer_rejects_owner_missing_from_base_users(self):
self.seed_agent("ra6", 9466)
self.seed_dbs("ra6", ledger_owner="weichen")
proc = self.svc("ra6", "rdm-admin", "transfer", "--to", "nobody", check=False)
self.assertEqual(proc.returncode, 1, proc.stdout)
self.assertIn("不在基座用户表", proc.stdout)
self.assertEqual(self.ledger_owner("ra6")[0], "weichen") # 台账未被动
def test_transfer_rejects_empty_ledger(self):
self.seed_agent("ra7", 9467)
self.seed_dbs("ra7", ledger_owner=None)
proc = self.svc("ra7", "rdm-admin", "transfer", "--to", "weichen", check=False)
self.assertEqual(proc.returncode, 1, proc.stdout)
self.assertIn("-bootstrap-admin", proc.stdout)
# ---- 绑定合同与用法 ----
def test_refuses_when_rdm_db_missing(self):
self.seed_agent("ra8", 9468)
self.seed_dbs("ra8") # 建基座库后删 rdm.db
(self.runtime("ra8") / "data" / "rdm.db").unlink()
proc = self.svc("ra8", "rdm-admin", check=False)
self.assertEqual(proc.returncode, 1, proc.stdout)
self.assertIn("无 RDM 域库", proc.stdout)
def test_refuses_when_base_db_missing(self):
self.seed_agent("ra9", 9469)
proc = self.svc("ra9", "rdm-admin", check=False)
self.assertEqual(proc.returncode, 1, proc.stdout)
self.assertIn("既有库不存在", proc.stdout)
def test_unknown_subcommand_usage_exit_2(self):
self.seed_agent("raa", 9470)
proc = self.svc("raa", "rdm-admin", "bogus", check=False)
self.assertEqual(proc.returncode, 2, proc.stdout)
self.assertIn("用法", proc.stdout)
# ---- status 巡检行 ----
def test_status_lists_rdm_admin_row_and_warning(self):
self.seed_agent("rab", 9471)
self.seed_dbs("rab", ledger_owner="u7-admin", base_owners=("weichen",))
out = self.svc("rab", "status").stdout
self.assertIn("RDM 域管理员 u7-admin", out)
self.assertIn("不在基座用户表", out)
def test_status_silent_without_rdm_db(self):
self.seed_agent("rac", 9472)
self.seed_dbs("rac")
(self.runtime("rac") / "data" / "rdm.db").unlink()
out = self.svc("rac", "status").stdout
self.assertNotIn("RDM 域管理员", out)
# ---- help 可发现性 ----
def test_help_topic_lists_rdm_admin(self):
out = self.svc("help", "rdm-admin").stdout
self.assertIn("锁死恢复通道", out)
self.assertIn("rdm.perm.transfer", out)
if __name__ == "__main__":
unittest.main()