feat(auth): assign elevated LDAP roles via configurable filters

The LDAP sync only ever granted the base "user" role. Add an optional
auth.ldap.role-filters map (role -> LDAP filter) so accounts matching a
filter are granted that elevated role (admin/support/api/manager).

LDAP is authoritative for the configured roles: sync both adds and removes
them to match group membership, while roles not listed (e.g. a manually
granted manager) are preserved. A managed manager that still has assigned
projects is never stripped. With no role-filters configured behaviour is
identical to before.

Roles are reconciled during periodic sync and at login. Sync evaluates
each filter once over the whole base (one search per role, not per user)
and reconciles existing users via a single ListUsers lookup plus the new
UserRepository.UpdateRoles helper.

Closes #74

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: e38526c3259d
This commit is contained in:
2026-06-19 06:36:55 +02:00
co-authored by Claude Opus 4.8
parent 1bd3f25371
commit 63a82d022b
4 changed files with 343 additions and 3 deletions
+218 -3
View File
@@ -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,
+106
View File
@@ -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)
}
})
}
}
+7
View File
@@ -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"]
+12
View File
@@ -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 {