package auth import ( "context" "crypto/tls" "fmt" ldapv3 "github.com/go-ldap/ldap/v3" ) // LDAPConfig holds settings for the LDAP backend. type LDAPConfig struct { URL string BaseDN string UserSearchDN string BindDN string BindPassword string UserAttr string EmailAttr string DisplayAttr string GroupAttr string RoleMapping map[string][]string // role -> list of group DNs TLSSkipVerify bool } // LDAPBackend authenticates via LDAP simple bind against FreeIPA. type LDAPBackend struct { cfg LDAPConfig } // NewLDAPBackend creates an LDAP authentication backend. func NewLDAPBackend(cfg LDAPConfig) *LDAPBackend { return &LDAPBackend{cfg: cfg} } // Name returns "ldap". func (b *LDAPBackend) Name() string { return "ldap" } // Authenticate verifies credentials against the LDAP server. func (b *LDAPBackend) Authenticate(_ context.Context, username, password string) (*User, error) { if username == "" || password == "" { return nil, fmt.Errorf("username and password required") } conn, err := ldapv3.DialURL(b.cfg.URL, ldapv3.DialWithTLSConfig(&tls.Config{ InsecureSkipVerify: b.cfg.TLSSkipVerify, })) if err != nil { return nil, fmt.Errorf("ldap dial: %w", err) } defer conn.Close() // Build user DN and bind with user credentials userDN := fmt.Sprintf("%s=%s,%s", b.cfg.UserAttr, ldapv3.EscapeFilter(username), b.cfg.UserSearchDN) if err := conn.Bind(userDN, password); err != nil { return nil, fmt.Errorf("ldap bind failed: %w", err) } // Search for user attributes searchReq := ldapv3.NewSearchRequest( userDN, ldapv3.ScopeBaseObject, ldapv3.NeverDerefAliases, 1, 10, false, "(objectClass=*)", []string{b.cfg.EmailAttr, b.cfg.DisplayAttr, b.cfg.GroupAttr}, nil, ) sr, err := conn.Search(searchReq) if err != nil || len(sr.Entries) == 0 { return nil, fmt.Errorf("ldap user search failed: %w", err) } entry := sr.Entries[0] email := entry.GetAttributeValue(b.cfg.EmailAttr) displayName := entry.GetAttributeValue(b.cfg.DisplayAttr) if displayName == "" { displayName = username } groups := entry.GetAttributeValues(b.cfg.GroupAttr) role := b.resolveRole(groups) return &User{ Username: username, DisplayName: displayName, Email: email, Role: role, AuthSource: "ldap", }, nil } // resolveRole maps LDAP group memberships to a Silo role. // Checks in priority order: admin > editor > viewer. func (b *LDAPBackend) resolveRole(groups []string) string { groupSet := make(map[string]struct{}, len(groups)) for _, g := range groups { groupSet[g] = struct{}{} } for _, role := range []string{RoleAdmin, RoleEditor, RoleViewer} { for _, requiredGroup := range b.cfg.RoleMapping[role] { if _, ok := groupSet[requiredGroup]; ok { return role } } } return RoleViewer }