diff --git a/internal/auth/ldap.go b/internal/auth/ldap.go index c8d2b6a3..5f3bcb0a 100644 --- a/internal/auth/ldap.go +++ b/internal/auth/ldap.go @@ -9,6 +9,8 @@ import ( "fmt" "net" "net/http" + "slices" + "sort" "strings" "time" @@ -36,12 +38,25 @@ type LdapConfig struct { // Password for the LDAP admin account used for syncing (optional). // Overridden by the LDAP_ADMIN_PASSWORD environment variable when set. SyncPassword string `json:"sync-password"` + + // Maps an elevated role (admin/support/api/manager) to an LDAP filter. + // An account matching the filter is granted that role; LDAP is authoritative + // for every role listed here (it is both added and removed to match group + // membership). Roles not listed here are never touched. Empty/absent means + // roles are never modified by LDAP. + RoleFilters map[string]string `json:"role-filters"` } type LdapAuthenticator struct { syncPassword string UserAttr string UIDAttr string + + // roleFilters holds the validated subset of LdapConfig.RoleFilters. + roleFilters map[string]string + // managedRoles is the sorted list of roles LDAP is authoritative for + // (the keys of roleFilters). Empty when no role filters are configured. + managedRoles []string } var _ Authenticator = (*LdapAuthenticator)(nil) @@ -64,6 +79,26 @@ func (la *LdapAuthenticator) Init() error { la.UIDAttr = "uid" } + // Validate the optional role filters. Invalid keys are dropped with a + // warning rather than failing startup. The baseline "user" role cannot be + // LDAP-managed (it is always granted), and an empty filter is meaningless. + la.roleFilters = make(map[string]string) + for role, filter := range Keys.LdapConfig.RoleFilters { + role = strings.ToLower(role) + if !schema.IsValidRole(role) || role == schema.GetRoleString(schema.RoleUser) || + role == schema.GetRoleString(schema.RoleAnonymous) { + cclog.Warnf("LDAP: ignoring role-filter for invalid or non-assignable role '%s'", role) + continue + } + if strings.TrimSpace(filter) == "" { + cclog.Warnf("LDAP: ignoring empty role-filter for role '%s'", role) + continue + } + la.roleFilters[role] = filter + la.managedRoles = append(la.managedRoles, role) + } + sort.Strings(la.managedRoles) + return nil } @@ -77,6 +112,25 @@ func (la *LdapAuthenticator) CanLogin( if user != nil { if user.AuthSource == schema.AuthViaLDAP { + // Refresh elevated roles from LDAP when role filters are configured + // and role updates on login are enabled. Without role filters this + // stays a fast path with no extra LDAP query. + if len(la.managedRoles) > 0 && lc.UpdateUserOnLogin { + if l, err := la.getLdapConnection(true); err != nil { + cclog.Warnf("LDAP: skipping role refresh for user '%s': connection error", user.Username) + } else { + defer l.Close() + if matched, err := la.matchRoles(l, user.Username); err == nil { + roles := mergeLdapRoles(user.Roles, la.managedRoles, matched, user.Projects) + current := append([]string{}, user.Roles...) + sort.Strings(current) + if !slices.Equal(roles, current) { + user.Roles = roles + handleLdapUser(user) + } + } + } + } return user, true } } else if lc.SyncUserOnLogin { @@ -106,10 +160,18 @@ func (la *LdapAuthenticator) CanLogin( } entry := sr.Entries[0] + + roles := []string{schema.GetRoleString(schema.RoleUser)} + if len(la.managedRoles) > 0 { + if matched, err := la.matchRoles(l, username); err == nil { + roles = mergeLdapRoles(nil, la.managedRoles, matched, nil) + } + } + user = &schema.User{ Username: username, Name: entry.GetAttributeValue(la.UserAttr), - Roles: []string{schema.GetRoleString(schema.RoleUser)}, + Roles: roles, Projects: make([]string, 0), AuthType: schema.AuthSession, AuthSource: schema.AuthViaLDAP, @@ -194,6 +256,28 @@ func (la *LdapAuthenticator) Sync() error { } } + // Evaluate configured role filters once over the whole base. Empty when no + // role filters are configured, in which case role handling is a no-op and + // behaviour is identical to before. + matched, err := la.matchRolesBulk(l) + if err != nil { + return err + } + + // Current roles/projects of users that already hold a non-default role, so + // existing users can be reconciled without a per-user lookup. + currentRoles := map[string]*schema.User{} + if len(la.managedRoles) > 0 { + specials, err := ur.ListUsers(true) + if err != nil { + return err + } + for _, u := range specials { + currentRoles[u.Username] = u + } + } + + userRole := schema.GetRoleString(schema.RoleUser) for username, where := range users { if where == InDB && lc.SyncDelOldUsers { if err := ur.DelUser(username); err != nil { @@ -204,25 +288,156 @@ func (la *LdapAuthenticator) Sync() error { } else if where == InLdap { name := newnames[username] + roles := []string{userRole} + if len(la.managedRoles) > 0 { + roles = mergeLdapRoles(nil, la.managedRoles, matched[username], nil) + } + user := &schema.User{ Username: username, Name: name, - Roles: []string{schema.GetRoleString(schema.RoleUser)}, + Roles: roles, Projects: make([]string, 0), AuthSource: schema.AuthViaLDAP, } - cclog.Debugf("sync: add %v (name: %v, roles: [user], ldap: true)", username, name) + cclog.Debugf("sync: add %v (name: %v, roles: %v, ldap: true)", username, name, roles) if err := ur.AddUserIfNotExists(user); err != nil { cclog.Errorf("User '%s' LDAP: Insert into DB failed", username) return err } + } else if where == InBoth && len(la.managedRoles) > 0 { + // Reconcile elevated roles for existing users: LDAP is authoritative + // for the managed roles, all other roles are preserved. + cur := []string{userRole} + var projects []string + if u, ok := currentRoles[username]; ok { + cur = u.Roles + projects = u.Projects + } + + roles := mergeLdapRoles(cur, la.managedRoles, matched[username], projects) + sortedCur := append([]string{}, cur...) + sort.Strings(sortedCur) + if !slices.Equal(roles, sortedCur) { + cclog.Debugf("sync: update %v roles %v -> %v", username, cur, roles) + if err := ur.UpdateRoles(username, roles); err != nil { + cclog.Errorf("User '%s' LDAP: role update failed: %v", username, err) + return err + } + } } } return nil } +// mergeLdapRoles computes the role set for an LDAP user. Every current role that +// LDAP does not manage is preserved, the baseline "user" role is always present, +// and the matched managed roles are added. managed is the set of roles LDAP is +// authoritative for; matched is the subset of those the account currently +// qualifies for. projects is used to guard manager removal: a manager that still +// has assigned projects keeps the role even if it is no longer matched (mirrors +// UserRepository.RemoveRole). The result is deduplicated and sorted. +func mergeLdapRoles(current, managed, matched, projects []string) []string { + managedSet := make(map[string]bool, len(managed)) + for _, r := range managed { + managedSet[r] = true + } + matchedSet := make(map[string]bool, len(matched)) + for _, r := range matched { + matchedSet[r] = true + } + + result := map[string]bool{schema.GetRoleString(schema.RoleUser): true} + + // Preserve roles LDAP does not manage (e.g. a manually granted manager). + for _, r := range current { + if !managedSet[r] { + result[r] = true + } + } + + // Add managed roles the account currently qualifies for. + for _, r := range managed { + if matchedSet[r] { + result[r] = true + } + } + + // Guard: do not strip a manager that still has assigned projects. + managerRole := schema.GetRoleString(schema.RoleManager) + if managedSet[managerRole] && !matchedSet[managerRole] && len(projects) > 0 && + slices.Contains(current, managerRole) { + cclog.Warnf("LDAP: keeping role 'manager' despite no filter match: user still has assigned project(s): %v", projects) + result[managerRole] = true + } + + roles := make([]string, 0, len(result)) + for r := range result { + roles = append(roles, r) + } + sort.Strings(roles) + return roles +} + +// matchRoles evaluates all configured role filters for a single user and returns +// the managed roles the account qualifies for. Used on the login path where only +// one user is inspected. +func (la *LdapAuthenticator) matchRoles(l *ldap.Conn, username string) ([]string, error) { + if len(la.managedRoles) == 0 { + return nil, nil + } + + matched := make([]string, 0, len(la.managedRoles)) + for _, role := range la.managedRoles { + filter := fmt.Sprintf("(&(%s=%s)%s)", la.UIDAttr, ldap.EscapeFilter(username), la.roleFilters[role]) + sr, err := l.Search(ldap.NewSearchRequest( + Keys.LdapConfig.UserBase, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + filter, + []string{la.UIDAttr}, nil)) + if err != nil { + cclog.Warnf("LDAP: role filter search for role '%s' failed: %v", role, err) + return nil, err + } + if len(sr.Entries) > 0 { + matched = append(matched, role) + } + } + return matched, nil +} + +// matchRolesBulk evaluates every configured role filter once over the whole user +// base and returns a username -> matched managed roles mapping. Used by Sync, +// it costs one LDAP search per configured role rather than one per user. +func (la *LdapAuthenticator) matchRolesBulk(l *ldap.Conn) (map[string][]string, error) { + matched := map[string][]string{} + if len(la.managedRoles) == 0 { + return matched, nil + } + + lc := Keys.LdapConfig + for _, role := range la.managedRoles { + filter := fmt.Sprintf("(&%s%s)", lc.UserFilter, la.roleFilters[role]) + sr, err := l.Search(ldap.NewSearchRequest( + lc.UserBase, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + filter, + []string{la.UIDAttr}, nil)) + if err != nil { + cclog.Warnf("LDAP: role filter search for role '%s' failed: %v", role, err) + return nil, err + } + for _, entry := range sr.Entries { + if username := entry.GetAttributeValue(la.UIDAttr); username != "" { + matched[username] = append(matched[username], role) + } + } + } + return matched, nil +} + func (la *LdapAuthenticator) getLdapConnection(admin bool) (*ldap.Conn, error) { lc := Keys.LdapConfig conn, err := ldap.DialURL(lc.URL, diff --git a/internal/auth/ldap_test.go b/internal/auth/ldap_test.go new file mode 100644 index 00000000..c484506e --- /dev/null +++ b/internal/auth/ldap_test.go @@ -0,0 +1,106 @@ +// Copyright (C) NHR@FAU, University Erlangen-Nuremberg. +// All rights reserved. This file is part of cc-backend. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package auth + +import ( + "reflect" + "testing" + + "github.com/ClusterCockpit/cc-lib/v2/schema" +) + +func TestMergeLdapRoles(t *testing.T) { + var ( + user = schema.GetRoleString(schema.RoleUser) + admin = schema.GetRoleString(schema.RoleAdmin) + support = schema.GetRoleString(schema.RoleSupport) + api = schema.GetRoleString(schema.RoleAPI) + manager = schema.GetRoleString(schema.RoleManager) + ) + + tests := []struct { + name string + current []string + managed []string + matched []string + projects []string + want []string + }{ + { + name: "no managed roles is a no-op keeping user baseline", + current: []string{user}, + managed: nil, + matched: nil, + want: []string{user}, + }, + { + name: "add matched elevated role to plain user", + current: []string{user}, + managed: []string{admin, support}, + matched: []string{admin}, + want: []string{admin, user}, + }, + { + name: "remove managed role when no longer matched", + current: []string{admin, user}, + managed: []string{admin}, + matched: nil, + want: []string{user}, + }, + { + name: "preserve non-managed roles (manager, api)", + current: []string{api, manager, user}, + managed: []string{admin, support}, + matched: []string{support}, + want: []string{api, manager, support, user}, + }, + { + name: "managed manager with projects is not removed", + current: []string{manager, user}, + managed: []string{manager}, + matched: nil, + projects: []string{"projA"}, + want: []string{manager, user}, + }, + { + name: "managed manager without projects is removed", + current: []string{manager, user}, + managed: []string{manager}, + matched: nil, + want: []string{user}, + }, + { + name: "user baseline always present even if absent in current", + current: []string{admin}, + managed: []string{admin}, + matched: []string{admin}, + want: []string{admin, user}, + }, + { + name: "new user (nil current) gets matched roles plus baseline", + current: nil, + managed: []string{admin, support, api}, + matched: []string{admin, api}, + want: []string{admin, api, user}, + }, + { + name: "result is deduplicated", + current: []string{admin, admin, user}, + managed: []string{admin}, + matched: []string{admin}, + want: []string{admin, user}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mergeLdapRoles(tt.current, tt.managed, tt.matched, tt.projects) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("mergeLdapRoles() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/auth/schema.go b/internal/auth/schema.go index 269cef45..310afa8a 100644 --- a/internal/auth/schema.go +++ b/internal/auth/schema.go @@ -131,6 +131,13 @@ var configSchema = ` "sync-password": { "description": "Password for the LDAP admin account used for syncing. Overridden by the LDAP_ADMIN_PASSWORD environment variable when set.", "type": "string" + }, + "role-filters": { + "description": "Maps an elevated role to an LDAP filter; accounts matching the filter are granted that role. LDAP is authoritative for every role listed here (roles are added and removed to match group membership), while roles not listed are preserved. Applied during sync and at login. Valid keys: admin, support, api, manager.", + "type": "object", + "additionalProperties": { + "type": "string" + } } }, "required": ["url", "user-base", "search-dn", "user-bind", "user-filter"] diff --git a/internal/repository/user.go b/internal/repository/user.go index a341e5be..46c126b1 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -298,6 +298,18 @@ func (r *UserRepository) UpdateUser(dbUser *schema.User, user *schema.User) erro return nil } +// UpdateRoles overwrites a user's role list with the provided roles. +// Used by the LDAP sync to reconcile elevated roles; callers are responsible for +// computing the full role set (the value replaces the existing one verbatim). +func (r *UserRepository) UpdateRoles(username string, roles []string) error { + rolesJSON, _ := json.Marshal(roles) + if _, err := sq.Update("hpc_user").Set("roles", rolesJSON).Where("hpc_user.username = ?", username).RunWith(r.DB).Exec(); err != nil { + cclog.Errorf("error while updating roles of user '%s'", username) + return err + } + return nil +} + func (r *UserRepository) DelUser(username string) error { _, err := r.DB.Exec(`DELETE FROM hpc_user WHERE hpc_user.username = ?`, username) if err != nil {