fix(rbac): protect built-in administrator from losing access

This commit is contained in:
Ed1s0nZ committed 2026-10-19 16:44:14 +08:00
1 parent e6121e83eb
commit be2c8c04e5
12 files changed
+319 -3

No files matched your search

+3
View File
@@ -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
+31
View File
@@ -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 }
+89
View File
@@ -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)
}
}
}