Auth: Add cluster_admin role with admin-tier and federation helpers

Adds the Portal-only cluster_admin operator role (declared in CE, registered
only on Portal builds), the acl.AdminRoles/IsAdminRole admin-tier set, and the
acl.IsFederatedRole/FederatedRoleUpdate helpers that keep cluster_admin and
visitor out of externally mapped account roles. SuperAdmin resolves to
cluster_admin on registered builds, SaveForm keeps an admin-level role on a
self-save, and UpdateUser rejects self-role changes. RoleStrings.Strings hides
visitor and none from the CLI role help.
This commit is contained in:
Michael Mayer 2026-06-02 11:57:02 +00:00
parent e81794a533
commit fbe8a6889b
8 changed files with 249 additions and 60 deletions

View file

@ -64,6 +64,13 @@ func UpdateUser(router *gin.RouterGroup) {
return
}
// System accounts (Unknown id=-1, Visitor id=-2) must not be modified.
if m.ID < 0 {
event.AuditErr([]string{ClientIP(c), "session %s", "users", clean.Log(uid), "update", status.Denied}, s.RefID)
AbortForbidden(c)
return
}
// Initialize form with model values.
f, err := m.Form()
@ -98,6 +105,15 @@ func UpdateUser(router *gin.RouterGroup) {
// Get user from session.
u := s.GetUser()
// Prevent users from changing their own account role, which could lock
// an operator out of the admin UI (e.g. a cluster_admin demoting
// themselves). Other own-profile fields remain editable.
if u != nil && u.UserUID == m.UserUID && f.UserRole != "" && clean.Role(f.UserRole) != clean.Role(m.UserRole) {
event.AuditErr([]string{ClientIP(c), "session %s", "users", m.UserName, "update own role", status.Denied}, s.RefID)
AbortForbidden(c)
return
}
// Persist form values.
if err = m.SaveForm(f, u); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "users", m.UserName, "update", err.Error()}, s.RefID)

View file

@ -202,3 +202,26 @@ func TestUpdateUser(t *testing.T) {
assert.Equal(t, http.StatusRequestEntityTooLarge, r.Code)
})
}
func TestUpdateUser_Guards(t *testing.T) {
t.Run("SelfRoleChangeForbidden", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
UpdateUser(router)
sessId := AuthenticateUser(app, router, "alice", "Alice123!")
body, _ := json.Marshal(form.User{UserName: "alice", UserRole: "user"})
r := AuthenticatedRequestWithBody(app, "PUT", "/api/v1/users/uqxetse3cy5eo9z2", string(body), sessId)
assert.Equal(t, http.StatusForbidden, r.Code)
})
t.Run("SystemAccountForbidden", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
UpdateUser(router)
sessId := AuthenticateUser(app, router, "alice", "Alice123!")
body, _ := json.Marshal(form.User{DisplayName: "Hacked"})
r := AuthenticatedRequestWithBody(app, "PUT", "/api/v1/users/"+entity.UnknownUser.UserUID, string(body), sessId)
assert.Equal(t, http.StatusForbidden, r.Code)
})
}

View file

@ -8,17 +8,21 @@ const RoleAliasNone = "none"
// Roles that can be granted Permissions to use a Resource.
const (
RoleDefault Role = "default"
RoleAdmin Role = "admin"
RoleUser Role = "user"
RoleViewer Role = "viewer"
RoleGuest Role = "guest"
RoleVisitor Role = "visitor"
RoleInstance Role = "instance"
RoleService Role = "service"
RolePortal Role = "portal"
RoleClient Role = "client"
RoleNone Role = ""
RoleDefault Role = "default"
RoleAdmin Role = "admin"
// RoleClusterAdmin is a Portal-only operator role for the cluster admin UI.
// It is registered (and thus assignable and granted permissions) only on
// Portal builds; CE/Pro leave it absent from acl.UserRoles and acl.Rules.
RoleClusterAdmin Role = "cluster_admin"
RoleUser Role = "user"
RoleViewer Role = "viewer"
RoleGuest Role = "guest"
RoleVisitor Role = "visitor"
RoleInstance Role = "instance"
RoleService Role = "service"
RolePortal Role = "portal"
RoleClient Role = "client"
RoleNone Role = ""
)
// Permissions to use a Resource that can be granted to a Role.

View file

