diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cc8fad1..5f2820da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Fixed +- 防止内置管理员失权:拒绝停用内置 `admin` 或移除其系统管理员角色,失败时保留原资料、权限和会话;界面同步禁用对应操作。 + - 修复资产管理信息收集向 FOFA 默认请求不支持的 `province` 返回字段导致查询失败的问题,统一使用 `region`;兼容旧表单/API 字段别名,保留导入资产的省份,并修正情报字段预设为 `asn/org`。 - 修复单代理、多代理、角色工作流和批量任务将下层调用超时误判为整任务超时的问题:仅任务上下文自身期限到期才标记任务超时,下层失败保留原始诊断;总时限与现有重试策略保持不变。 diff --git a/docs/en-US/rbac.md b/docs/en-US/rbac.md index 4ca375fe..fa735b51 100644 --- a/docs/en-US/rbac.md +++ b/docs/en-US/rbac.md @@ -53,6 +53,8 @@ Frontend button hiding is only a usability feature. The server is the security b System roles cannot be edited or deleted. Their grants are rebuilt from the current permission catalog during upgrade, preventing stale grants from older versions. Create custom roles for different job functions. +The built-in `admin` account is the permanent management fallback. It cannot be disabled, deleted, or stripped of its system `admin` role. These updates are rejected through both the UI and API, preserving the account details, permissions, and valid sessions. Display name and password updates, and role changes that retain `admin`, remain available. Other accounts can still be disabled or have their roles changed. This protection does not automatically repair previously missing role bindings. + An account without a role can still authenticate but has almost no business capability; do not treat “no role” as a complete job profile. --- diff --git a/docs/zh-CN/rbac.md b/docs/zh-CN/rbac.md index d6e90a18..ce9e7404 100644 --- a/docs/zh-CN/rbac.md +++ b/docs/zh-CN/rbac.md @@ -53,6 +53,8 @@ AI 测试角色不是安全授权边界。即使选择了“渗透测试”角 系统角色不可修改或删除,升级时会按当前版本的权限目录重新构建授权,避免旧版本残留权限。需要不同组合时创建自定义角色。 +内置管理员账号 `admin` 是保底管理入口,不能停用、删除或移除其系统管理员角色。通过界面或 API 提交此类更新都会被拒绝,原账号资料、权限和有效会话保持不变;仍可修改显示名称、密码以及保留 `admin` 角色的其他角色配置。普通账号的启用状态和角色仍可按需调整。此保护不自动修复历史上已丢失的角色绑定。 + 没有分配任何角色的账号仍可登录,但基本没有业务权限;不要依赖“无角色”作为完整岗位配置。 --- diff --git a/internal/database/rbac.go b/internal/database/rbac.go index 9926ed54..8949abe8 100644 --- a/internal/database/rbac.go +++ b/internal/database/rbac.go @@ -1164,6 +1164,9 @@ func (db *DB) UpdateRBACUser(userID, displayName string, enabled *bool, roleIDs return err } defer func() { _ = tx.Rollback() }() + if err := validateBuiltinAdminUpdate(tx, userID, enabled, roleIDs); err != nil { + return err + } if enabled != nil { if _, err := tx.Exec(`UPDATE rbac_users SET display_name = ?, enabled = ?, updated_at = ? WHERE id = ?`, strings.TrimSpace(displayName), boolToInt(*enabled), time.Now(), userID); err != nil { return err diff --git a/internal/database/rbac_admin_guard.go b/internal/database/rbac_admin_guard.go new file mode 100644 index 00000000..98306022 --- /dev/null +++ b/internal/database/rbac_admin_guard.go @@ -0,0 +1,31 @@ +package database + +import ( + "database/sql" + "errors" + "strings" +) + +// validateBuiltinAdminUpdate runs before any account writes in the same +// transaction. The built-in administrator is the permanent management fallback. +func validateBuiltinAdminUpdate(tx *sql.Tx, userID string, enabled *bool, roleIDs *[]string) error { + var builtin bool + if err := tx.QueryRow(`SELECT is_builtin FROM rbac_users WHERE id = ?`, userID).Scan(&builtin); err != nil { + return err + } + if !builtin && userID != "admin" { + return nil + } + if enabled != nil && !*enabled { + return errors.New("内置管理员不能停用") + } + if roleIDs != nil { + for _, roleID := range *roleIDs { + if strings.TrimSpace(roleID) == RBACSystemRoleAdmin { + return nil + } + } + return errors.New("内置管理员必须保留管理员角色") + } + return nil +} diff --git a/internal/database/rbac_admin_guard_test.go b/internal/database/rbac_admin_guard_test.go new file mode 100644 index 00000000..1e6bc219 --- /dev/null +++ b/internal/database/rbac_admin_guard_test.go @@ -0,0 +1,91 @@ +package database + +import ( + "reflect" + "testing" +) + +func TestBuiltinAdminRejectsLossOfAccessAtomically(t *testing.T) { + for _, tc := range []struct { + name string + enabled *bool + roles *[]string + }{ + {name: "empty roles", roles: roleList()}, + {name: "operator only", roles: roleList(RBACSystemRoleOperator)}, + {name: "blank role", roles: roleList(" ")}, + {name: "disable", enabled: boolValue(false)}, + {name: "disable while retaining admin", enabled: boolValue(false), roles: roleList(RBACSystemRoleAdmin)}, + } { + t.Run(tc.name, func(t *testing.T) { + db := newRBACTestDB(t) + if err := db.BootstrapRBAC("original-hash", map[string]string{"rbac:write": "manage", "auth:self": "self"}); err != nil { + t.Fatal(err) + } + before, err := db.GetRBACUserByID("admin") + if err != nil { + t.Fatal(err) + } + if err := db.UpdateRBACUser("admin", "must not persist", tc.enabled, tc.roles); err == nil { + t.Fatal("destructive admin update succeeded") + } + after, err := db.GetRBACUserByID("admin") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, after) { + t.Fatalf("rejected update changed account: before=%#v after=%#v", before, after) + } + access, err := db.ResolveRBACAccess("admin") + if err != nil { + t.Fatal(err) + } + if len(access.Roles) != 1 || access.Roles[0].ID != RBACSystemRoleAdmin || !access.Permissions["rbac:write"] || access.Scope != RBACScopeAll { + t.Fatalf("admin access changed: %#v", access) + } + }) + } +} + +func TestBuiltinAdminAllowsSafeMaintenanceAndMemberChanges(t *testing.T) { + db := newRBACTestDB(t) + if err := db.BootstrapRBAC("hash", map[string]string{"rbac:write": "manage"}); err != nil { + t.Fatal(err) + } + if err := db.UpdateRBACUser("admin", "Renamed", nil, nil); err != nil { + t.Fatal(err) + } + if err := db.UpdateRBACUser("admin", "Renamed", boolValue(true), roleList(" admin ", RBACSystemRoleViewer)); err != nil { + t.Fatal(err) + } + if err := db.UpdateRBACUserPassword("admin", "new-hash"); err != nil { + t.Fatal(err) + } + admin, err := db.GetRBACUserByID("admin") + if err != nil { + t.Fatal(err) + } + if admin.DisplayName != "Renamed" || admin.PasswordHash != "new-hash" || !admin.Enabled { + t.Fatalf("safe maintenance failed: %#v", admin) + } + member, err := db.CreateRBACUser("operator", "Operator", "hash", true, []string{RBACSystemRoleAdmin, RBACSystemRoleOperator}) + if err != nil { + t.Fatal(err) + } + if err := db.UpdateRBACUser(member.ID, "Member", boolValue(false), roleList()); err != nil { + t.Fatal(err) + } + access, err := db.ResolveRBACAccess(member.ID) + if err != nil { + t.Fatal(err) + } + if access.User.Enabled || len(access.Roles) != 0 { + t.Fatalf("ordinary member changes blocked: %#v", access) + } + if err := db.DeleteRBACUser("admin"); err == nil { + t.Fatal("deleted builtin admin") + } +} + +func roleList(roles ...string) *[]string { return &roles } +func boolValue(value bool) *bool { return &value } diff --git a/internal/handler/rbac_admin_guard_test.go b/internal/handler/rbac_admin_guard_test.go new file mode 100644 index 00000000..6b42d432 --- /dev/null +++ b/internal/handler/rbac_admin_guard_test.go @@ -0,0 +1,89 @@ +package handler + +import ( + "bytes" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "cyberstrike-ai/internal/database" + "cyberstrike-ai/internal/security" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +func TestBuiltinAdminProtectionPreservesAuthentication(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := database.NewDB(filepath.Join(t.TempDir(), "admin-guard.db"), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + auth := security.NewAuthManager(12) + password, err := auth.AttachRBACStore(db) + if err != nil { + t.Fatal(err) + } + token, _, err := auth.Authenticate("admin", password) + if err != nil { + t.Fatal(err) + } + h := NewRBACHandler(db, zap.NewNop()) + h.SetAuthManager(auth) + router := gin.New() + router.Use(security.AuthMiddleware(auth), security.RBACMiddleware(db)) + router.POST("/api/rbac/users", h.CreateUser) + router.PUT("/api/rbac/users/:id", h.UpdateUser) + router.DELETE("/api/rbac/users/:id", h.DeleteUser) + router.GET("/api/rbac/users", h.ListUsers) + request := func(method, path, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, path, bytes.NewBufferString(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w + } + // The reported trigger must not change the administrator's authorization. + w := request(http.MethodPost, "/api/rbac/users", `{"username":"new-operator","password":"operator-password","roles":["operator"]}`) + if w.Code != http.StatusOK { + t.Fatalf("create operator: %d %s", w.Code, w.Body.String()) + } + for _, body := range []string{ + `{"display_name":"must not persist","roles":[],"password":"changed-password"}`, + `{"roles":["operator"]}`, + `{"enabled":false}`, + `{"enabled":false,"roles":["admin"]}`, + } { + w := request(http.MethodPut, "/api/rbac/users/admin", body) + if w.Code != http.StatusBadRequest { + t.Fatalf("update %s: %d %s", body, w.Code, w.Body.String()) + } + if w := request(http.MethodGet, "/api/rbac/users", ""); w.Code != http.StatusOK { + t.Fatalf("rejection revoked admin access: %d %s", w.Code, w.Body.String()) + } + } + if w := request(http.MethodDelete, "/api/rbac/users/admin", ""); w.Code != http.StatusBadRequest { + t.Fatalf("delete builtin: %d %s", w.Code, w.Body.String()) + } + user, err := db.GetRBACUserByUsername("admin") + if err != nil { + t.Fatal(err) + } + if user.DisplayName != "管理员" || !user.Enabled { + t.Fatalf("rejected update mutated admin: %#v", user) + } + newToken, _, err := auth.Authenticate("admin", password) + if err != nil { + t.Fatalf("original password no longer works: %v", err) + } + for _, tok := range []string{token, newToken} { + session, ok := auth.ValidateToken(tok) + if !ok || !session.Permissions["rbac:write"] || session.Scope != database.RBACScopeAll { + t.Fatalf("admin authorization lost: %#v valid=%v", session, ok) + } + } +} diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index ef836538..ec2660f9 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -4793,6 +4793,8 @@ "deleteRoleConfirm": "Delete this platform role?", "scopeHint": "Resource scope and operation permissions jointly determine effective access.", "builtinAccount": "Built-in account", + "protectBuiltinAdminRole": "The built-in administrator must retain the admin role", + "protectBuiltinAdminEnabled": "The built-in administrator cannot be disabled", "statusEnabled": "Enabled", "statusDisabled": "Disabled", "noRoleAssigned": "No role assigned", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index eb943a0e..91d84d18 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -4806,6 +4806,8 @@ "deleteRoleConfirm": "Удалить эту роль платформы?", "scopeHint": "Область ресурсов и права на операции вместе определяют фактический доступ.", "builtinAccount": "Встроенная учётная запись", + "protectBuiltinAdminRole": "Встроенный администратор должен сохранять роль admin", + "protectBuiltinAdminEnabled": "Встроенного администратора нельзя отключить", "statusEnabled": "Включена", "statusDisabled": "Отключена", "noRoleAssigned": "Роль не назначена", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index e913f938..c43d1be7 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -4781,6 +4781,8 @@ "deleteRoleConfirm": "确认删除该平台角色?", "scopeHint": "资源范围与操作权限共同决定最终访问能力。", "builtinAccount": "内置账号", + "protectBuiltinAdminRole": "内置管理员必须保留管理员角色", + "protectBuiltinAdminEnabled": "内置管理员不能停用", "statusEnabled": "启用", "statusDisabled": "停用", "noRoleAssigned": "未分配角色", diff --git a/web/static/js/rbac-admin-guard.test.cjs b/web/static/js/rbac-admin-guard.test.cjs new file mode 100644 index 00000000..0bbf2943 --- /dev/null +++ b/web/static/js/rbac-admin-guard.test.cjs @@ -0,0 +1,84 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +function setup() { + const elements = new Map([ + 'rbac-user-id', 'rbac-username', 'rbac-display-name', 'rbac-password', + 'rbac-user-enabled', 'rbac-user-roles-create-section', 'rbac-user-roles-edit-section', + 'rbac-user-roles-readonly', 'rbac-user-role-checkboxes', 'rbac-roles-list', + ].map(id => [id, { value: '', checked: false, disabled: false, dataset: {}, innerHTML: '' }])); + const context = vm.createContext({ + window: {}, console, + document: { getElementById: id => elements.get(id) || null, querySelector: () => null, addEventListener() {} }, + openAppModal() {}, + escapeHtml: text => String(text), + }); + vm.runInContext(fs.readFileSync(path.join(__dirname, 'rbac.js'), 'utf8') + '\nglobalThis.state = rbacState;', context); + context.state.users = [ + { id: 'admin', username: 'admin', enabled: true, is_builtin: true, roles: ['admin'] }, + { id: 'member', username: 'member', enabled: true, roles: ['admin', 'operator'] }, + ]; + context.state.roles = [ + { id: 'admin', name: 'Administrator', scope: 'all', is_system: true }, + { id: 'operator', name: 'Operator', scope: 'assigned', is_system: true }, + ]; + return { context, elements }; +} + +function adminCheckbox(html) { + return html.match(/]+onchange="toggleSelectedUserRole\('admin'[^>]+>/)[0]; +} + +test('built-in enabled control is locked, then resets for member editing and creation', () => { + const { context, elements } = setup(); + const input = elements.get('rbac-user-enabled'); + context.openRbacUserModal('admin'); + assert.equal(input.checked, true); + assert.equal(input.disabled, true); + assert.equal(input.dataset.stateDisabled, 'true'); + assert.ok(input.title); + context.openRbacUserModal('member'); + assert.equal(input.disabled, false); + assert.equal(input.dataset.stateDisabled, 'false'); + assert.equal(input.title, ''); + context.openRbacUserModal('admin'); + context.openRbacUserModal(); + assert.equal(input.disabled, false); + assert.equal(input.checked, true); + assert.equal(elements.get('rbac-user-id').value, ''); +}); + +test('built-in admin role cannot be unchecked; another admin account can be demoted', () => { + const { context, elements } = setup(); + context.state.selectedUserId = 'admin'; + context.renderRbacRoles(); + let checkbox = adminCheckbox(elements.get('rbac-roles-list').innerHTML); + assert.match(checkbox, /\bchecked\b/); + assert.match(checkbox, /\bdisabled\b/); + context.toggleSelectedUserRole('admin', false); + assert.equal(context.state.pendingUserRoles.has('admin'), true); + context.state.selectedUserId = 'member'; + context.renderRbacRoles(); + checkbox = adminCheckbox(elements.get('rbac-roles-list').innerHTML); + assert.doesNotMatch(checkbox, /\bdisabled\b/); + context.toggleSelectedUserRole('admin', false); + assert.equal(context.state.pendingUserRoles.has('admin'), false); + assert.equal(context.state.pendingUserRoles.has('operator'), true); +}); + +test('protection still permits explicitly restoring a historically damaged built-in account', () => { + const { context, elements } = setup(); + const admin = context.state.users[0]; + admin.enabled = false; + admin.roles = []; + context.openRbacUserModal('admin'); + assert.equal(elements.get('rbac-user-enabled').disabled, false); + context.state.selectedUserId = 'admin'; + context.renderRbacRoles(); + assert.doesNotMatch(adminCheckbox(elements.get('rbac-roles-list').innerHTML), /\bdisabled\b/); + context.toggleSelectedUserRole('admin', true); + assert.match(adminCheckbox(elements.get('rbac-roles-list').innerHTML), /\bdisabled\b/); +}); diff --git a/web/static/js/rbac.js b/web/static/js/rbac.js index eec6f7d1..df747f73 100644 --- a/web/static/js/rbac.js +++ b/web/static/js/rbac.js @@ -714,6 +714,7 @@ function renderRbacRoles() { const userRoles = user ? rbacState.pendingUserRoles : new Set(); box.innerHTML = rbacState.roles.map(role => { const checked = userRoles.has(role.id); + const protectedRole = rbacUserIsBuiltin(user) && role.id === 'admin' && checked; const permissionCount = (role.permissions || []).length; const assignedUsers = rbacState.users.filter(u => rbacUserRoles(u).includes(role.id)).length; const scope = rbacScopeInfo(role.scope); @@ -721,7 +722,7 @@ function renderRbacRoles() {
@@ -751,7 +752,8 @@ function ensurePendingUserRoles(user) { } function toggleSelectedUserRole(roleId, enabled) { - if (!selectedRbacUser()) return; + const user = selectedRbacUser(); + if (!user || (rbacUserIsBuiltin(user) && roleId === 'admin' && !enabled)) return; if (enabled) rbacState.pendingUserRoles.add(roleId); else rbacState.pendingUserRoles.delete(roleId); renderRbacRoles(); @@ -1232,7 +1234,11 @@ function openRbacUserModal(userId = '') { document.getElementById('rbac-username').disabled = !!user; document.getElementById('rbac-display-name').value = user ? (user.displayName || user.display_name || '') : ''; document.getElementById('rbac-password').value = ''; - document.getElementById('rbac-user-enabled').checked = user ? !!user.enabled : true; + const enabledInput = document.getElementById('rbac-user-enabled'); + enabledInput.checked = user ? !!user.enabled : true; + enabledInput.disabled = rbacUserIsBuiltin(user) && enabledInput.checked; + enabledInput.dataset.stateDisabled = String(enabledInput.disabled); + enabledInput.title = enabledInput.disabled ? rbacT('rbac.protectBuiltinAdminEnabled', '内置管理员不能停用') : ''; const createRoles = document.getElementById('rbac-user-roles-create-section'); const editRoles = document.getElementById('rbac-user-roles-edit-section'); if (createRoles) createRoles.hidden = !!user;