mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-20 22:41:23 +02:00
fix(rbac): protect built-in administrator from losing access
This commit is contained in:
1 parent
e6121e83eb
commit
be2c8c04e5
12 files changed
+319
-3
No files matched your search
@@ -8,6 +8,8 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- 防止内置管理员失权:拒绝停用内置 `admin` 或移除其系统管理员角色,失败时保留原资料、权限和会话;界面同步禁用对应操作。
|
||||
|
||||
- 修复资产管理信息收集向 FOFA 默认请求不支持的 `province` 返回字段导致查询失败的问题,统一使用 `region`;兼容旧表单/API 字段别名,保留导入资产的省份,并修正情报字段预设为 `asn/org`。
|
||||
|
||||
- 修复单代理、多代理、角色工作流和批量任务将下层调用超时误判为整任务超时的问题:仅任务上下文自身期限到期才标记任务超时,下层失败保留原始诊断;总时限与现有重试策略保持不变。
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -53,6 +53,8 @@ AI 测试角色不是安全授权边界。即使选择了“渗透测试”角
|
||||
|
||||
系统角色不可修改或删除,升级时会按当前版本的权限目录重新构建授权,避免旧版本残留权限。需要不同组合时创建自定义角色。
|
||||
|
||||
内置管理员账号 `admin` 是保底管理入口,不能停用、删除或移除其系统管理员角色。通过界面或 API 提交此类更新都会被拒绝,原账号资料、权限和有效会话保持不变;仍可修改显示名称、密码以及保留 `admin` 角色的其他角色配置。普通账号的启用状态和角色仍可按需调整。此保护不自动修复历史上已丢失的角色绑定。
|
||||
|
||||
没有分配任何角色的账号仍可登录,但基本没有业务权限;不要依赖“无角色”作为完整岗位配置。
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -4806,6 +4806,8 @@
|
||||
"deleteRoleConfirm": "Удалить эту роль платформы?",
|
||||
"scopeHint": "Область ресурсов и права на операции вместе определяют фактический доступ.",
|
||||
"builtinAccount": "Встроенная учётная запись",
|
||||
"protectBuiltinAdminRole": "Встроенный администратор должен сохранять роль admin",
|
||||
"protectBuiltinAdminEnabled": "Встроенного администратора нельзя отключить",
|
||||
"statusEnabled": "Включена",
|
||||
"statusDisabled": "Отключена",
|
||||
"noRoleAssigned": "Роль не назначена",
|
||||
|
||||
@@ -4781,6 +4781,8 @@
|
||||
"deleteRoleConfirm": "确认删除该平台角色?",
|
||||
"scopeHint": "资源范围与操作权限共同决定最终访问能力。",
|
||||
"builtinAccount": "内置账号",
|
||||
"protectBuiltinAdminRole": "内置管理员必须保留管理员角色",
|
||||
"protectBuiltinAdminEnabled": "内置管理员不能停用",
|
||||
"statusEnabled": "启用",
|
||||
"statusDisabled": "停用",
|
||||
"noRoleAssigned": "未分配角色",
|
||||
|
||||
@@ -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(/<input[^>]+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/);
|
||||
});
|
||||
@@ -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() {
|
||||
<article class="rbac-role-card${checked ? ' is-selected' : ''}">
|
||||
<div class="rbac-role-card-head">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" class="modern-checkbox" ${checked ? 'checked' : ''} ${!user ? 'disabled' : ''} onchange="toggleSelectedUserRole('${rbacEscape(role.id)}', this.checked)">
|
||||
<input type="checkbox" class="modern-checkbox" ${checked ? 'checked' : ''} ${!user || protectedRole ? 'disabled' : ''} title="${protectedRole ? rbacEscape(rbacT('rbac.protectBuiltinAdminRole', '内置管理员必须保留管理员角色')) : ''}" onchange="toggleSelectedUserRole('${rbacEscape(role.id)}', this.checked)">
|
||||
<span class="checkbox-custom"></span>
|
||||
<span class="checkbox-text"><strong>${rbacEscape(rbacRoleName(role))}</strong></span>
|
||||
</label>
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user