@ -29,29 +29,66 @@ var ClientRoles = RoleStrings{
RoleAliasNone: RoleNone,
}
// Strings returns the roles as string slice.
// AdminRoles maps the roles that grant administrative privileges. The
// Portal-only cluster_admin is treated as an admin-tier role everywhere admin
// privileges are checked (e.g. user-management self-lockout protection), so a
// cluster_admin owner is not forced or downgraded to the plain admin role.
var AdminRoles = RoleStrings{
string(RoleAdmin): RoleAdmin,
string(RoleClusterAdmin): RoleClusterAdmin,
}
// IsAdminRole reports whether role is an administrative role (admin or cluster_admin).
func IsAdminRole(role Role) bool {
_, ok := AdminRoles[string(role)]
return ok
}
// IsFederatedRole reports whether role may be assigned to a user account through
// an external identity provider (OIDC group/role claims or LDAP group/attribute
// mapping). The Portal-only operator role cluster_admin and the anonymous
// visitor role are never federated, and the empty role is rejected so a missing
// mapping cannot clear an account's role. Federation therefore neither grants
// nor revokes a non-federatable role: a compromised IdP/AD cannot escalate an
// account to operator access (the Portal Admin UI), and an existing
// cluster_admin/visitor account is never changed by a directory sync.
func IsFederatedRole(role Role) bool {
switch role {
case RoleNone, RoleClusterAdmin, RoleVisitor:
return false
default:
return true
}
}
// FederatedRoleUpdate reports the account role an external identity provider may
// apply to an existing user and whether to apply it. Federation neither grants
// nor revokes a non-federatable role, so it returns ok=false when the current
// role is non-federatable (cluster_admin/visitor must not be touched), when the
// mapped role is non-federatable (no escalation to operator/visitor), or when
// the role is unchanged. Both the OIDC and LDAP sync paths share this decision.
func FederatedRoleUpdate(current, mapped Role) (Role, bool) {
if !IsFederatedRole(current) || !IsFederatedRole(mapped) || current == mapped {
return RoleNone, false
}
return mapped, true
}
// Strings returns the roles as string slice for display, e.g. CLI help.
func (m RoleStrings) Strings() []string {
result := make([]string, 0, len(m))
includesNone := false
for r := range m {
if r == "app" {
if r == "" || r == RoleAliasNone || r == "app" || r == RoleVisitor.String() {
continue
}
if r == RoleAliasNone {
includesNone = true
} else if r != string(RoleNone) {
result = append(result, r)
}
result = append(result, r)
}
sort.Strings(result)
if includesNone {
result = append(result, RoleAliasNone)
}
return result
}

View file

@ -18,8 +18,10 @@ func TestRoleStrings_Strings_SortedAndNoEmpty(t *testing.T) {
got := m.Strings()
// Expect deterministic, sorted output and no empty entries.
assert.Equal(t, []string{"admin", "guest", "visitor"}, got)
// Expect deterministic, sorted output, no empty entries, and visitor
// excluded (reserved for anonymous/link-share access, never offered).
assert.Equal(t, []string{"admin", "guest"}, got)
assert.NotContains(t, got, "visitor")
assert.True(t, sort.StringsAreSorted(got))
}
@ -47,8 +49,12 @@ func TestRoleStrings_CliUsageString(t *testing.T) {
assert.Equal(t, "admin, or guest", m.CliUsageString())
})
t.Run("Three", func(t *testing.T) {
m := RoleStrings{"user": RoleUser, "guest": RoleGuest, "admin": RoleAdmin}
assert.Equal(t, "admin, guest, or user", m.CliUsageString())
})
t.Run("ExcludesVisitor", func(t *testing.T) {
m := RoleStrings{"visitor": RoleVisitor, "guest": RoleGuest, "admin": RoleAdmin}
assert.Equal(t, "admin, guest, or visitor", m.CliUsageString())
assert.Equal(t, "admin, or guest", m.CliUsageString())
})
}
@ -84,36 +90,43 @@ func TestRoles_Allow(t *testing.T) {
}
func TestRoleStrings_GlobalMaps_AliasNoneAndUsage(t *testing.T) {
t.Run("ClientRolesStringsIncludeAliasNoneExcludeEmpty", func(t *testing.T) {
t.Run("ClientRolesStringsExcludeAliasNoneAndEmpty", func(t *testing.T) {
got := ClientRoles.Strings()
// Contains exactly the expected elements, order not enforced.
assert.ElementsMatch(t, []string{"admin", "instance", "client", "none", "portal", "service"}, got)
// Does not include empty string
// Contains exactly the expected elements, order not enforced; the "none"
// alias and the empty role are excluded from display.
assert.ElementsMatch(t, []string{"admin", "instance", "client", "portal", "service"}, got)
assert.NotContains(t, got, "none")
// Does not include empty string.
for _, s := range got {
assert.NotEqual(t, "", s)
}
})
t.Run("UserRolesStringsIncludeAliasNoneExcludeEmpty", func(t *testing.T) {
t.Run("UserRolesStringsExcludeAliasNoneEmptyAndVisitor", func(t *testing.T) {
got := UserRoles.Strings()
assert.ElementsMatch(t, []string{"admin", "guest", "none", "visitor"}, got)
assert.ElementsMatch(t, []string{"admin", "guest"}, got)
assert.NotContains(t, got, "none")
assert.NotContains(t, got, "visitor")
for _, s := range got {
assert.NotEqual(t, "", s)
}
})
t.Run("ClientRolesCliUsageStringIncludesNoneAndOrBeforeLast", func(t *testing.T) {
t.Run("ClientRolesCliUsageStringExcludesNoneAndOrBeforeLast", func(t *testing.T) {
u := ClientRoles.CliUsageString()
// Should list known roles and end with "or none" (alias present).
for _, s := range []string{"admin", "client", "instance", "portal", "service", "none"} {
// Should list known roles and end with "or service"; the "none" alias is excluded.
for _, s := range []string{"admin", "client", "instance", "portal", "service"} {
assert.Contains(t, u, s)
}
assert.Regexp(t, `, or none$`, u)
assert.NotContains(t, u, "none")
assert.Regexp(t, `, or service$`, u)
})
t.Run("UserRolesCliUsageStringIncludesNoneAndOrBeforeLast", func(t *testing.T) {
t.Run("UserRolesCliUsageStringExcludesNoneVisitorAndOrBeforeLast", func(t *testing.T) {
u := UserRoles.CliUsageString()
for _, s := range []string{"admin", "guest", "visitor", "none"} {
for _, s := range []string{"admin", "guest"} {
assert.Contains(t, u, s)
}
assert.Regexp(t, `, or none$`, u)
assert.NotContains(t, u, "none")
assert.NotContains(t, u, "visitor")
assert.Regexp(t, `, or guest$`, u)
})
t.Run("AliasNoneMapsToRoleNone", func(t *testing.T) {
assert.Equal(t, RoleNone, ClientRoles[RoleAliasNone])
@ -177,3 +190,61 @@ func TestResourceNames_ContainsCore(t *testing.T) {
assert.Truef(t, found, "resource %s not found in ResourceNames", w)
}
}
func TestIsAdminRole(t *testing.T) {
assert.True(t, IsAdminRole(RoleAdmin))
assert.True(t, IsAdminRole(RoleClusterAdmin))
assert.False(t, IsAdminRole(RoleUser))
assert.False(t, IsAdminRole(RoleViewer))
assert.False(t, IsAdminRole(RoleGuest))
assert.False(t, IsAdminRole(RoleVisitor))
assert.False(t, IsAdminRole(RoleNone))
assert.False(t, IsAdminRole(Role("manager")))
}
func TestIsFederatedRole(t *testing.T) {
t.Run("Federatable", func(t *testing.T) {
assert.True(t, IsFederatedRole(RoleAdmin))
assert.True(t, IsFederatedRole(RoleUser))
assert.True(t, IsFederatedRole(RoleViewer))
assert.True(t, IsFederatedRole(RoleGuest))
assert.True(t, IsFederatedRole(Role("manager")))
assert.True(t, IsFederatedRole(Role("contributor")))
})
t.Run("NotFederatable", func(t *testing.T) {
// cluster_admin is the Portal operator role and visitor is anonymous;
// neither may be granted or revoked via an external IdP/AD, and an empty
// role must never be applied by a sync.
assert.False(t, IsFederatedRole(RoleClusterAdmin))
assert.False(t, IsFederatedRole(RoleVisitor))
assert.False(t, IsFederatedRole(RoleNone))
})
}
func TestFederatedRoleUpdate(t *testing.T) {
t.Run("AppliesChangedFederatableRole", func(t *testing.T) {
role, ok := FederatedRoleUpdate(RoleUser, RoleViewer)
assert.True(t, ok)
assert.Equal(t, RoleViewer, role)
})
t.Run("UnchangedRoleNotApplied", func(t *testing.T) {
_, ok := FederatedRoleUpdate(RoleUser, RoleUser)
assert.False(t, ok)
})
t.Run("NeverDowngradesClusterAdmin", func(t *testing.T) {
// An existing cluster_admin/visitor account must not be touched.
_, ok := FederatedRoleUpdate(RoleClusterAdmin, RoleAdmin)
assert.False(t, ok)
_, ok = FederatedRoleUpdate(RoleVisitor, RoleGuest)
assert.False(t, ok)
})
t.Run("NeverEscalatesToNonFederatable", func(t *testing.T) {
// The directory must not promote to cluster_admin/visitor.
_, ok := FederatedRoleUpdate(RoleUser, RoleClusterAdmin)
assert.False(t, ok)
_, ok = FederatedRoleUpdate(RoleAdmin, RoleVisitor)
assert.False(t, ok)
_, ok = FederatedRoleUpdate(RoleUser, RoleNone)
assert.False(t, ok)
})
}

View file

@ -7,8 +7,8 @@ import (
"github.com/urfave/cli/v2"
)
func TestClientRoleFlagUsage_IncludesNoneAlias(t *testing.T) {
t.Run("AddCommandRoleFlagIncludesNone", func(t *testing.T) {
func TestClientRoleFlagUsage_ExcludesNoneAlias(t *testing.T) {
t.Run("AddCommandRoleFlagExcludesNone", func(t *testing.T) {
var roleFlag *cli.StringFlag
for _, f := range ClientsAddCommand.Flags {
if rf, ok := f.(*cli.StringFlag); ok && rf.Name == "role" {
@ -19,9 +19,12 @@ func TestClientRoleFlagUsage_IncludesNoneAlias(t *testing.T) {
if roleFlag == nil {
t.Fatal("role flag not found on ClientsAddCommand")
}
assert.Contains(t, roleFlag.Usage, "none")
// Real client roles are listed; the "none" alias is excluded.
assert.Contains(t, roleFlag.Usage, "client")
assert.Contains(t, roleFlag.Usage, "service")
assert.NotContains(t, roleFlag.Usage, "none")
})
t.Run("ModCommandRoleFlagIncludesNone", func(t *testing.T) {
t.Run("ModCommandRoleFlagExcludesNone", func(t *testing.T) {
var roleFlag *cli.StringFlag
for _, f := range ClientsModCommand.Flags {
if rf, ok := f.(*cli.StringFlag); ok && rf.Name == "role" {
@ -32,6 +35,8 @@ func TestClientRoleFlagUsage_IncludesNoneAlias(t *testing.T) {
if roleFlag == nil {
t.Fatal("role flag not found on ClientsModCommand")
}
assert.Contains(t, roleFlag.Usage, "none")
assert.Contains(t, roleFlag.Usage, "client")
assert.Contains(t, roleFlag.Usage, "service")
assert.NotContains(t, roleFlag.Usage, "none")
})
}

View file

@ -7,8 +7,8 @@ import (
"github.com/urfave/cli/v2"
)
func TestUserRoleFlagUsage_IncludesNoneAlias(t *testing.T) {
t.Run("AddCommandUserRoleFlagIncludesNone", func(t *testing.T) {
func TestUserRoleFlagUsage_ExcludesNoneAndVisitor(t *testing.T) {
t.Run("AddCommandUserRoleFlagExcludesNoneAndVisitor", func(t *testing.T) {
var roleFlag *cli.StringFlag
for _, f := range UsersAddCommand.Flags {
if rf, ok := f.(*cli.StringFlag); ok && rf.Name == "role" {
@ -19,9 +19,13 @@ func TestUserRoleFlagUsage_IncludesNoneAlias(t *testing.T) {
if roleFlag == nil {
t.Fatal("role flag not found on UsersAddCommand")
}
assert.Contains(t, roleFlag.Usage, "none")
// Offered roles are listed; the "none" alias and "visitor" are excluded.
assert.Contains(t, roleFlag.Usage, "admin")
assert.Contains(t, roleFlag.Usage, "guest")
assert.NotContains(t, roleFlag.Usage, "none")
assert.NotContains(t, roleFlag.Usage, "visitor")
})
t.Run("ModCommandUserRoleFlagIncludesNone", func(t *testing.T) {
t.Run("ModCommandUserRoleFlagExcludesNoneAndVisitor", func(t *testing.T) {
var roleFlag *cli.StringFlag
for _, f := range UsersModCommand.Flags {
if rf, ok := f.(*cli.StringFlag); ok && rf.Name == "role" {
@ -32,6 +36,9 @@ func TestUserRoleFlagUsage_IncludesNoneAlias(t *testing.T) {
if roleFlag == nil {
t.Fatal("role flag not found on UsersModCommand")
}
assert.Contains(t, roleFlag.Usage, "none")
assert.Contains(t, roleFlag.Usage, "admin")
assert.Contains(t, roleFlag.Usage, "guest")
assert.NotContains(t, roleFlag.Usage, "none")
assert.NotContains(t, roleFlag.Usage, "visitor")
})
}

View file

@ -777,6 +777,12 @@ func (m *User) AclRole() acl.Role {
switch {
case m.SuperAdmin:
// SuperAdmins map to the strongest operator role available. On Portal
// builds that is cluster_admin (registered in acl.UserRoles); CE/Pro
// leave it unregistered, so super admins resolve to RoleAdmin there.
if r, ok := acl.UserRoles[acl.RoleClusterAdmin.String()]; ok {
return r
}
return acl.RoleAdmin
case role == "":
return acl.RoleNone
@ -878,13 +884,23 @@ func (m *User) Equal(u *User) bool {
return m.UserUID == u.UserUID
}
// IsAdmin checks if the user is an admin with username.
// IsAdmin checks if the user is an admin with username. The Portal-only
// cluster_admin operator role counts as an admin so account-management logic
// (e.g. SaveForm role changes) treats it like a regular admin.
func (m *User) IsAdmin() bool {
if m == nil {
return false
}
return m.IsSuperAdmin() || m.IsRegistered() && m.AclRole() == acl.RoleAdmin
if !m.IsSuperAdmin() && !m.IsRegistered() {
return false
}
if m.IsSuperAdmin() {
return true
}
return acl.IsAdminRole(m.AclRole())
}
// IsSuperAdmin checks if the user is a super admin.
@ -1328,7 +1344,9 @@ func (m *User) PrivilegeLevelChange(frm form.User) bool {
func (m *User) SaveForm(frm form.User, u *User) error {
if m.UserName == "" || m.ID <= 0 {
return fmt.Errorf("system users cannot be modified")
} else if (m.ID == 1 || frm.SuperAdmin) && acl.RoleAdmin.NotEqual(frm.Role()) {
} else if (m.ID == 1 || frm.SuperAdmin) && !acl.IsAdminRole(acl.Role(frm.Role())) {
// Super admins must keep an admin-level role. cluster_admin is the
// Portal operator role and counts as admin-level (it is unused on CE/Pro).
return fmt.Errorf("super admin must not have a non-admin role")
} else if frm.BasePath != "" && clean.UserPath(frm.BasePath) == "" {
return fmt.Errorf("invalid base folder")
@ -1372,9 +1390,11 @@ func (m *User) SaveForm(frm form.User, u *User) error {
m.DeletedAt = nil
}
// Prevent admins from locking themselves out.
// Prevent admins from locking themselves out: keep their current role
// and keep login enabled on a self-save. Forcing RoleAdmin here would
// silently demote a Portal cluster_admin; an explicit self role change
// is already rejected by the update handler.
if u.Equal(m) {
m.SetRole(acl.RoleAdmin.String())
m.CanLogin = true
} else {
m.SetRole(frm.Role())
@ -1402,16 +1422,22 @@ func (m *User) SaveForm(frm form.User, u *User) error {
m.SetUploadPath(frm.UploadPath)
}
// Ensure super admins never have a non-admin role.
if m.SuperAdmin {
m.SetRole(acl.RoleAdmin.String())
// Ensure super admins keep an admin-level role (admin, or cluster_admin on
// the Portal). Anything else is normalized to the configured default admin
// role, derived from Admin.UserRole (admin on CE/Pro, cluster_admin on the
// Portal).
if m.SuperAdmin && !acl.IsAdminRole(acl.Role(m.UserRole)) {
m.SetRole(Admin.UserRole)
}
// Make sure that the initial admin user cannot lock itself out.
if m.ID == Admin.ID && (m.AclRole() != acl.RoleAdmin || !m.SuperAdmin || !m.CanLogin) {
m.SetRole(acl.RoleAdmin.String())
// Make sure the initial admin user cannot lock itself out: it stays a super
// admin that can log in with an admin-level role.
if m.ID == Admin.ID {
m.SuperAdmin = true
m.CanLogin = true
if !acl.IsAdminRole(acl.Role(m.UserRole)) {
m.SetRole(Admin.UserRole)
}
}
return m.Save()