feat(server/rbac): 权限页聚合迁移、最后管理员保护与配置原子落盘

PageCatalog 将 admin-config 重构为 6 大聚合分类,并加旧 key 别名兼容旧链接;
RbacStore 接入原子写 + 损坏备份,新增「最后管理员」保护,避免操作后系统无任何在岗管理员;
ConfigStore 改用 AtomicFile 持久化;新增 Infra/AtomicFile 原子写工具(临时名带 GUID 防并发)。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zhaowei.huang
2026-06-08 16:09:01 +08:00
co-authored by Cursor
parent 91318bd7f4
commit e77ee818f1
4 changed files with 155 additions and 20 deletions
+69 -3
View File
@@ -2,6 +2,7 @@ using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using MiGu.Server.Configs;
using MiGu.Server.Infra;
namespace MiGu.Server.Auth;
@@ -67,6 +68,11 @@ public sealed class RbacStore
catch (Exception ex)
{
_logger.LogError(ex, "RBAC 文件 {File} 解析失败,回退到默认 seed。", _file);
// S2:先备份疑似损坏的 rbac.json,避免随后默认 seed 的写入把自定义用户 / 角色
// 永久冲掉(损坏 → 静默重置成 admin/ops 弱口令是高危场景)。
var bak = AtomicFile.BackupCorrupt(_file);
if (bak != null)
_logger.LogWarning("已备份疑似损坏的 RBAC 文件到 {Backup},请人工核查后恢复自定义数据。", bak);
}
}
@@ -90,6 +96,7 @@ public sealed class RbacStore
r.Ops = (r.Ops ?? new()).Distinct().ToList();
r.WidgetGrants ??= new();
if (string.IsNullOrWhiteSpace(r.Scope)) r.Scope = PageCatalog.ScopePlatform;
BackfillKnownPageMigrations(r);
}
foreach (var u in snap.Users)
{
@@ -98,6 +105,23 @@ public sealed class RbacStore
return snap;
}
private static void BackfillKnownPageMigrations(RbacRole r)
{
if (!string.Equals(r.Scope, PageCatalog.ScopePlatform, StringComparison.OrdinalIgnoreCase)
&& r.Scope != PageCatalog.Wildcard) return;
// 任务编排页(admin-task-templates,原任务模板 / WorkflowEditor)与脚本、进程管理同属一组编排能力。
// 旧的 admin-missions 入口已下线:上面 Normalize 会按 PageCatalog 过滤掉历史角色里的该 key。
// 这里在角色已有进程 + 脚本入口时补齐「任务编排」菜单权限。
var hasProcessAndScript =
r.Pages.Contains("admin-processes", StringComparer.OrdinalIgnoreCase)
&& r.Pages.Contains("admin-scripts", StringComparer.OrdinalIgnoreCase);
if (!r.Pages.Contains(PageCatalog.Wildcard)
&& !r.Pages.Contains("admin-task-templates", StringComparer.OrdinalIgnoreCase)
&& hasProcessAndScript)
r.Pages.Add("admin-task-templates");
}
private RbacSnapshot SeedDefault(IConfiguration config)
{
var adminPwd = config["Auth:Users:admin:Password"] ?? "admin";
@@ -123,7 +147,7 @@ public sealed class RbacStore
{
Id = RoleOpsId, Name = "运营人员", Description = "运营监控端默认角色:可执行运维操作、查看监控",
Scope = PageCatalog.ScopeMonitor,
Pages = new() { "monitor-dashboard", "monitor-map", "monitor-ops", "monitor-notes" },
Pages = new() { "monitor-dashboard", "monitor-vehicle-hub", "monitor-map", "monitor-ops", "monitor-notes" },
Ops = new()
{
"ops.car.pause", "ops.car.resume", "ops.car.gohome", "ops.car.resetSession",
@@ -154,7 +178,7 @@ public sealed class RbacStore
{
try
{
File.WriteAllText(_file, JsonSerializer.Serialize(_snapshot, _jsonOpts));
AtomicFile.WriteAllText(_file, JsonSerializer.Serialize(_snapshot, _jsonOpts));
}
catch (Exception ex)
{
@@ -290,9 +314,15 @@ public sealed class RbacStore
lock (_gate)
{
var u = _snapshot.Users.FirstOrDefault(x => x.Id == id) ?? throw new RbacException("用户不存在");
var oldName = u.DisplayName;
var oldRoles = u.RoleIds;
var oldEnabled = u.Enabled;
if (req.DisplayName is not null) u.DisplayName = req.DisplayName.Trim();
if (req.RoleIds is not null) u.RoleIds = FilterExistingRoles(req.RoleIds);
if (req.Enabled is bool en) u.Enabled = en;
// M5:若本次改动(改角色 / 停用)导致系统再无有效管理员,则回滚后报错。
try { EnsureAdminRemainsNoLock(); }
catch { u.DisplayName = oldName; u.RoleIds = oldRoles; u.Enabled = oldEnabled; throw; }
Persist();
return ToDto(u);
}
@@ -316,7 +346,11 @@ public sealed class RbacStore
lock (_gate)
{
var u = _snapshot.Users.FirstOrDefault(x => x.Id == id) ?? throw new RbacException("用户不存在");
_snapshot.Users.Remove(u);
var idx = _snapshot.Users.IndexOf(u);
_snapshot.Users.RemoveAt(idx);
// M5:删除后若系统再无有效管理员,则恢复并报错。
try { EnsureAdminRemainsNoLock(); }
catch { _snapshot.Users.Insert(idx, u); throw; }
Persist();
}
}
@@ -353,12 +387,21 @@ public sealed class RbacStore
lock (_gate)
{
var role = _snapshot.Roles.FirstOrDefault(r => r.Id == id) ?? throw new RbacException("角色不存在");
var backup = Clone(role);
role.Name = req.Name.Trim();
role.Description = req.Description?.Trim() ?? "";
role.Scope = scope;
role.Pages = SanitizePages(req.Pages);
role.Ops = req.Ops?.Distinct().ToList() ?? new();
role.WidgetGrants = req.WidgetGrants ?? new();
// M5:若本次改动(如去掉角色的 "*"/auth.manage)导致系统再无有效管理员,则回滚。
try { EnsureAdminRemainsNoLock(); }
catch
{
role.Name = backup.Name; role.Description = backup.Description; role.Scope = backup.Scope;
role.Pages = backup.Pages; role.Ops = backup.Ops; role.WidgetGrants = backup.WidgetGrants;
throw;
}
Persist();
return Clone(role);
}
@@ -392,6 +435,29 @@ public sealed class RbacStore
user.RoleIds.Select(id => _snapshot.Roles.FirstOrDefault(r => r.Id == id))
.Where(r => r is not null).Select(r => r!).ToList();
private const string OpAuthManage = "auth.manage";
/// <summary>
/// 该用户当前是否为「有效系统管理员」:启用 且 至少一个角色的 Ops 含 "*" 或 "auth.manage"。
/// 用于 M5「最后管理员」保护。
/// </summary>
private bool IsActiveAdminNoLock(RbacUser u)
{
if (!u.Enabled) return false;
foreach (var r in RolesOf(u))
if (r.Ops.Contains("*") || r.Ops.Contains(OpAuthManage)) return true;
return false;
}
private int CountActiveAdminsNoLock() => _snapshot.Users.Count(IsActiveAdminNoLock);
/// <summary>校验修改应用后系统仍至少有一名有效管理员,否则抛异常(调用方负责回滚内存改动)。</summary>
private void EnsureAdminRemainsNoLock()
{
if (CountActiveAdminsNoLock() == 0)
throw new RbacException("该操作会使系统再无任何具备管理权限(auth.manage)的启用账号,已阻止。请至少保留一名超级管理员。");
}
private List<string> FilterExistingRoles(List<string>? roleIds) =>
(roleIds ?? new()).Where(id => _snapshot.Roles.Any(r => r.Id == id)).Distinct().ToList();