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 }