From 4fca10795e5bc52981e3be63d5653d2a00d2f182 Mon Sep 17 00:00:00 2001 From: Nathan Coad Date: Tue, 21 Apr 2026 14:24:16 +1000 Subject: [PATCH] add user/group DNs to config --- README.md | 5 +++++ internal/auth/ldap.go | 25 +++++++++++++++++++++-- internal/settings/settings.go | 10 +++++++++ internal/settings/settings_strict_test.go | 6 ++++++ server/handler/auth.go | 4 ++++ 5 files changed, 48 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e02bda4..f5405a5 100644 --- a/README.md +++ b/README.md @@ -353,6 +353,9 @@ settings: auth_mode: required ldap_bind_address: ldaps://ad01.example.com:636 ldap_base_dn: DC=example,DC=com + # Optional performance scopes; default to ldap_base_dn when omitted. + ldap_user_base_dn: OU=Users,DC=example,DC=com + ldap_group_base_dn: OU=Groups,DC=example,DC=com auth_group_role_mappings: "CN=vctp-viewers,OU=Groups,DC=example,DC=com": viewer "CN=vctp-admins,OU=Groups,DC=example,DC=com": admin @@ -511,6 +514,8 @@ Authentication: - `settings.ldap_groups` empty/omitted means no allowlist filter, but mapped-role requirement still applies. - `settings.ldap_bind_address`: LDAP/LDAPS URL used for authentication. - `settings.ldap_base_dn`: LDAP base DN for user/group lookups. +- `settings.ldap_user_base_dn`: optional user lookup base DN; defaults to `settings.ldap_base_dn`. +- `settings.ldap_group_base_dn`: optional group lookup base DN; defaults to `settings.ldap_base_dn`. - `settings.ldap_trust_cert_file`: optional CA cert file for LDAP TLS. - `settings.ldap_disable_validation`: disables LDAP TLS cert validation. - `settings.ldap_insecure`: insecure LDAP TLS mode. diff --git a/internal/auth/ldap.go b/internal/auth/ldap.go index 89f43d7..864221a 100644 --- a/internal/auth/ldap.go +++ b/internal/auth/ldap.go @@ -25,6 +25,8 @@ var ( type LDAPConfig struct { BindAddress string BaseDN string + UserBaseDN string + GroupBaseDN string TrustCertFile string DisableValidation bool Insecure bool @@ -45,6 +47,8 @@ type LDAPIdentity struct { type LDAPAuthenticator struct { bindAddress string baseDN string + userBaseDN string + groupBaseDN string trustCertFile string disableValidation bool insecure bool @@ -54,6 +58,8 @@ type LDAPAuthenticator struct { func NewLDAPAuthenticator(cfg LDAPConfig) (*LDAPAuthenticator, error) { bindAddress := strings.TrimSpace(cfg.BindAddress) baseDN := strings.TrimSpace(cfg.BaseDN) + userBaseDN := strings.TrimSpace(cfg.UserBaseDN) + groupBaseDN := strings.TrimSpace(cfg.GroupBaseDN) trustCertFile := strings.TrimSpace(cfg.TrustCertFile) if bindAddress == "" { @@ -62,6 +68,12 @@ func NewLDAPAuthenticator(cfg LDAPConfig) (*LDAPAuthenticator, error) { if baseDN == "" { return nil, fmt.Errorf("%w: base DN is required", ErrInvalidLDAPConfig) } + if userBaseDN == "" { + userBaseDN = baseDN + } + if groupBaseDN == "" { + groupBaseDN = baseDN + } if _, err := url.ParseRequestURI(bindAddress); err != nil { return nil, fmt.Errorf("%w: bind address must be a valid URL: %v", ErrInvalidLDAPConfig, err) } @@ -74,6 +86,8 @@ func NewLDAPAuthenticator(cfg LDAPConfig) (*LDAPAuthenticator, error) { return &LDAPAuthenticator{ bindAddress: bindAddress, baseDN: baseDN, + userBaseDN: userBaseDN, + groupBaseDN: groupBaseDN, trustCertFile: trustCertFile, disableValidation: cfg.DisableValidation, insecure: cfg.Insecure, @@ -119,6 +133,10 @@ func (a *LDAPAuthenticator) AuthenticateAndFetchGroups(ctx context.Context, user if rewrittenToUPN { identity.Diagnostics = append(identity.Diagnostics, "bind_username_rewritten_to_upn") } + identity.Diagnostics = append(identity.Diagnostics, + "user_lookup_base_dn="+a.userBaseDN, + "group_lookup_base_dn="+a.groupBaseDN, + ) if whoami, err := conn.WhoAmI(nil); err != nil { identity.Diagnostics = append(identity.Diagnostics, fmt.Sprintf("whoami_failed:%v", err)) } else if boundDN := parseWhoAmIDN(whoami.AuthzID); boundDN != "" { @@ -131,7 +149,7 @@ func (a *LDAPAuthenticator) AuthenticateAndFetchGroups(ctx context.Context, user } userLookupStartedAt := time.Now() - entry, lookupStrategy, err := a.lookupUserEntry(conn, bindUsername, identity.UserDN) + entry, lookupStrategy, err := a.lookupUserEntry(conn, inputUsername, identity.UserDN) identity.UserLookupDuration = time.Since(userLookupStartedAt) identity.Diagnostics = append(identity.Diagnostics, fmt.Sprintf("user_lookup_duration_ms=%d", identity.UserLookupDuration.Milliseconds())) if err != nil { @@ -329,6 +347,9 @@ func (a *LDAPAuthenticator) lookupUserEntry(conn *ldap.Conn, username string, us if entry != nil { return entry, "principal_upn", nil } + // For UPN principals, avoid fallback attribute probes that are unlikely to match + // and can be expensive on large directory trees. + continue } entry, err := a.searchUserByAttribute(conn, "sAMAccountName", principal) @@ -359,7 +380,7 @@ func (a *LDAPAuthenticator) searchUserByAttribute(conn *ldap.Conn, attribute str } searchRes, err := conn.Search(ldap.NewSearchRequest( - a.baseDN, + a.userBaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 2, diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 574a12d..2ac1c21 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -79,6 +79,8 @@ type SettingsYML struct { LDAPGroups []string `yaml:"ldap_groups"` LDAPBindAddress string `yaml:"ldap_bind_address"` LDAPBaseDN string `yaml:"ldap_base_dn"` + LDAPUserBaseDN string `yaml:"ldap_user_base_dn"` + LDAPGroupBaseDN string `yaml:"ldap_group_base_dn"` LDAPTrustCertFile string `yaml:"ldap_trust_cert_file"` LDAPDisableValidation bool `yaml:"ldap_disable_validation"` LDAPInsecure bool `yaml:"ldap_insecure"` @@ -284,6 +286,8 @@ func applyDefaultsAndValidateSettings(cfg *SettingsYML) error { s.AuthJWTSigningKey = strings.TrimSpace(s.AuthJWTSigningKey) s.LDAPBindAddress = strings.TrimSpace(s.LDAPBindAddress) s.LDAPBaseDN = strings.TrimSpace(s.LDAPBaseDN) + s.LDAPUserBaseDN = strings.TrimSpace(s.LDAPUserBaseDN) + s.LDAPGroupBaseDN = strings.TrimSpace(s.LDAPGroupBaseDN) s.LDAPTrustCertFile = strings.TrimSpace(s.LDAPTrustCertFile) s.LDAPGroups = compactTrimmedStrings(s.LDAPGroups) @@ -340,6 +344,12 @@ func applyDefaultsAndValidateSettings(cfg *SettingsYML) error { if s.LDAPBaseDN == "" { return errors.New("settings.ldap_base_dn is required when settings.auth_enabled=true") } + if s.LDAPUserBaseDN == "" { + s.LDAPUserBaseDN = s.LDAPBaseDN + } + if s.LDAPGroupBaseDN == "" { + s.LDAPGroupBaseDN = s.LDAPBaseDN + } if len(s.AuthGroupRoleMappings) == 0 { return errors.New("settings.auth_group_role_mappings must define at least one mapping when settings.auth_enabled=true") } diff --git a/internal/settings/settings_strict_test.go b/internal/settings/settings_strict_test.go index 4b8ed22..adeac39 100644 --- a/internal/settings/settings_strict_test.go +++ b/internal/settings/settings_strict_test.go @@ -193,6 +193,12 @@ func TestReadYMLSettingsAcceptsValidAuthConfigAndNormalizesMappings(t *testing.T if len(got.LDAPGroups) != 1 || got.LDAPGroups[0] != "cn=vctp-viewers,ou=groups,dc=example,dc=com" { t.Fatalf("expected ldap_groups to be compacted+trimmed, got %#v", got.LDAPGroups) } + if got.LDAPUserBaseDN != "dc=example,dc=com" { + t.Fatalf("expected default ldap_user_base_dn to fall back to ldap_base_dn, got %q", got.LDAPUserBaseDN) + } + if got.LDAPGroupBaseDN != "dc=example,dc=com" { + t.Fatalf("expected default ldap_group_base_dn to fall back to ldap_base_dn, got %q", got.LDAPGroupBaseDN) + } if got.AuthGroupRoleMappings["cn=vctp-admins,ou=groups,dc=example,dc=com"] != authRoleAdmin { t.Fatalf("expected admin mapping to normalize role to %q, got %#v", authRoleAdmin, got.AuthGroupRoleMappings) } diff --git a/server/handler/auth.go b/server/handler/auth.go index d718e5f..2dd6e87 100644 --- a/server/handler/auth.go +++ b/server/handler/auth.go @@ -85,6 +85,8 @@ func (h *Handler) AuthLogin(w http.ResponseWriter, r *http.Request) { "username", username, "ldap_bind_address", cfg.LDAPBindAddress, "ldap_base_dn", cfg.LDAPBaseDN, + "ldap_user_base_dn", cfg.LDAPUserBaseDN, + "ldap_group_base_dn", cfg.LDAPGroupBaseDN, "ldap_group_requirements", limitStrings(cfg.LDAPGroups, maxDebugLogListItems), "auth_group_role_mapping_keys", limitStrings(sortedStringMapKeys(cfg.AuthGroupRoleMappings), maxDebugLogListItems), "ldap_insecure", cfg.LDAPInsecure, @@ -95,6 +97,8 @@ func (h *Handler) AuthLogin(w http.ResponseWriter, r *http.Request) { ldapAuth, err := newLDAPAuthenticator(auth.LDAPConfig{ BindAddress: cfg.LDAPBindAddress, BaseDN: cfg.LDAPBaseDN, + UserBaseDN: cfg.LDAPUserBaseDN, + GroupBaseDN: cfg.LDAPGroupBaseDN, TrustCertFile: cfg.LDAPTrustCertFile, DisableValidation: cfg.LDAPDisableValidation, Insecure: cfg.LDAPInsecure,