You are viewing a plain text version of this content. The canonical link for it is here.
Posted to issues@yunikorn.apache.org by pb...@apache.org on 2025/10/30 14:57:15 UTC

(yunikorn-core) branch master updated: [YUNIKORN-656] Add LDAP resolver for group resolution (#1021)

This is an automated email from the ASF dual-hosted git repository.

pbacsko pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/yunikorn-core.git


The following commit(s) were added to refs/heads/master by this push:
     new 22d82c19 [YUNIKORN-656] Add LDAP resolver for group resolution (#1021)
22d82c19 is described below

commit 22d82c1966d449529b63a161e22ed90b3803cefc
Author: Mit Desai <md...@visa.com>
AuthorDate: Thu Oct 30 15:56:52 2025 +0100

    [YUNIKORN-656] Add LDAP resolver for group resolution (#1021)
    
    Closes: #1021
    
    Signed-off-by: Peter Bacsko <ba...@gmail.com>
---
 go.mod                                             |   4 +
 go.sum                                             |  24 +
 pkg/common/configs/config.go                       |  18 +-
 pkg/common/configs/config_test.go                  |  50 ++
 pkg/common/constants.go                            |  28 +
 pkg/common/security/ldap_validator.go              | 381 ++++++++
 pkg/common/security/ldap_validator_test.go         | 993 +++++++++++++++++++++
 pkg/common/security/usergroup.go                   |  22 +-
 pkg/common/security/usergroup_ldap_resolver.go     | 383 ++++++++
 .../security/usergroup_ldap_resolver_test.go       | 708 +++++++++++++++
 pkg/common/security/usergroup_no_resolver_test.go  | 100 +++
 .../usergroup_os_resolver_test.go}                 |  38 +-
 pkg/common/security/usergroup_test.go              | 745 ++++++++++------
 pkg/common/security/usergroup_test_resolver.go     |   3 +
 pkg/scheduler/partition.go                         |   2 +-
 15 files changed, 3197 insertions(+), 302 deletions(-)

diff --git a/go.mod b/go.mod
index 8a8b537a..a6036c7a 100644
--- a/go.mod
+++ b/go.mod
@@ -25,6 +25,7 @@ toolchain go1.23.7
 
 require (
 	github.com/apache/yunikorn-scheduler-interface v0.0.0-20251021140208-d3b357b98dcd
+	github.com/go-ldap/ldap/v3 v3.4.11
 	github.com/google/btree v1.1.3
 	github.com/google/go-cmp v0.7.0
 	github.com/google/uuid v1.6.0
@@ -44,13 +45,16 @@ require (
 )
 
 require (
+	github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
 	github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
 	github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe // indirect
 	github.com/prometheus/procfs v0.12.0 // indirect
 	go.uber.org/multierr v1.10.0 // indirect
+	golang.org/x/crypto v0.41.0 // indirect
 	golang.org/x/sys v0.35.0 // indirect
 	golang.org/x/text v0.28.0 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect
diff --git a/go.sum b/go.sum
index d144cf81..03f92b72 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,7 @@
+github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
+github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
+github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
+github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
 github.com/apache/yunikorn-scheduler-interface v0.0.0-20251021140208-d3b357b98dcd h1:7HA8EmjMbw81fQpRDRtLAt2i96PKG080ure1V8Bl7K4=
 github.com/apache/yunikorn-scheduler-interface v0.0.0-20251021140208-d3b357b98dcd/go.mod h1:fQPKbRdD2fYEjjJG9Gjop95NG2/DoJb939XXSxiuu10=
 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -6,6 +10,10 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
+github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
+github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU=
+github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM=
 github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
 github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -18,6 +26,20 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
+github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
+github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
+github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
+github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
+github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
+github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
+github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
+github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
+github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
+github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
+github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
+github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
 github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
 github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -66,6 +88,8 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
 go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
 go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
+golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
 golang.org/x/exp v0.0.0-20250228200357-dead58393ab7 h1:aWwlzYV971S4BXRS9AmqwDLAD85ouC6X+pocatKY58c=
 golang.org/x/exp v0.0.0-20250228200357-dead58393ab7/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk=
 golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
diff --git a/pkg/common/configs/config.go b/pkg/common/configs/config.go
index ce91d9cc..32a4ef2d 100644
--- a/pkg/common/configs/config.go
+++ b/pkg/common/configs/config.go
@@ -45,13 +45,19 @@ type SchedulerConfig struct {
 // - a list of placement rule definition objects
 // - a list of users specifying limits on the partition
 // - the preemption configuration for the partition
+// - user group resolver type (os, ldap, "")
 type PartitionConfig struct {
-	Name           string
-	Queues         []QueueConfig
-	PlacementRules []PlacementRule           `yaml:",omitempty" json:",omitempty"`
-	Limits         []Limit                   `yaml:",omitempty" json:",omitempty"`
-	Preemption     PartitionPreemptionConfig `yaml:",omitempty" json:",omitempty"`
-	NodeSortPolicy NodeSortingPolicy         `yaml:",omitempty" json:",omitempty"`
+	Name              string
+	Queues            []QueueConfig
+	PlacementRules    []PlacementRule           `yaml:",omitempty" json:",omitempty"`
+	Limits            []Limit                   `yaml:",omitempty" json:",omitempty"`
+	Preemption        PartitionPreemptionConfig `yaml:",omitempty" json:",omitempty"`
+	NodeSortPolicy    NodeSortingPolicy         `yaml:",omitempty" json:",omitempty"`
+	UserGroupResolver UserGroupResolver         `yaml:",omitempty" json:",omitempty"`
+}
+
+type UserGroupResolver struct {
+	Type string `yaml:"type,omitempty" json:"type,omitempty"`
 }
 
 // The partition preemption configuration
diff --git a/pkg/common/configs/config_test.go b/pkg/common/configs/config_test.go
index f0710b59..53f737c0 100644
--- a/pkg/common/configs/config_test.go
+++ b/pkg/common/configs/config_test.go
@@ -2181,3 +2181,53 @@ partitions:
 	_, err = CreateConfig(data)
 	assert.ErrorContains(t, err, "group * max resource map[memory:90000 vcore:100000] of queue leaf is greater than immediate or ancestor parent maximum resource map[memory:10000 vcore:10000000]")
 }
+
+// TestUserGroupResolverConfig: tests the user group resolver configuration
+func TestUserGroupResolverConfig(t *testing.T) {
+	data := `
+partitions:
+  -
+    name: default
+    usergroupresolver:
+      type: ldap
+    placementrules:
+      - name: tag
+        value: namespace
+        create: true
+    queues:
+      - name: root
+        submitacl: '*'
+        properties:
+          application.sort.policy: fifo
+          sample: value2
+`
+	// validate the config and check after the update
+	config, err := CreateConfig(data)
+	assert.NilError(t, err)
+
+	// check if the user group resolver is set correctly
+	assert.Equal(t, "ldap", config.Partitions[0].UserGroupResolver.Type)
+
+	// partition with no user group resolver
+	data = `
+partitions:
+  -
+    name: default
+    placementrules:
+      - name: tag
+        value: namespace
+        create: true
+    queues:
+      - name: root
+        submitacl: '*'
+        properties:
+          application.sort.policy: fifo
+          sample: value2
+`
+	// validate the config and check after the update
+	config, err = CreateConfig(data)
+	assert.NilError(t, err)
+
+	// check if the user group resolver is set to empty
+	assert.Equal(t, "", config.Partitions[0].UserGroupResolver.Type)
+}
diff --git a/pkg/common/constants.go b/pkg/common/constants.go
index 7eae1c79..04bc2de3 100644
--- a/pkg/common/constants.go
+++ b/pkg/common/constants.go
@@ -29,4 +29,32 @@ const (
 	RecoveryQueue         = "@recovery@"
 	RecoveryQueueFull     = "root." + RecoveryQueue
 	DefaultPlacementQueue = "root.default"
+	LdapHost              = "Host"
+	LdapPort              = "Port"
+	LdapBaseDN            = "BaseDN"
+	LdapFilter            = "Filter"
+	LdapGroupAttr         = "GroupAttr"
+	LdapReturnAttr        = "ReturnAttr"
+	LdapBindUser          = "BindUser"
+	LdapBindPassword      = "BindPassword"
+	LdapInsecure          = "Insecure"
+	LdapSSL               = "SSL"
+)
+
+const (
+	DefaultLdapHost         = "localhost"
+	DefaultLdapPort         = 389
+	DefaultLdapBaseDN       = "dc=example,dc=com"
+	DefaultLdapFilter       = "(&(sAMAccountName=%s))"
+	DefaultLdapGroupAttr    = "memberOf"
+	DefaultLdapBindUser     = "admin"
+	DefaultLdapBindPassword = "admin"
+	DefaultLdapInsecure     = false
+	DefaultLdapSSL          = false
+	DefaultLdapUserUID      = "1211"
+)
+
+var (
+	LdapMountPath         = "/run/secrets/ldap"
+	DefaultLdapReturnAttr = []string{"memberOf"}
 )
diff --git a/pkg/common/security/ldap_validator.go b/pkg/common/security/ldap_validator.go
new file mode 100644
index 00000000..40d255de
--- /dev/null
+++ b/pkg/common/security/ldap_validator.go
@@ -0,0 +1,381 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package security
+
+import (
+	"fmt"
+	"net"
+	"regexp"
+	"strconv"
+	"strings"
+
+	"go.uber.org/zap"
+
+	"github.com/apache/yunikorn-core/pkg/common"
+	"github.com/apache/yunikorn-core/pkg/log"
+)
+
+// ValidationLevel defines the severity of validation issues
+type ValidationLevel int
+
+const (
+	// ValidationWarning indicates a non-critical issue that allows operation but might cause problems
+	ValidationWarning ValidationLevel = iota
+	// ValidationError indicates a critical issue that prevents proper operation
+	ValidationError
+)
+
+// ValidationIssue represents a single validation problem
+type ValidationIssue struct {
+	Field   string
+	Message string
+	Level   ValidationLevel
+}
+
+// LdapValidator provides validation for LDAP configuration
+type LdapValidator struct {
+	issues []ValidationIssue
+}
+
+// NewLdapValidator creates a new validator instance
+func NewLdapValidator() *LdapValidator {
+	return &LdapValidator{
+		issues: make([]ValidationIssue, 0),
+	}
+}
+
+// ValidateConfig validates the entire LDAP configuration
+func (v *LdapValidator) ValidateConfig(config *LdapConfig) bool {
+	v.validateHost(config.Host)
+	v.validatePort(config.Port)
+	v.validateBaseDN(config.BaseDN)
+	v.validateFilter(config.Filter)
+	v.validateGroupAttr(config.GroupAttr)
+	v.validateReturnAttr(config.ReturnAttr)
+	v.validateBindUser(config.BindUser)
+	v.validateBindPassword(config.BindPassword)
+
+	// Consistency checks
+	v.validateConsistency(config)
+
+	// Log all issues
+	v.logIssues()
+
+	// Return true if no errors (warnings are acceptable)
+	return !v.hasErrors()
+}
+
+// validateHost validates the LDAP host
+func (v *LdapValidator) validateHost(host string) {
+	if host == "" {
+		v.addIssue("Host", "Host cannot be empty", ValidationError)
+		return
+	}
+
+	// Check if it's an IP address
+	if net.ParseIP(host) != nil {
+		return // Valid IP address
+	}
+
+	// Check if it's a valid hostname
+	hostnameRegex := regexp.MustCompile(`^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$`)
+	if !hostnameRegex.MatchString(host) {
+		v.addIssue("Host", fmt.Sprintf("Invalid hostname format: %s", host), ValidationWarning)
+	}
+}
+
+// validatePort validates the LDAP port
+func (v *LdapValidator) validatePort(port int) {
+	if port < 1 || port > 65535 {
+		v.addIssue("Port", fmt.Sprintf("Port must be between 1 and 65535, got: %d", port), ValidationError)
+	}
+}
+
+// validateBaseDN validates the LDAP base DN
+func (v *LdapValidator) validateBaseDN(baseDN string) {
+	if baseDN == "" {
+		v.addIssue("BaseDN", "BaseDN cannot be empty", ValidationError)
+		return
+	}
+
+	// Check for at least one domain component
+	if !strings.Contains(strings.ToLower(baseDN), "dc=") {
+		v.addIssue("BaseDN", "BaseDN should contain at least one domain component (dc=)", ValidationWarning)
+	}
+
+	// Check for valid DN format
+	dnRegex := regexp.MustCompile(`^(?:(?:[a-zA-Z0-9]+=[^,]+)(?:,(?:[a-zA-Z0-9]+=[^,]+))*)?$`)
+	if !dnRegex.MatchString(baseDN) {
+		v.addIssue("BaseDN", fmt.Sprintf("Invalid DN format: %s", baseDN), ValidationWarning)
+	}
+}
+
+// validateFilter validates the LDAP filter
+func (v *LdapValidator) validateFilter(filter string) {
+	if filter == "" {
+		v.addIssue("Filter", "Filter cannot be empty", ValidationError)
+		return
+	}
+
+	// Check for username placeholder
+	if !strings.Contains(filter, "%s") {
+		v.addIssue("Filter", "Filter must contain '%s' placeholder for username substitution", ValidationError)
+	}
+
+	// Check for balanced parentheses
+	if !hasBalancedParentheses(filter) {
+		v.addIssue("Filter", "Filter has unbalanced parentheses", ValidationError)
+	}
+
+	// Basic filter format check
+	filterRegex := regexp.MustCompile(`^\(.*\)$`)
+	if !filterRegex.MatchString(filter) {
+		v.addIssue("Filter", "Filter should be enclosed in parentheses", ValidationWarning)
+	}
+}
+
+// validateGroupAttr validates the LDAP group attribute
+func (v *LdapValidator) validateGroupAttr(groupAttr string) {
+	if groupAttr == "" {
+		v.addIssue("GroupAttr", "GroupAttr cannot be empty", ValidationError)
+		return
+	}
+
+	// Check for valid attribute name format
+	attrRegex := regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9\-_]*$`)
+	if !attrRegex.MatchString(groupAttr) {
+		v.addIssue("GroupAttr", fmt.Sprintf("Invalid attribute name format: %s", groupAttr), ValidationWarning)
+	}
+}
+
+// validateReturnAttr validates the LDAP return attributes
+func (v *LdapValidator) validateReturnAttr(returnAttr []string) {
+	if len(returnAttr) == 0 {
+		v.addIssue("ReturnAttr", "ReturnAttr cannot be empty", ValidationError)
+		return
+	}
+
+	attrRegex := regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9\-_]*$`)
+	for _, attr := range returnAttr {
+		if !attrRegex.MatchString(attr) {
+			v.addIssue("ReturnAttr", fmt.Sprintf("Invalid attribute name format: %s", attr), ValidationWarning)
+		}
+	}
+}
+
+// validateBindUser validates the LDAP bind user
+func (v *LdapValidator) validateBindUser(bindUser string) {
+	if bindUser == "" {
+		v.addIssue("BindUser", "BindUser cannot be empty", ValidationError)
+		return
+	}
+
+	// Check if it's a DN format
+	dnRegex := regexp.MustCompile(`^(?:(?:[a-zA-Z0-9]+=[^,]+)(?:,(?:[a-zA-Z0-9]+=[^,]+))*)?$`)
+	if dnRegex.MatchString(bindUser) {
+		return // Valid DN format
+	}
+
+	// Check if it's a username format
+	usernameRegex := regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9\._\-@]*$`)
+	if !usernameRegex.MatchString(bindUser) {
+		v.addIssue("BindUser", fmt.Sprintf("BindUser is neither a valid DN nor a valid username: %s", bindUser), ValidationWarning)
+	}
+}
+
+// validateBindPassword validates the LDAP bind password
+func (v *LdapValidator) validateBindPassword(bindPassword string) {
+	if bindPassword == "" {
+		v.addIssue("BindPassword", "BindPassword cannot be empty", ValidationError)
+		return
+	}
+
+	// Check for minimum length
+	if len(bindPassword) < 3 {
+		v.addIssue("BindPassword", "BindPassword is too short", ValidationWarning)
+	}
+
+	// We don't check for password complexity here as it depends on the LDAP server policy
+}
+
+// validateConsistency performs cross-field validation
+func (v *LdapValidator) validateConsistency(config *LdapConfig) {
+	// Check SSL and port consistency
+	if config.useSsl && config.Port != 636 {
+		v.addIssue("Port", fmt.Sprintf("SSL is enabled but port is not the default LDAPS port (636), using: %d", config.Port), ValidationWarning)
+	}
+
+	// Check SSL and Insecure consistency
+	if config.useSsl && config.Insecure {
+		v.addIssue("SSL/Insecure", "Both SSL and Insecure are enabled, which may indicate a security misconfiguration", ValidationWarning)
+	}
+}
+
+// addIssue adds a validation issue to the list
+func (v *LdapValidator) addIssue(field, message string, level ValidationLevel) {
+	v.issues = append(v.issues, ValidationIssue{
+		Field:   field,
+		Message: message,
+		Level:   level,
+	})
+}
+
+// hasErrors checks if there are any validation errors
+func (v *LdapValidator) hasErrors() bool {
+	for _, issue := range v.issues {
+		if issue.Level == ValidationError {
+			return true
+		}
+	}
+	return false
+}
+
+// logIssues logs all validation issues
+func (v *LdapValidator) logIssues() {
+	for _, issue := range v.issues {
+		if issue.Level == ValidationError {
+			log.Log(log.Security).Error("LDAP configuration validation error",
+				zap.String("field", issue.Field),
+				zap.String("message", issue.Message))
+		} else {
+			log.Log(log.Security).Warn("LDAP configuration validation warning",
+				zap.String("field", issue.Field),
+				zap.String("message", issue.Message))
+		}
+	}
+}
+
+// hasBalancedParentheses checks if a string has balanced parentheses
+func hasBalancedParentheses(s string) bool {
+	count := 0
+	for _, c := range s {
+		switch c {
+		case '(':
+			count++
+		case ')':
+			count--
+			if count < 0 {
+				return false
+			}
+		}
+	}
+	return count == 0
+}
+
+// ValidateSecretValue validates a single secret value based on its key
+func ValidateSecretValue(key, value string) (interface{}, error) {
+	switch key {
+	case common.LdapHost:
+		return validateHostValue(value)
+	case common.LdapPort:
+		return validatePortValue(value)
+	case common.LdapBaseDN:
+		return validateBaseDNValue(value)
+	case common.LdapFilter:
+		return validateFilterValue(value)
+	case common.LdapGroupAttr:
+		return validateGroupAttrValue(value)
+	case common.LdapReturnAttr:
+		return validateReturnAttrValue(value)
+	case common.LdapBindUser:
+		return validateBindUserValue(value)
+	case common.LdapBindPassword:
+		return validateBindPasswordValue(value)
+	case common.LdapInsecure, common.LdapSSL:
+		return validateBoolValue(value)
+	default:
+		return nil, fmt.Errorf("unknown LDAP secret key: %s", key)
+	}
+}
+
+// Individual validation functions for each secret type
+func validateHostValue(value string) (string, error) {
+	if value == "" {
+		return "", fmt.Errorf("host cannot be empty")
+	}
+	return value, nil
+}
+
+func validatePortValue(value string) (int, error) {
+	port, err := strconv.Atoi(value)
+	if err != nil {
+		return 0, fmt.Errorf("invalid port number: %s", err)
+	}
+	if port < 1 || port > 65535 {
+		return 0, fmt.Errorf("port must be between 1 and 65535, got: %d", port)
+	}
+	return port, nil
+}
+
+func validateBaseDNValue(value string) (string, error) {
+	if value == "" {
+		return "", fmt.Errorf("baseDN cannot be empty")
+	}
+	return value, nil
+}
+
+func validateFilterValue(value string) (string, error) {
+	if value == "" {
+		return "", fmt.Errorf("filter cannot be empty")
+	}
+	if !strings.Contains(value, "%s") {
+		return "", fmt.Errorf("filter must contain '%%s' placeholder for username substitution")
+	}
+	if !hasBalancedParentheses(value) {
+		return "", fmt.Errorf("filter has unbalanced parentheses")
+	}
+	return value, nil
+}
+
+func validateGroupAttrValue(value string) (string, error) {
+	if value == "" {
+		return "", fmt.Errorf("groupAttr cannot be empty")
+	}
+	return value, nil
+}
+
+func validateReturnAttrValue(value string) ([]string, error) {
+	if value == "" {
+		return nil, fmt.Errorf("returnAttr cannot be empty")
+	}
+	attrs := strings.Split(value, ",")
+	return attrs, nil
+}
+
+func validateBindUserValue(value string) (string, error) {
+	if value == "" {
+		return "", fmt.Errorf("bindUser cannot be empty")
+	}
+	return value, nil
+}
+
+func validateBindPasswordValue(value string) (string, error) {
+	if value == "" {
+		return "", fmt.Errorf("bindPassword cannot be empty")
+	}
+	return value, nil
+}
+
+func validateBoolValue(value string) (bool, error) {
+	boolValue, err := strconv.ParseBool(value)
+	if err != nil {
+		return false, fmt.Errorf("invalid boolean value: %s", err)
+	}
+	return boolValue, nil
+}
diff --git a/pkg/common/security/ldap_validator_test.go b/pkg/common/security/ldap_validator_test.go
new file mode 100644
index 00000000..c9f7f7f0
--- /dev/null
+++ b/pkg/common/security/ldap_validator_test.go
@@ -0,0 +1,993 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package security
+
+import (
+	"testing"
+
+	"gotest.tools/v3/assert"
+
+	"github.com/apache/yunikorn-core/pkg/common"
+)
+
+func TestValidateHostValue(t *testing.T) {
+	tests := []struct {
+		name      string
+		value     string
+		wantError bool
+	}{
+		{"Valid hostname", "ldap.example.com", false},
+		{"Valid IP", "192.168.1.1", false},
+		{"Empty", "", true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			_, err := validateHostValue(tt.value)
+			if tt.wantError {
+				assert.Assert(t, err != nil)
+			} else {
+				assert.NilError(t, err)
+			}
+		})
+	}
+}
+
+func TestValidatePortValue(t *testing.T) {
+	tests := []struct {
+		name      string
+		value     string
+		wantError bool
+	}{
+		{"Valid port", "389", false},
+		{"Valid port range", "65535", false},
+		{"Invalid port - too high", "65536", true},
+		{"Invalid port - too low", "0", true},
+		{"Invalid port - not a number", "abc", true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			_, err := validatePortValue(tt.value)
+			if tt.wantError {
+				assert.Assert(t, err != nil)
+			} else {
+				assert.NilError(t, err)
+			}
+		})
+	}
+}
+
+func TestValidateFilterValue(t *testing.T) {
+	tests := []struct {
+		name      string
+		value     string
+		wantError bool
+	}{
+		{"Valid filter", "(&(objectClass=user)(sAMAccountName=%s))", false},
+		{"Missing placeholder", "(&(objectClass=user)(sAMAccountName=user))", true},
+		{"Unbalanced parentheses", "(&(objectClass=user)(sAMAccountName=%s)", true},
+		{"Empty", "", true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			_, err := validateFilterValue(tt.value)
+			if tt.wantError {
+				assert.Assert(t, err != nil)
+			} else {
+				assert.NilError(t, err)
+			}
+		})
+	}
+}
+
+func TestValidateReturnAttrValue(t *testing.T) {
+	tests := []struct {
+		name      string
+		value     string
+		wantError bool
+	}{
+		{"Valid single attr", "memberOf", false},
+		{"Valid multiple attrs", "memberOf,cn,mail", false},
+		{"Empty", "", true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			attrs, err := validateReturnAttrValue(tt.value)
+			if tt.wantError {
+				assert.Assert(t, err != nil)
+			} else {
+				assert.NilError(t, err)
+				switch tt.value {
+				case "memberOf":
+					assert.Equal(t, 1, len(attrs))
+					assert.Equal(t, "memberOf", attrs[0])
+				case "memberOf,cn,mail":
+					assert.Equal(t, 3, len(attrs))
+					assert.Equal(t, "memberOf", attrs[0])
+					assert.Equal(t, "cn", attrs[1])
+					assert.Equal(t, "mail", attrs[2])
+				}
+			}
+		})
+	}
+}
+
+func TestValidateBoolValue(t *testing.T) {
+	tests := []struct {
+		name      string
+		value     string
+		expected  bool
+		wantError bool
+	}{
+		{"Valid true", "true", true, false},
+		{"Valid false", "false", false, false},
+		{"Valid 1", "1", true, false},
+		{"Valid 0", "0", false, false},
+		{"Invalid", "notabool", false, true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			val, err := validateBoolValue(tt.value)
+			if tt.wantError {
+				assert.Assert(t, err != nil)
+			} else {
+				assert.NilError(t, err)
+				assert.Equal(t, tt.expected, val)
+			}
+		})
+	}
+}
+
+func TestValidateConfig(t *testing.T) {
+	tests := []struct {
+		name     string
+		config   *LdapConfig
+		expected bool
+	}{
+		{
+			name: "Valid configuration",
+			config: &LdapConfig{
+				Host:         "ldap.example.com",
+				Port:         389,
+				BaseDN:       "dc=example,dc=com",
+				Filter:       "(&(objectClass=user)(sAMAccountName=%s))",
+				GroupAttr:    "memberOf",
+				ReturnAttr:   []string{"memberOf"},
+				BindUser:     "cn=admin,dc=example,dc=com",
+				BindPassword: "password",
+				Insecure:     false,
+				useSsl:       false,
+			},
+			expected: true,
+		},
+		{
+			name: "Invalid configuration - empty fields",
+			config: &LdapConfig{
+				Host:         "",
+				Port:         0,
+				BaseDN:       "",
+				Filter:       "invalid-filter",
+				GroupAttr:    "",
+				ReturnAttr:   []string{},
+				BindUser:     "",
+				BindPassword: "",
+				Insecure:     true,
+				useSsl:       true,
+			},
+			expected: false,
+		},
+		{
+			name: "Invalid configuration - missing placeholder in filter",
+			config: &LdapConfig{
+				Host:         "ldap.example.com",
+				Port:         389,
+				BaseDN:       "dc=example,dc=com",
+				Filter:       "(&(objectClass=user)(sAMAccountName=user))", // Missing %s placeholder
+				GroupAttr:    "memberOf",
+				ReturnAttr:   []string{"memberOf"},
+				BindUser:     "cn=admin,dc=example,dc=com",
+				BindPassword: "password",
+				Insecure:     false,
+				useSsl:       false,
+			},
+			expected: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			validator := NewLdapValidator()
+			result := validator.ValidateConfig(tt.config)
+			assert.Equal(t, tt.expected, result)
+		})
+	}
+}
+
+// TestValidateBaseDN tests the validateBaseDN method with various inputs
+func TestValidateBaseDN(t *testing.T) {
+	tests := []struct {
+		name          string
+		baseDN        string
+		expectWarning bool
+		expectError   bool
+	}{
+		{
+			name:          "Valid BaseDN",
+			baseDN:        "dc=example,dc=com",
+			expectWarning: false,
+			expectError:   false,
+		},
+		{
+			name:          "Empty BaseDN",
+			baseDN:        "",
+			expectWarning: false,
+			expectError:   true,
+		},
+		{
+			name:          "Invalid format - missing value",
+			baseDN:        "dc=,dc=com",
+			expectWarning: true,
+			expectError:   false,
+		},
+		{
+			name:          "Invalid format - missing equals",
+			baseDN:        "dcexample,dc=com",
+			expectWarning: true,
+			expectError:   false,
+		},
+		{
+			name:          "Invalid format - unbalanced commas",
+			baseDN:        "dc=example,dc=com,",
+			expectWarning: true,
+			expectError:   false,
+		},
+		{
+			name:          "No domain component",
+			baseDN:        "cn=admin,ou=users",
+			expectWarning: true,
+			expectError:   false,
+		},
+		{
+			name:          "Invalid format - extra comma",
+			baseDN:        "dc=example,,dc=com",
+			expectWarning: true,
+			expectError:   false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			validator := NewLdapValidator()
+			validator.validateBaseDN(tt.baseDN)
+
+			hasError := false
+			hasWarning := false
+
+			for _, issue := range validator.issues {
+				if issue.Field == "BaseDN" {
+					switch issue.Level {
+					case ValidationError:
+						hasError = true
+					case ValidationWarning:
+						hasWarning = true
+					}
+				}
+			}
+
+			assert.Equal(t, tt.expectError, hasError, "Expected error: %v, got: %v", tt.expectError, hasError)
+			assert.Equal(t, tt.expectWarning, hasWarning, "Expected warning: %v, got: %v", tt.expectWarning, hasWarning)
+		})
+	}
+}
+
+// TestValidateGroupAttr tests the validateGroupAttr method with various inputs
+func TestValidateGroupAttr(t *testing.T) {
+	tests := []struct {
+		name          string
+		groupAttr     string
+		expectWarning bool
+		expectError   bool
+	}{
+		{
+			name:          "Valid attribute name",
+			groupAttr:     "memberOf",
+			expectWarning: false,
+			expectError:   false,
+		},
+		{
+			name:          "Empty attribute name",
+			groupAttr:     "",
+			expectWarning: false,
+			expectError:   true,
+		},
+		{
+			name:          "Invalid format - starts with number",
+			groupAttr:     "1memberOf",
+			expectWarning: true,
+			expectError:   false,
+		},
+		{
+			name:          "Invalid format - special characters",
+			groupAttr:     "member@Of",
+			expectWarning: true,
+			expectError:   false,
+		},
+		{
+			name:          "Valid with hyphen",
+			groupAttr:     "member-of",
+			expectWarning: false,
+			expectError:   false,
+		},
+		{
+			name:          "Valid with underscore",
+			groupAttr:     "member_of",
+			expectWarning: false,
+			expectError:   false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			validator := NewLdapValidator()
+			validator.validateGroupAttr(tt.groupAttr)
+
+			hasError := false
+			hasWarning := false
+
+			for _, issue := range validator.issues {
+				if issue.Field == "GroupAttr" {
+					switch issue.Level {
+					case ValidationError:
+						hasError = true
+					case ValidationWarning:
+						hasWarning = true
+					}
+				}
+			}
+
+			assert.Equal(t, tt.expectError, hasError, "Expected error: %v, got: %v", tt.expectError, hasError)
+			assert.Equal(t, tt.expectWarning, hasWarning, "Expected warning: %v, got: %v", tt.expectWarning, hasWarning)
+		})
+	}
+}
+
+// TestValidateBindUser tests the validateBindUser method with various inputs
+func TestValidateBindUser(t *testing.T) {
+	tests := []struct {
+		name          string
+		bindUser      string
+		expectWarning bool
+		expectError   bool
+	}{
+		{
+			name:          "Valid DN format",
+			bindUser:      "cn=admin,dc=example,dc=com",
+			expectWarning: false,
+			expectError:   false,
+		},
+		{
+			name:          "Valid username format",
+			bindUser:      "admin",
+			expectWarning: false,
+			expectError:   false,
+		},
+		{
+			name:          "Valid username with domain",
+			bindUser:      "admin@example.com",
+			expectWarning: false,
+			expectError:   false,
+		},
+		{
+			name:          "Empty bind user",
+			bindUser:      "",
+			expectWarning: false,
+			expectError:   true,
+		},
+		{
+			name:          "Invalid format - special characters",
+			bindUser:      "admin!#$%",
+			expectWarning: true,
+			expectError:   false,
+		},
+		{
+			name:          "Invalid DN format",
+			bindUser:      "cn=admin,dc=example,=com",
+			expectWarning: true,
+			expectError:   false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			validator := NewLdapValidator()
+			validator.validateBindUser(tt.bindUser)
+
+			hasError := false
+			hasWarning := false
+
+			for _, issue := range validator.issues {
+				if issue.Field == "BindUser" {
+					switch issue.Level {
+					case ValidationError:
+						hasError = true
+					case ValidationWarning:
+						hasWarning = true
+					}
+				}
+			}
+
+			assert.Equal(t, tt.expectError, hasError, "Expected error: %v, got: %v", tt.expectError, hasError)
+			assert.Equal(t, tt.expectWarning, hasWarning, "Expected warning: %v, got: %v", tt.expectWarning, hasWarning)
+		})
+	}
+}
+
+// TestValidateBindPassword tests the validateBindPassword method with various inputs
+func TestValidateBindPassword(t *testing.T) {
+	tests := []struct {
+		name          string
+		bindPassword  string
+		expectWarning bool
+		expectError   bool
+	}{
+		{
+			name:          "Valid password",
+			bindPassword:  "password123",
+			expectWarning: false,
+			expectError:   false,
+		},
+		{
+			name:          "Empty password",
+			bindPassword:  "",
+			expectWarning: false,
+			expectError:   true,
+		},
+		{
+			name:          "Very short password",
+			bindPassword:  "a",
+			expectWarning: true,
+			expectError:   false,
+		},
+		{
+			name:          "Password with special characters",
+			bindPassword:  "p@ssw0rd!",
+			expectWarning: false,
+			expectError:   false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			validator := NewLdapValidator()
+			validator.validateBindPassword(tt.bindPassword)
+
+			hasError := false
+			hasWarning := false
+
+			for _, issue := range validator.issues {
+				if issue.Field == "BindPassword" {
+					switch issue.Level {
+					case ValidationError:
+						hasError = true
+					case ValidationWarning:
+						hasWarning = true
+					}
+				}
+			}
+
+			assert.Equal(t, tt.expectError, hasError, "Expected error: %v, got: %v", tt.expectError, hasError)
+			assert.Equal(t, tt.expectWarning, hasWarning, "Expected warning: %v, got: %v", tt.expectWarning, hasWarning)
+		})
+	}
+}
+
+// TestValidateReturnAttr tests the validateReturnAttr method with various inputs
+func TestValidateReturnAttr(t *testing.T) {
+	tests := []struct {
+		name          string
+		returnAttr    []string
+		expectWarning bool
+		expectError   bool
+	}{
+		{
+			name:          "Valid single attribute",
+			returnAttr:    []string{"memberOf"},
+			expectWarning: false,
+			expectError:   false,
+		},
+		{
+			name:          "Valid multiple attributes",
+			returnAttr:    []string{"memberOf", "cn", "mail"},
+			expectWarning: false,
+			expectError:   false,
+		},
+		{
+			name:          "Empty array",
+			returnAttr:    []string{},
+			expectWarning: false,
+			expectError:   true,
+		},
+		{
+			name:          "Invalid attribute name",
+			returnAttr:    []string{"member@Of"},
+			expectWarning: true,
+			expectError:   false,
+		},
+		{
+			name:          "Mix of valid and invalid",
+			returnAttr:    []string{"memberOf", "123invalid"},
+			expectWarning: true,
+			expectError:   false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			validator := NewLdapValidator()
+			validator.validateReturnAttr(tt.returnAttr)
+
+			hasError := false
+			hasWarning := false
+
+			for _, issue := range validator.issues {
+				if issue.Field == "ReturnAttr" {
+					switch issue.Level {
+					case ValidationError:
+						hasError = true
+					case ValidationWarning:
+						hasWarning = true
+					}
+				}
+			}
+
+			assert.Equal(t, tt.expectError, hasError, "Expected error: %v, got: %v", tt.expectError, hasError)
+			assert.Equal(t, tt.expectWarning, hasWarning, "Expected warning: %v, got: %v", tt.expectWarning, hasWarning)
+		})
+	}
+}
+
+// TestValidateHost tests the validateHost method with various inputs
+func TestValidateHost(t *testing.T) {
+	tests := []struct {
+		name          string
+		host          string
+		expectWarning bool
+		expectError   bool
+	}{
+		{
+			name:          "Valid hostname",
+			host:          "ldap.example.com",
+			expectWarning: false,
+			expectError:   false,
+		},
+		{
+			name:          "Valid IP address",
+			host:          "192.168.1.1",
+			expectWarning: false,
+			expectError:   false,
+		},
+		{
+			name:          "Empty host",
+			host:          "",
+			expectWarning: false,
+			expectError:   true,
+		},
+		{
+			name:          "Invalid hostname - starts with hyphen",
+			host:          "-ldap.example.com",
+			expectWarning: true,
+			expectError:   false,
+		},
+		{
+			name:          "Invalid hostname - contains invalid characters",
+			host:          "ldap_example.com",
+			expectWarning: true,
+			expectError:   false,
+		},
+		{
+			name:          "Invalid hostname - double dots",
+			host:          "ldap..example.com",
+			expectWarning: true,
+			expectError:   false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			validator := NewLdapValidator()
+			validator.validateHost(tt.host)
+
+			hasError := false
+			hasWarning := false
+
+			for _, issue := range validator.issues {
+				if issue.Field == "Host" {
+					switch issue.Level {
+					case ValidationError:
+						hasError = true
+					case ValidationWarning:
+						hasWarning = true
+					}
+				}
+			}
+
+			assert.Equal(t, tt.expectError, hasError, "Expected error: %v, got: %v", tt.expectError, hasError)
+			assert.Equal(t, tt.expectWarning, hasWarning, "Expected warning: %v, got: %v", tt.expectWarning, hasWarning)
+		})
+	}
+}
+
+// TestValidateFilter tests the validateFilter method with various inputs
+func TestValidateFilter(t *testing.T) {
+	tests := []struct {
+		name          string
+		filter        string
+		expectWarning bool
+		expectError   bool
+	}{
+		{
+			name:          "Valid filter",
+			filter:        "(&(objectClass=user)(sAMAccountName=%s))",
+			expectWarning: false,
+			expectError:   false,
+		},
+		{
+			name:          "Empty filter",
+			filter:        "",
+			expectWarning: false,
+			expectError:   true,
+		},
+		{
+			name:          "Missing placeholder",
+			filter:        "(&(objectClass=user)(sAMAccountName=user))",
+			expectWarning: false,
+			expectError:   true,
+		},
+		{
+			name:          "Unbalanced parentheses",
+			filter:        "(&(objectClass=user)(sAMAccountName=%s)",
+			expectWarning: false,
+			expectError:   true,
+		},
+		{
+			name:          "Not enclosed in parentheses",
+			filter:        "objectClass=user&sAMAccountName=%s",
+			expectWarning: true,
+			expectError:   false, // This filter does contain the %s placeholder, so it's not an error
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			validator := NewLdapValidator()
+			validator.validateFilter(tt.filter)
+
+			hasError := false
+			hasWarning := false
+
+			for _, issue := range validator.issues {
+				if issue.Field == "Filter" {
+					switch issue.Level {
+					case ValidationError:
+						hasError = true
+					case ValidationWarning:
+						hasWarning = true
+					}
+				}
+			}
+
+			assert.Equal(t, tt.expectError, hasError, "Expected error: %v, got: %v", tt.expectError, hasError)
+			assert.Equal(t, tt.expectWarning, hasWarning, "Expected warning: %v, got: %v", tt.expectWarning, hasWarning)
+		})
+	}
+}
+
+func TestValidateSecretValue(t *testing.T) {
+	tests := []struct {
+		name      string
+		key       string
+		value     string
+		wantError bool
+	}{
+		{"Valid host", common.LdapHost, "ldap.example.com", false},
+		{"Valid port", common.LdapPort, "389", false},
+		{"Valid baseDN", common.LdapBaseDN, "dc=example,dc=com", false},
+		{"Valid filter", common.LdapFilter, "(&(objectClass=user)(sAMAccountName=%s))", false},
+		{"Valid groupAttr", common.LdapGroupAttr, "memberOf", false},
+		{"Valid returnAttr", common.LdapReturnAttr, "memberOf,cn", false},
+		{"Valid bindUser", common.LdapBindUser, "cn=admin,dc=example,dc=com", false},
+		{"Valid bindPassword", common.LdapBindPassword, "password", false},
+		{"Valid insecure", common.LdapInsecure, "true", false},
+		{"Valid SSL", common.LdapSSL, "false", false},
+		{"Invalid key", "unknown", "value", true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			_, err := ValidateSecretValue(tt.key, tt.value)
+			if tt.wantError {
+				assert.Assert(t, err != nil)
+			} else {
+				assert.NilError(t, err)
+			}
+		})
+	}
+}
+
+func TestHasBalancedParentheses(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    string
+		expected bool
+	}{
+		{"Empty string", "", true},
+		{"Simple balanced", "()", true},
+		{"Nested balanced", "((()))", true},
+		{"Complex balanced", "(a(b)c(d(e)f)g)", true},
+		{"Unbalanced - too many open", "(()", false},
+		{"Unbalanced - too many closed", "())", false},
+		{"Unbalanced - wrong order", ")(", false},
+		{"No parentheses", "abc", true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := hasBalancedParentheses(tt.input)
+			assert.Equal(t, tt.expected, result)
+		})
+	}
+}
+
+func TestLdapValidatorAddIssue(t *testing.T) {
+	validator := NewLdapValidator()
+
+	// Add a warning
+	validator.addIssue("TestField", "Test warning", ValidationWarning)
+
+	// Add an error
+	validator.addIssue("TestField2", "Test error", ValidationError)
+
+	// Check that issues were added
+	assert.Equal(t, 2, len(validator.issues))
+	assert.Equal(t, "TestField", validator.issues[0].Field)
+	assert.Equal(t, "Test warning", validator.issues[0].Message)
+	assert.Equal(t, ValidationWarning, validator.issues[0].Level)
+	assert.Equal(t, "TestField2", validator.issues[1].Field)
+	assert.Equal(t, "Test error", validator.issues[1].Message)
+	assert.Equal(t, ValidationError, validator.issues[1].Level)
+
+	// Check hasErrors
+	assert.Assert(t, validator.hasErrors())
+}
+
+func TestLdapValidatorValidateConsistency(t *testing.T) {
+	tests := []struct {
+		name          string
+		config        *LdapConfig
+		expectWarning bool
+	}{
+		{
+			name: "No warnings",
+			config: &LdapConfig{
+				useSsl:   false,
+				Insecure: false,
+				Port:     389,
+			},
+			expectWarning: false,
+		},
+		{
+			name: "SSL with non-standard port",
+			config: &LdapConfig{
+				useSsl:   true,
+				Insecure: false,
+				Port:     389,
+			},
+			expectWarning: true,
+		},
+		{
+			name: "SSL with insecure",
+			config: &LdapConfig{
+				useSsl:   true,
+				Insecure: true,
+				Port:     636,
+			},
+			expectWarning: true,
+		},
+		{
+			name: "SSL with standard port",
+			config: &LdapConfig{
+				useSsl:   true,
+				Insecure: false,
+				Port:     636,
+			},
+			expectWarning: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			validator := NewLdapValidator()
+			validator.validateConsistency(tt.config)
+
+			hasWarnings := len(validator.issues) > 0
+			assert.Equal(t, tt.expectWarning, hasWarnings)
+		})
+	}
+}
+
+// TestValidatePort tests the validatePort method with various inputs
+func TestValidatePort(t *testing.T) {
+	tests := []struct {
+		name          string
+		port          int
+		expectWarning bool
+		expectError   bool
+	}{
+		{
+			name:          "Valid port - LDAP",
+			port:          389,
+			expectWarning: false,
+			expectError:   false,
+		},
+		{
+			name:          "Valid port - LDAPS",
+			port:          636,
+			expectWarning: false,
+			expectError:   false,
+		},
+		{
+			name:          "Valid port - custom",
+			port:          1389,
+			expectWarning: false,
+			expectError:   false,
+		},
+		{
+			name:          "Invalid port - too low",
+			port:          0,
+			expectWarning: false,
+			expectError:   true,
+		},
+		{
+			name:          "Invalid port - too high",
+			port:          65536,
+			expectWarning: false,
+			expectError:   true,
+		},
+		{
+			name:          "Valid port - minimum",
+			port:          1,
+			expectWarning: false,
+			expectError:   false,
+		},
+		{
+			name:          "Valid port - maximum",
+			port:          65535,
+			expectWarning: false,
+			expectError:   false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			validator := NewLdapValidator()
+			validator.validatePort(tt.port)
+
+			hasError := false
+			hasWarning := false
+
+			for _, issue := range validator.issues {
+				if issue.Field == "Port" {
+					switch issue.Level {
+					case ValidationError:
+						hasError = true
+					case ValidationWarning:
+						hasWarning = true
+					}
+				}
+			}
+
+			assert.Equal(t, tt.expectError, hasError, "Expected error: %v, got: %v", tt.expectError, hasError)
+			assert.Equal(t, tt.expectWarning, hasWarning, "Expected warning: %v, got: %v", tt.expectWarning, hasWarning)
+		})
+	}
+}
+
+// TestLogIssues tests the logIssues method
+func TestLogIssues(t *testing.T) {
+	validator := NewLdapValidator()
+
+	// Add a warning
+	validator.addIssue("TestField1", "Test warning message", ValidationWarning)
+
+	// Add an error
+	validator.addIssue("TestField2", "Test error message", ValidationError)
+
+	// Call logIssues - we can't easily capture the log output in a unit test,
+	// but we can at least verify it doesn't panic
+	validator.logIssues()
+
+	// Verify the issues are still present after logging
+	assert.Equal(t, 2, len(validator.issues))
+	assert.Equal(t, "TestField1", validator.issues[0].Field)
+	assert.Equal(t, ValidationWarning, validator.issues[0].Level)
+	assert.Equal(t, "Test warning message", validator.issues[0].Message)
+	assert.Equal(t, "TestField2", validator.issues[1].Field)
+	assert.Equal(t, ValidationError, validator.issues[1].Level)
+	assert.Equal(t, "Test error message", validator.issues[1].Message)
+}
+
+// TestValidateBindUserValue tests the validateBindUserValue function
+func TestValidateBindUserValue(t *testing.T) {
+	tests := []struct {
+		name      string
+		value     string
+		wantError bool
+	}{
+		{"Valid DN", "cn=admin,dc=example,dc=com", false},
+		{"Valid username", "admin", false},
+		{"Empty", "", true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			_, err := validateBindUserValue(tt.value)
+			if tt.wantError {
+				assert.Assert(t, err != nil)
+			} else {
+				assert.NilError(t, err)
+			}
+		})
+	}
+}
+
+// TestValidateBaseDNValueEdgeCases tests edge cases for validateBaseDNValue
+func TestValidateBaseDNValueEdgeCases(t *testing.T) {
+	// Test empty value
+	_, err := validateBaseDNValue("")
+	assert.Assert(t, err != nil)
+	assert.ErrorContains(t, err, "baseDN cannot be empty")
+}
+
+// TestValidateGroupAttrValueEdgeCases tests edge cases for validateGroupAttrValue
+func TestValidateGroupAttrValueEdgeCases(t *testing.T) {
+	// Test empty value
+	_, err := validateGroupAttrValue("")
+	assert.Assert(t, err != nil)
+	assert.ErrorContains(t, err, "groupAttr cannot be empty")
+}
+
+// TestValidateReturnAttrValueEdgeCases tests edge cases for validateReturnAttrValue
+func TestValidateReturnAttrValueEdgeCases(t *testing.T) {
+	// Test empty value
+	_, err := validateReturnAttrValue("")
+	assert.Assert(t, err != nil)
+	assert.ErrorContains(t, err, "returnAttr cannot be empty")
+}
+
+// TestValidateBindPasswordValueEdgeCases tests edge cases for validateBindPasswordValue
+func TestValidateBindPasswordValueEdgeCases(t *testing.T) {
+	// Test empty value
+	_, err := validateBindPasswordValue("")
+	assert.Assert(t, err != nil)
+	assert.ErrorContains(t, err, "bindPassword cannot be empty")
+}
diff --git a/pkg/common/security/usergroup.go b/pkg/common/security/usergroup.go
index d9a1966c..eae10a44 100644
--- a/pkg/common/security/usergroup.go
+++ b/pkg/common/security/usergroup.go
@@ -66,20 +66,32 @@ type UserGroup struct {
 	resolved int64
 }
 
+const (
+	Default = ""
+	Ldap    = "ldap"
+	Test    = "test"
+	Os      = "os"
+)
+
 // Get the resolver for the user and group info.
 // Current setup allows three resolvers:
 // * NO resolver: default, no user or group resolution just return the info (k8s use case)
 // * OS resolver: uses the OS libraries to resolve user and group memberships
 // * Test resolver: fake resolution for testing
-func GetUserGroupCache(resolver string) *UserGroupCache {
+// * Ldap resolver: uses the LDAP protocol to resolve user and group memberships
+func GetUserGroupCache(ugr configs.UserGroupResolver, ldapConfigReader ConfigReader, ldapAccess LdapAccess) *UserGroupCache {
+	resolver := ugr.Type
 	once.Do(func() {
 		switch resolver {
-		case "test":
+		case Test:
 			log.Log(log.Security).Info("creating test user group resolver")
 			instance = GetUserGroupCacheTest()
-		case "os":
+		case Os:
 			log.Log(log.Security).Info("creating OS user group resolver")
 			instance = GetUserGroupCacheOS()
+		case Ldap:
+			log.Log(log.Security).Info("creating LDAP user group resolver")
+			instance = GetUserGroupCacheLdap(ldapConfigReader, ldapAccess)
 		default:
 			log.Log(log.Security).Info("creating UserGroupCache without resolver")
 			instance = GetUserGroupNoResolve()
@@ -231,6 +243,10 @@ func (c *UserGroupCache) Stop() {
 	if !stopped.Load() {
 		log.Log(log.Security).Info("Stopping UserGroupCache background cleanup")
 		close(c.stop)
+		// Clear the cache before resetting the instance
+		c.lock.Lock()
+		c.ugs = make(map[string]*UserGroup)
+		c.lock.Unlock()
 		once = &sync.Once{} // re-init so that GetUserGroupCache() can create a new instance again
 		instance = nil
 		stopped.Store(true)
diff --git a/pkg/common/security/usergroup_ldap_resolver.go b/pkg/common/security/usergroup_ldap_resolver.go
new file mode 100644
index 00000000..eea68b44
--- /dev/null
+++ b/pkg/common/security/usergroup_ldap_resolver.go
@@ -0,0 +1,383 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package security
+
+import (
+	"crypto/tls"
+	"fmt"
+	"os"
+	"os/user"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/go-ldap/ldap/v3"
+	"go.uber.org/zap"
+
+	"github.com/apache/yunikorn-core/pkg/common"
+	"github.com/apache/yunikorn-core/pkg/log"
+)
+
+// This file contains the implementation of the LDAP resolver for user groups
+
+type LdapLookup struct {
+	config LdapConfig
+	access LdapAccess
+}
+
+// LdapAccess defines the interface for LDAP operations
+type LdapAccess interface {
+	// DialURL establishes a connection to the LDAP server
+	DialURL(url string, options ...ldap.DialOpt) (*ldap.Conn, error)
+
+	// Bind authenticates with the LDAP server
+	Bind(conn *ldap.Conn, username, password string) error
+
+	// Search performs an LDAP search operation
+	Search(conn *ldap.Conn, searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error)
+
+	// Close closes the LDAP connection
+	Close(conn *ldap.Conn)
+}
+
+type ConfigReader interface {
+	ReadLdapConfig() (*LdapConfig, error)
+}
+
+type configReaderImpl struct{}
+
+func (configReaderImpl) ReadLdapConfig() (*LdapConfig, error) {
+	secretsDir := common.LdapMountPath
+
+	// Read all files from secrets directory
+	files, err := os.ReadDir(secretsDir)
+	if err != nil {
+		log.Log(log.Security).Error("Unable to access LDAP secrets directory",
+			zap.String("directory", secretsDir),
+			zap.Error(err))
+		return nil, fmt.Errorf("unable to access LDAP secrets directory under %s", secretsDir)
+	}
+
+	secretCount := 0
+	validSecrets := make(map[string]interface{})
+
+	// Iterate over all secret files in the secrets directory
+	for _, file := range files {
+		fileName := file.Name()
+
+		// Skip non-secret entries such as Kubernetes internal metadata (e.g., symlinks like "..data" or directories like "..timestamp")
+		if strings.HasPrefix(fileName, "..") || file.IsDir() {
+			log.Log(log.Security).Info("Ignoring non-secret entry (Kubernetes metadata entry or directory)",
+				zap.String("name", fileName))
+			continue
+		}
+
+		secretKey := fileName
+		secretValueBytes, err := os.ReadFile(filepath.Join(secretsDir, secretKey))
+		if err != nil {
+			log.Log(log.Security).Warn("Could not read secret file",
+				zap.String("file", secretKey),
+				zap.Error(err))
+			continue
+		}
+		secretValue := strings.TrimSpace(string(secretValueBytes))
+
+		// Validate the secret value
+		validatedValue, err := ValidateSecretValue(secretKey, secretValue)
+		if err != nil {
+			log.Log(log.Security).Warn("Invalid LDAP secret value",
+				zap.String("key", secretKey),
+				zap.Error(err))
+			continue
+		}
+
+		// Store the validated value
+		validSecrets[secretKey] = validatedValue
+		secretCount++
+
+		log.Log(log.Security).Debug("Loaded LDAP secret",
+			zap.String("key", secretKey))
+	}
+
+	ldapConf := getDefaultLdapConfig()
+
+	// Apply validated values to the configuration
+	if host, ok := validSecrets[common.LdapHost].(string); ok {
+		ldapConf.Host = host
+	}
+	if port, ok := validSecrets[common.LdapPort].(int); ok {
+		ldapConf.Port = port
+	}
+	if baseDN, ok := validSecrets[common.LdapBaseDN].(string); ok {
+		ldapConf.BaseDN = baseDN
+	}
+	if filter, ok := validSecrets[common.LdapFilter].(string); ok {
+		ldapConf.Filter = filter
+	}
+	if groupAttr, ok := validSecrets[common.LdapGroupAttr].(string); ok {
+		ldapConf.GroupAttr = groupAttr
+	}
+	if returnAttr, ok := validSecrets[common.LdapReturnAttr].([]string); ok {
+		ldapConf.ReturnAttr = returnAttr
+	}
+	if bindUser, ok := validSecrets[common.LdapBindUser].(string); ok {
+		ldapConf.BindUser = bindUser
+	}
+	if bindPassword, ok := validSecrets[common.LdapBindPassword].(string); ok {
+		ldapConf.BindPassword = bindPassword
+	}
+	if insecure, ok := validSecrets[common.LdapInsecure].(bool); ok {
+		ldapConf.Insecure = insecure
+	}
+	if ssl, ok := validSecrets[common.LdapSSL].(bool); ok {
+		ldapConf.useSsl = ssl
+	}
+
+	// Validate the entire configuration
+	validator := NewLdapValidator()
+	isValid := validator.ValidateConfig(ldapConf)
+
+	// Check if all required fields were provided in the secrets
+	requiredFields := []string{
+		common.LdapHost,
+		common.LdapPort,
+		common.LdapBaseDN,
+		common.LdapFilter,
+		common.LdapGroupAttr,
+		common.LdapReturnAttr,
+		common.LdapBindUser,
+		common.LdapBindPassword,
+	}
+
+	var missingFields []string
+	for _, field := range requiredFields {
+		if _, ok := validSecrets[field]; !ok {
+			missingFields = append(missingFields, field)
+		}
+	}
+
+	if len(missingFields) > 0 {
+		log.Log(log.Security).Error("Missing required LDAP configuration fields",
+			zap.Strings("missingFields", missingFields))
+		isValid = false
+	}
+
+	log.Log(log.Security).Info("Finished loading LDAP secrets",
+		zap.Int("numberOfSecretsLoaded", secretCount),
+		zap.Bool("configurationValid", isValid),
+		zap.Int("missingRequiredFields", len(missingFields)))
+
+	if secretCount == 0 || !isValid || len(missingFields) != 0 {
+		return ldapConf, fmt.Errorf("unable to properly load LDAP configuration")
+	}
+
+	return ldapConf, nil
+}
+
+func GetConfigReader() ConfigReader {
+	return configReaderImpl{}
+}
+
+func getDefaultLdapConfig() *LdapConfig {
+	return &LdapConfig{
+		Host:         common.DefaultLdapHost,
+		Port:         common.DefaultLdapPort,
+		BaseDN:       common.DefaultLdapBaseDN,
+		Filter:       common.DefaultLdapFilter,
+		GroupAttr:    common.DefaultLdapGroupAttr,
+		ReturnAttr:   common.DefaultLdapReturnAttr,
+		BindUser:     common.DefaultLdapBindUser,
+		BindPassword: common.DefaultLdapBindPassword,
+		Insecure:     common.DefaultLdapInsecure,
+		useSsl:       common.DefaultLdapSSL,
+	}
+}
+
+// ldapAccessImpl implements the LdapAccess interface with real LDAP operations
+type ldapAccessImpl struct{}
+
+func (ldapAccessImpl) DialURL(url string, options ...ldap.DialOpt) (*ldap.Conn, error) {
+	return ldap.DialURL(url, options...)
+}
+
+func (ldapAccessImpl) Bind(conn *ldap.Conn, username, password string) error {
+	return conn.Bind(username, password)
+}
+
+func (ldapAccessImpl) Search(conn *ldap.Conn, searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) {
+	return conn.Search(searchRequest)
+}
+
+func (ldapAccessImpl) Close(conn *ldap.Conn) {
+	_ = conn.Close()
+}
+
+func GetLdapAccess() LdapAccess {
+	return ldapAccessImpl{}
+}
+
+// LDAPResolverConfig holds the configuration for the LDAP resolver
+type LdapConfig struct {
+	Host         string
+	Port         int
+	BaseDN       string
+	Filter       string
+	GroupAttr    string
+	ReturnAttr   []string
+	BindUser     string
+	BindPassword string
+	Insecure     bool
+	useSsl       bool
+}
+
+func GetUserGroupCacheLdap(reader ConfigReader, access LdapAccess) *UserGroupCache {
+	config, err := reader.ReadLdapConfig()
+	if err != nil {
+		// Log a FATAL level message - this is very prominent and will typically cause the application to exit
+		log.Log(log.Security).Fatal("LDAP configuration not found or invalid. No secrets were loaded from the secrets directory.",
+			zap.String("secretsPath", common.LdapMountPath),
+			zap.String("resolution", "Ensure LDAP secrets are properly mounted and accessible"))
+
+		// If the Fatal log doesn't cause an exit (depends on logger configuration),
+		// we could also panic here to ensure the application stops
+		panic("LDAP configuration not found or invalid")
+	}
+
+	ldapLookup := &LdapLookup{
+		config: *config,
+		access: access,
+	}
+
+	return &UserGroupCache{
+		ugs:           map[string]*UserGroup{},
+		interval:      cleanerInterval * time.Second,
+		lookup:        ldapLookup.LdapLookupUser,
+		lookupGroupID: ldapLookup.LdapLookupGroupID,
+		groupIds:      ldapLookup.LDAPLookupGroupIds,
+		stop:          make(chan struct{}),
+	}
+}
+
+// Default linux behaviour: a user is member of the primary group with the same name
+func (LdapLookup) LdapLookupUser(userName string) (*user.User, error) {
+	log.Log(log.Security).Debug("Performing LDAP user lookup",
+		zap.String("username", userName),
+		zap.String("defaultUID", common.DefaultLdapUserUID))
+	return &user.User{
+		Uid:      common.DefaultLdapUserUID,
+		Gid:      userName,
+		Username: userName,
+	}, nil
+}
+
+func (LdapLookup) LdapLookupGroupID(gid string) (*user.Group, error) {
+	log.Log(log.Security).Debug("Looking up LDAP group ID",
+		zap.String("groupID", gid))
+	group := user.Group{Gid: gid}
+	group.Name = gid
+	return &group, nil
+}
+
+func (lu LdapLookup) LDAPLookupGroupIds(osUser *user.User) ([]string, error) {
+	sr, err := ldapSearch(lu.access, lu.config, osUser.Username)
+	if err != nil {
+		log.Log(log.Security).Error("Failed to connect to LDAP for group lookup",
+			zap.String("user", osUser.Username),
+			zap.Error(err))
+		return nil, err
+	}
+
+	var groups []string
+	for _, entry := range sr.Entries {
+		attr := entry.GetAttributeValues("memberOf")
+		log.Log(log.Security).Debug("LDAP 'memberOf' attributes for user",
+			zap.String("user", osUser.Username),
+			zap.Strings("attributes", attr))
+		for i := range attr {
+			s := strings.Split(attr[i], ",")
+			newgroup := strings.Split(s[0], "CN=")
+			groups = append(groups, newgroup[1])
+		}
+	}
+	return groups, nil
+}
+
+// ldapSearch performs an LDAP search for the specified username
+// This replaces the old LDAPConn_Bind function with a more testable approach
+func ldapSearch(ldapAccess LdapAccess, ldapConf LdapConfig, userName string) (*ldap.SearchResult, error) {
+	var ldapUri string
+	if ldapConf.useSsl {
+		ldapUri = "ldaps"
+	} else {
+		ldapUri = "ldap"
+	}
+
+	ldapaddr := fmt.Sprintf("%s://%s:%d", ldapUri, ldapConf.Host, ldapConf.Port)
+	log.Log(log.Security).Debug("Attempting LDAP connection",
+		zap.String("address", ldapaddr),
+		zap.Bool("ssl", ldapConf.useSsl),
+		zap.Bool("insecureSkipVerify", ldapConf.Insecure))
+
+	l, err := ldapAccess.DialURL(ldapaddr,
+		ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: ldapConf.Insecure})) // #nosec G402
+	if err != nil {
+		log.Log(log.Security).Error("Error connecting to LDAP server",
+			zap.String("address", ldapaddr),
+			zap.Error(err))
+		return nil, err
+	}
+	defer ldapAccess.Close(l)
+
+	log.Log(log.Security).Debug("LDAP connection successful, attempting bind",
+		zap.String("bindUser", ldapConf.BindUser))
+	err = ldapAccess.Bind(l, ldapConf.BindUser, ldapConf.BindPassword)
+	if err != nil {
+		log.Log(log.Security).Error("Failed to bind with LDAP server",
+			zap.String("bindDN", ldapConf.BindUser),
+			zap.Error(err))
+		return nil, err
+	}
+
+	filter := fmt.Sprintf(ldapConf.Filter, userName)
+	log.Log(log.Security).Debug("Executing LDAP search",
+		zap.String("baseDN", ldapConf.BaseDN),
+		zap.String("filter", filter),
+		zap.Strings("attributesToReturn", ldapConf.ReturnAttr))
+
+	searchRequest := ldap.NewSearchRequest(
+		ldapConf.BaseDN,
+		ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
+		filter,
+		ldapConf.ReturnAttr,
+		nil,
+	)
+	sr, err := ldapAccess.Search(l, searchRequest)
+	if err != nil {
+		log.Log(log.Security).Error("Failed to execute LDAP search query",
+			zap.String("filter", filter),
+			zap.String("baseDN", ldapConf.BaseDN),
+			zap.Error(err))
+		return nil, err
+	}
+
+	log.Log(log.Security).Debug("LDAP search completed successfully",
+		zap.String("username", userName),
+		zap.Int("entriesFound", len(sr.Entries)))
+	return sr, nil
+}
diff --git a/pkg/common/security/usergroup_ldap_resolver_test.go b/pkg/common/security/usergroup_ldap_resolver_test.go
new file mode 100644
index 00000000..17f28cf8
--- /dev/null
+++ b/pkg/common/security/usergroup_ldap_resolver_test.go
@@ -0,0 +1,708 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package security
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"os/user"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/go-ldap/ldap/v3"
+	"gotest.tools/v3/assert"
+
+	"github.com/apache/yunikorn-core/pkg/common"
+)
+
+// Mock LDAP search result for testing
+func mockLdapSearchResult(username string) (*ldap.SearchResult, error) {
+	if username == Testuser1 || username == Testuser {
+		return &ldap.SearchResult{
+			Entries: []*ldap.Entry{
+				{
+					Attributes: []*ldap.EntryAttribute{
+						{
+							Name:   "memberOf",
+							Values: []string{"CN=group1001,OU=groups,DC=example,DC=com"},
+						},
+					},
+				},
+			},
+		}, nil
+	}
+	if username == Testuser2 {
+		return &ldap.SearchResult{
+			Entries: []*ldap.Entry{
+				{
+					Attributes: []*ldap.EntryAttribute{
+						{
+							Name:   "memberOf",
+							Values: []string{"CN=group1001,OU=groups,DC=example,DC=com", "CN=group1002,OU=groups,DC=example,DC=com"},
+						},
+					},
+				},
+			},
+		}, nil
+	}
+	if username == Testuser3 {
+		return &ldap.SearchResult{
+			Entries: []*ldap.Entry{
+				{
+					Attributes: []*ldap.EntryAttribute{
+						{
+							Name:   "memberOf",
+							Values: []string{"CN=group1002,OU=groups,DC=example,DC=com", "CN=group1001,OU=groups,DC=example,DC=com", "CN=group1003,OU=groups,DC=example,DC=com", "CN=group1004,OU=groups,DC=example,DC=com"},
+						},
+					},
+				},
+			},
+		}, nil
+	}
+	if username == Testuser4 {
+		return &ldap.SearchResult{
+			Entries: []*ldap.Entry{
+				{
+					Attributes: []*ldap.EntryAttribute{
+						{
+							Name:   "memberOf",
+							Values: []string{"CN=group901,OU=groups,DC=example,DC=com", "CN=group902,OU=groups,DC=example,DC=com"},
+						},
+					},
+				},
+			},
+		}, nil
+	}
+	return nil, fmt.Errorf("ldap lookup failed for user: %s", username)
+}
+
+// LdapAccessMock implements the LdapAccess interface for testing
+type LdapAccessMock struct {
+	DialURLFunc  func(url string, options ...ldap.DialOpt) (*ldap.Conn, error)
+	BindFunc     func(conn *ldap.Conn, username, password string) error
+	SearchFunc   func(conn *ldap.Conn, searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error)
+	CloseFunc    func(conn *ldap.Conn)
+	SearchResult *ldap.SearchResult
+	Error        error
+}
+
+type ConfigReaderMock struct{}
+
+func (ConfigReaderMock) ReadLdapConfig() (*LdapConfig, error) {
+	return &LdapConfig{}, nil
+}
+
+func (m *LdapAccessMock) DialURL(url string, options ...ldap.DialOpt) (*ldap.Conn, error) {
+	if m.DialURLFunc != nil {
+		return m.DialURLFunc(url, options...)
+	}
+	return &ldap.Conn{}, nil
+}
+
+func (m *LdapAccessMock) Bind(conn *ldap.Conn, username, password string) error {
+	if m.BindFunc != nil {
+		return m.BindFunc(conn, username, password)
+	}
+	return nil
+}
+
+func (m *LdapAccessMock) Search(conn *ldap.Conn, searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) {
+	if m.SearchFunc != nil {
+		return m.SearchFunc(conn, searchRequest)
+	}
+	return m.SearchResult, m.Error
+}
+
+func (m *LdapAccessMock) Close(conn *ldap.Conn) {
+	if m.CloseFunc != nil {
+		m.CloseFunc(conn)
+	}
+}
+
+// Helper function to create a mock LDAP access with predefined search results
+func newMockLdapAccess(searchResult *ldap.SearchResult, err error) *LdapAccessMock {
+	return &LdapAccessMock{
+		SearchResult: searchResult,
+		Error:        err,
+	}
+}
+
+// TestLdapSearch tests the new ldapSearch function with a mock LdapAccess
+func TestLdapSearch(t *testing.T) {
+	// Create a mock search result
+	mockResult := &ldap.SearchResult{
+		Entries: []*ldap.Entry{
+			{
+				Attributes: []*ldap.EntryAttribute{
+					{
+						Name:   "memberOf",
+						Values: []string{"CN=group1,OU=groups,DC=example,DC=com", "CN=group2,OU=groups,DC=example,DC=com"},
+					},
+				},
+			},
+		},
+	}
+
+	// Create a mock LDAP access with the mock result
+	mockAccess := newMockLdapAccess(mockResult, nil)
+	savedUrl := ""
+	mockAccess.DialURLFunc = func(url string, _ ...ldap.DialOpt) (*ldap.Conn, error) {
+		savedUrl = url
+		return &ldap.Conn{}, nil
+	}
+
+	// Call ldapSearch with the mock access
+	ldapConf := LdapConfig{
+		Host: "testhost",
+		Port: 1234,
+	}
+	result, err := ldapSearch(mockAccess, ldapConf, "testuser")
+
+	// Verify results
+	assert.NilError(t, err)
+	assert.Assert(t, result != nil)
+	assert.Equal(t, 1, len(result.Entries))
+	assert.Equal(t, 1, len(result.Entries[0].Attributes))
+	assert.Equal(t, "memberOf", result.Entries[0].Attributes[0].Name)
+	assert.Equal(t, 2, len(result.Entries[0].Attributes[0].Values))
+	assert.Equal(t, "CN=group1,OU=groups,DC=example,DC=com", result.Entries[0].Attributes[0].Values[0])
+	assert.Equal(t, "CN=group2,OU=groups,DC=example,DC=com", result.Entries[0].Attributes[0].Values[1])
+	assert.Equal(t, "ldap://testhost:1234", savedUrl)
+
+	// check useSsl
+	ldapConf = LdapConfig{
+		Host:   "testhost",
+		Port:   1234,
+		useSsl: true,
+	}
+	_, err = ldapSearch(mockAccess, ldapConf, "testuser")
+	assert.NilError(t, err)
+	assert.Equal(t, "ldaps://testhost:1234", savedUrl)
+}
+
+// TestLdapSearchError tests the error handling in ldapSearch
+func TestLdapSearchError(t *testing.T) {
+	// Test cases for different error scenarios
+	testCases := []struct {
+		name        string
+		dialError   error
+		bindError   error
+		searchError error
+	}{
+		{
+			name:        "Dial Error",
+			dialError:   errors.New("dial error"),
+			bindError:   nil,
+			searchError: nil,
+		},
+		{
+			name:        "Bind Error",
+			dialError:   nil,
+			bindError:   errors.New("bind error"),
+			searchError: nil,
+		},
+		{
+			name:        "Search Error",
+			dialError:   nil,
+			bindError:   nil,
+			searchError: errors.New("search error"),
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			// Create a mock LDAP access with the appropriate error
+			mockAccess := &LdapAccessMock{
+				DialURLFunc: func(url string, options ...ldap.DialOpt) (*ldap.Conn, error) {
+					return &ldap.Conn{}, tc.dialError
+				},
+				BindFunc: func(conn *ldap.Conn, username, password string) error {
+					return tc.bindError
+				},
+				SearchFunc: func(conn *ldap.Conn, searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) {
+					return nil, tc.searchError
+				},
+			}
+
+			// Call ldapSearch with the mock access
+			result, err := ldapSearch(mockAccess, LdapConfig{}, "testuser")
+
+			// Verify error
+			assert.Assert(t, err != nil)
+			assert.Assert(t, result == nil)
+
+			// Check for specific error
+			switch {
+			case tc.dialError != nil:
+				assert.Equal(t, tc.dialError.Error(), err.Error())
+			case tc.bindError != nil:
+				assert.Equal(t, tc.bindError.Error(), err.Error())
+			case tc.searchError != nil:
+				assert.Equal(t, tc.searchError.Error(), err.Error())
+			}
+		})
+	}
+}
+
+func TestLdapLookups(t *testing.T) {
+	tests := []struct {
+		name     string
+		testType string
+		id       string
+		validate func(t *testing.T, result interface{}, err error)
+	}{
+		{
+			name:     "Lookup user",
+			testType: "user",
+			id:       "testuser",
+			validate: func(t *testing.T, result interface{}, err error) {
+				assert.NilError(t, err)
+				u, ok := result.(*user.User)
+				assert.Assert(t, ok, "invalid result type")
+				assert.Equal(t, "testuser", u.Username)
+				assert.Equal(t, "testuser", u.Gid)
+				assert.Equal(t, "1211", u.Uid)
+			},
+		},
+		{
+			name:     "Lookup group",
+			testType: "group",
+			id:       "testgroup",
+			validate: func(t *testing.T, result interface{}, err error) {
+				assert.NilError(t, err)
+				g, ok := result.(*user.Group)
+				assert.Assert(t, ok, "invalid result type")
+				assert.Equal(t, "testgroup", g.Gid)
+				assert.Equal(t, "testgroup", g.Name)
+			},
+		},
+	}
+
+	lu := &LdapLookup{}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			switch tt.testType {
+			case "user":
+				u, err := lu.LdapLookupUser(tt.id)
+				tt.validate(t, u, err)
+			case "group":
+				g, err := lu.LdapLookupGroupID(tt.id)
+				tt.validate(t, g, err)
+			}
+		})
+	}
+}
+
+func TestLDAPLookupGroupIds(t *testing.T) {
+	// Create a mock search result
+	mockResult := &ldap.SearchResult{
+		Entries: []*ldap.Entry{
+			{
+				Attributes: []*ldap.EntryAttribute{
+					{
+						Name:   "memberOf",
+						Values: []string{"CN=group1,OU=groups,DC=example,DC=com", "CN=group2,OU=groups,DC=example,DC=com"},
+					},
+				},
+			},
+		},
+	}
+
+	u := &user.User{Username: "testuser"}
+	lu := &LdapLookup{
+		access: newMockLdapAccess(mockResult, nil),
+		config: LdapConfig{},
+	}
+
+	groups, err := lu.LDAPLookupGroupIds(u)
+	assert.NilError(t, err)
+	assert.Assert(t, strings.Contains(strings.Join(groups, ","), "group1"))
+	assert.Assert(t, strings.Contains(strings.Join(groups, ","), "group2"))
+}
+
+func TestLDAPLookupGroupIdsError(t *testing.T) {
+	u := &user.User{Username: "testuser"}
+	lu := &LdapLookup{
+		access: newMockLdapAccess(nil, errors.New("ldap error")),
+		config: LdapConfig{},
+	}
+	groups, err := lu.LDAPLookupGroupIds(u)
+	assert.Error(t, err, "ldap error")
+	assert.Assert(t, groups == nil)
+}
+
+//nolint:funlen // Table-driven test for coverage, helpers used to reduce length
+func TestReadSecrets(t *testing.T) {
+	tests := getReadSecretsTestCases()
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			_, cleanup := tt.setupFunc(t)
+			defer cleanup()
+			reader := &configReaderImpl{}
+			ldapConf, err := reader.ReadLdapConfig()
+			assert.Equal(t, tt.expectedResult, err == nil)
+			if tt.nilConf && ldapConf == nil {
+				return
+			}
+			tt.validateFunc(t, ldapConf)
+		})
+	}
+}
+
+//nolint:funlen // Table-driven test helper for coverage, intentionally long
+func getReadSecretsTestCases() []struct {
+	name           string
+	setupFunc      func(t *testing.T) (string, func())
+	expectedResult bool
+	nilConf        bool
+	validateFunc   func(t *testing.T, conf *LdapConfig)
+} {
+	return []struct {
+		name           string
+		setupFunc      func(t *testing.T) (string, func())
+		expectedResult bool
+		nilConf        bool
+		validateFunc   func(t *testing.T, conf *LdapConfig)
+	}{
+		{
+			name: "Skips K8s metadata and directories",
+			setupFunc: func(t *testing.T) (string, func()) {
+				tmpDir := t.TempDir()
+				err := os.Mkdir(filepath.Join(tmpDir, "..data"), 0755)
+				assert.NilError(t, err)
+				err = os.Mkdir(filepath.Join(tmpDir, "dir1"), 0755)
+				assert.NilError(t, err)
+				err = os.WriteFile(filepath.Join(tmpDir, "key1"), []byte("value1"), 0600)
+				assert.NilError(t, err)
+				err = os.WriteFile(filepath.Join(tmpDir, "..timestamp"), []byte("meta"), 0600)
+				assert.NilError(t, err)
+				origLdapMountPath := common.LdapMountPath
+				common.LdapMountPath = tmpDir
+				return tmpDir, func() { common.LdapMountPath = origLdapMountPath }
+			},
+			expectedResult: false,
+			validateFunc: func(t *testing.T, ldapConf *LdapConfig) {
+				assert.Equal(t, common.DefaultLdapHost, ldapConf.Host)
+				assert.Equal(t, common.DefaultLdapPort, ldapConf.Port)
+				assert.Equal(t, common.DefaultLdapBaseDN, ldapConf.BaseDN)
+				assert.Equal(t, common.DefaultLdapFilter, ldapConf.Filter)
+				assert.Equal(t, common.DefaultLdapGroupAttr, ldapConf.GroupAttr)
+				assert.Equal(t, strings.Join(common.DefaultLdapReturnAttr, ","), strings.Join(ldapConf.ReturnAttr, ","))
+				assert.Equal(t, common.DefaultLdapBindUser, ldapConf.BindUser)
+				assert.Equal(t, common.DefaultLdapBindPassword, ldapConf.BindPassword)
+				assert.Equal(t, common.DefaultLdapInsecure, ldapConf.Insecure)
+				assert.Equal(t, common.DefaultLdapSSL, ldapConf.useSsl)
+			},
+		},
+		{
+			name: "Handles missing secrets directory",
+			setupFunc: func(t *testing.T) (string, func()) {
+				origLdapMountPath := common.LdapMountPath
+				common.LdapMountPath = "/nonexistent"
+				return "/nonexistent", func() { common.LdapMountPath = origLdapMountPath }
+			},
+			expectedResult: false,
+			nilConf:        true,
+			validateFunc:   func(t *testing.T, ldapConf *LdapConfig) {},
+		},
+		{
+			name: "Handles unknown key",
+			setupFunc: func(t *testing.T) (string, func()) {
+				tmpDir := t.TempDir()
+				err := os.WriteFile(filepath.Join(tmpDir, "unknownKey"), []byte("somevalue"), 0600)
+				assert.NilError(t, err)
+				origLdapMountPath := common.LdapMountPath
+				common.LdapMountPath = tmpDir
+				return tmpDir, func() { common.LdapMountPath = origLdapMountPath }
+			},
+			expectedResult: false,
+			validateFunc: func(t *testing.T, ldapConf *LdapConfig) {
+				assert.Equal(t, common.DefaultLdapHost, ldapConf.Host)
+				assert.Equal(t, common.DefaultLdapPort, ldapConf.Port)
+				assert.Equal(t, common.DefaultLdapBaseDN, ldapConf.BaseDN)
+				assert.Equal(t, common.DefaultLdapFilter, ldapConf.Filter)
+				assert.Equal(t, common.DefaultLdapGroupAttr, ldapConf.GroupAttr)
+				assert.Equal(t, strings.Join(common.DefaultLdapReturnAttr, ","), strings.Join(ldapConf.ReturnAttr, ","))
+				assert.Equal(t, common.DefaultLdapBindUser, ldapConf.BindUser)
+				assert.Equal(t, common.DefaultLdapBindPassword, ldapConf.BindPassword)
+				assert.Equal(t, common.DefaultLdapInsecure, ldapConf.Insecure)
+				assert.Equal(t, common.DefaultLdapSSL, ldapConf.useSsl)
+			},
+		},
+		{
+			name: "Handles invalid port and bool values",
+			setupFunc: func(t *testing.T) (string, func()) {
+				tmpDir := t.TempDir()
+				err := os.WriteFile(filepath.Join(tmpDir, common.LdapPort), []byte("notanint"), 0600)
+				assert.NilError(t, err)
+				err = os.WriteFile(filepath.Join(tmpDir, common.LdapInsecure), []byte("notabool"), 0600)
+				assert.NilError(t, err)
+				err = os.WriteFile(filepath.Join(tmpDir, common.LdapSSL), []byte("notabool"), 0600)
+				assert.NilError(t, err)
+				origLdapMountPath := common.LdapMountPath
+				common.LdapMountPath = tmpDir
+				return tmpDir, func() { common.LdapMountPath = origLdapMountPath }
+			},
+			expectedResult: false,
+			validateFunc: func(t *testing.T, ldapConf *LdapConfig) {
+				// Assert that ldapConf.Port is set to DefaultLdapPort when invalid int value is provided
+				assert.Equal(t, common.DefaultLdapPort, ldapConf.Port)
+
+				// Assert that rest of ldap conf is set to default values
+				assert.Equal(t, common.DefaultLdapHost, ldapConf.Host)
+				assert.Equal(t, common.DefaultLdapBaseDN, ldapConf.BaseDN)
+				assert.Equal(t, common.DefaultLdapFilter, ldapConf.Filter)
+				assert.Equal(t, common.DefaultLdapGroupAttr, ldapConf.GroupAttr)
+				assert.Equal(t, strings.Join(common.DefaultLdapReturnAttr, ","), strings.Join(ldapConf.ReturnAttr, ","))
+				assert.Equal(t, common.DefaultLdapBindUser, ldapConf.BindUser)
+				assert.Equal(t, common.DefaultLdapBindPassword, ldapConf.BindPassword)
+				assert.Equal(t, common.DefaultLdapInsecure, ldapConf.Insecure)
+				assert.Equal(t, common.DefaultLdapSSL, ldapConf.useSsl)
+			},
+		},
+		{
+			name: "Sets custom values",
+			setupFunc: func(t *testing.T) (string, func()) {
+				tmpDir := t.TempDir()
+				err := os.WriteFile(filepath.Join(tmpDir, common.LdapHost), []byte("myhost"), 0600)
+				assert.NilError(t, err)
+				err = os.WriteFile(filepath.Join(tmpDir, common.LdapPort), []byte("1234"), 0600)
+				assert.NilError(t, err)
+				err = os.WriteFile(filepath.Join(tmpDir, common.LdapBaseDN), []byte("dc=test,dc=com"), 0600)
+				assert.NilError(t, err)
+				err = os.WriteFile(filepath.Join(tmpDir, common.LdapFilter), []byte("(&(uid=%s))"), 0600)
+				assert.NilError(t, err)
+				err = os.WriteFile(filepath.Join(tmpDir, common.LdapGroupAttr), []byte("groups"), 0600)
+				assert.NilError(t, err)
+				err = os.WriteFile(filepath.Join(tmpDir, common.LdapReturnAttr), []byte("memberOf,groups"), 0600)
+				assert.NilError(t, err)
+				err = os.WriteFile(filepath.Join(tmpDir, common.LdapBindUser), []byte("binduser"), 0600)
+				assert.NilError(t, err)
+				err = os.WriteFile(filepath.Join(tmpDir, common.LdapBindPassword), []byte("bindpass"), 0600)
+				assert.NilError(t, err)
+				err = os.WriteFile(filepath.Join(tmpDir, common.LdapInsecure), []byte("true"), 0600)
+				assert.NilError(t, err)
+				err = os.WriteFile(filepath.Join(tmpDir, common.LdapSSL), []byte("true"), 0600)
+				assert.NilError(t, err)
+				origLdapMountPath := common.LdapMountPath
+				common.LdapMountPath = tmpDir
+				return tmpDir, func() { common.LdapMountPath = origLdapMountPath }
+			},
+			expectedResult: true,
+			validateFunc: func(t *testing.T, ldapConf *LdapConfig) {
+				assert.Equal(t, "myhost", ldapConf.Host)
+
+				// Use strconv to verify the port value to ensure the import is used
+				portStr := "1234"
+				expectedPort, err := strconv.Atoi(portStr)
+				assert.NilError(t, err, "failed to convert port string to int")
+				assert.Equal(t, expectedPort, ldapConf.Port)
+
+				assert.Equal(t, "dc=test,dc=com", ldapConf.BaseDN)
+				assert.Equal(t, "(&(uid=%s))", ldapConf.Filter)
+				assert.Equal(t, "groups", ldapConf.GroupAttr)
+				assert.Equal(t, "memberOf,groups", strings.Join(ldapConf.ReturnAttr, ","))
+				assert.Equal(t, "binduser", ldapConf.BindUser)
+				assert.Equal(t, "bindpass", ldapConf.BindPassword)
+
+				// Use strconv to verify boolean values
+				insecureStr := "true"
+				expectedInsecure, err := strconv.ParseBool(insecureStr)
+				assert.NilError(t, err, "failed to convert insecure string to bool")
+				assert.Equal(t, expectedInsecure, ldapConf.Insecure)
+
+				sslStr := "true"
+				expectedSSL, err := strconv.ParseBool(sslStr)
+				assert.NilError(t, err, "failed to convert ssl string to bool")
+				assert.Equal(t, expectedSSL, ldapConf.useSsl)
+			},
+		},
+		{
+			name: "Missing required fields",
+			setupFunc: func(t *testing.T) (string, func()) {
+				tmpDir := t.TempDir()
+				err := os.WriteFile(filepath.Join(tmpDir, common.LdapHost), []byte("ldap.example.com"), 0600)
+				assert.NilError(t, err)
+				err = os.WriteFile(filepath.Join(tmpDir, common.LdapPort), []byte("389"), 0600)
+				assert.NilError(t, err)
+				// Missing BaseDN, Filter, GroupAttr, ReturnAttr, BindUser, BindPassword
+				origLdapMountPath := common.LdapMountPath
+				common.LdapMountPath = tmpDir
+				return tmpDir, func() { common.LdapMountPath = origLdapMountPath }
+			},
+			expectedResult: false,
+			validateFunc: func(_ *testing.T, _ *LdapConfig) {
+				// No specific validation needed - we're testing the return value
+			},
+		},
+		{
+			name: "All required fields present",
+			setupFunc: func(t *testing.T) (string, func()) {
+				tmpDir := t.TempDir()
+				requiredFields := map[string]string{
+					common.LdapHost:         "ldap.example.com",
+					common.LdapPort:         "389",
+					common.LdapBaseDN:       "dc=example,dc=com",
+					common.LdapFilter:       "(&(objectClass=user)(sAMAccountName=%s))",
+					common.LdapGroupAttr:    "memberOf",
+					common.LdapReturnAttr:   "memberOf",
+					common.LdapBindUser:     "cn=admin,dc=example,dc=com",
+					common.LdapBindPassword: "password",
+				}
+
+				for key, value := range requiredFields {
+					err := os.WriteFile(filepath.Join(tmpDir, key), []byte(value), 0600)
+					if err != nil {
+						t.Fatalf("failed to write file %s: %v", key, err)
+					}
+				}
+
+				origLdapMountPath := common.LdapMountPath
+				common.LdapMountPath = tmpDir
+				return tmpDir, func() { common.LdapMountPath = origLdapMountPath }
+			},
+			expectedResult: true,
+			validateFunc: func(_ *testing.T, _ *LdapConfig) {
+				// No specific validation needed - we're testing the return value
+			},
+		},
+	}
+}
+
+func TestUserGroupCacheLdap(t *testing.T) {
+	tests := []struct {
+		name         string
+		validateFunc func(t *testing.T, cache *UserGroupCache)
+	}{
+		{
+			name: "Cache initialization",
+			validateFunc: func(t *testing.T, cache *UserGroupCache) {
+				assert.Assert(t, cache != nil)
+				assert.Assert(t, cache.ugs != nil)
+				assert.Assert(t, cache.lookup != nil)
+				assert.Assert(t, cache.lookupGroupID != nil)
+				assert.Assert(t, cache.groupIds != nil)
+			},
+		},
+		{
+			name: "Cache interval",
+			validateFunc: func(t *testing.T, cache *UserGroupCache) {
+				interval := cache.interval
+				expectedInterval := cleanerInterval * time.Second // 60 seconds
+				assert.Equal(t, expectedInterval, interval, "LDAP resolver interval should be 60 seconds")
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Get the LDAP user group cache
+			cache := GetUserGroupCacheLdap(&ConfigReaderMock{}, newMockLdapAccess(nil, nil))
+			// Run the validation function
+			tt.validateFunc(t, cache)
+		})
+	}
+}
+
+func TestMockLdapSearchResult(t *testing.T) {
+	// Test valid users
+	testCases := []struct {
+		username      string
+		expectedCount int
+		expectError   bool
+	}{
+		{"testuser1", 1, false},
+		{"testuser", 1, false},
+		{"testuser2", 2, false},
+		{"testuser3", 4, false},
+		{"testuser4", 2, false},
+		{"unknown", 0, true},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.username, func(t *testing.T) {
+			result, err := mockLdapSearchResult(tc.username)
+
+			if tc.expectError {
+				assert.Assert(t, err != nil, "Expected error for user %s but got none", tc.username)
+				assert.Assert(t, result == nil, "Expected nil result for user %s but got %v", tc.username, result)
+				assert.ErrorContains(t, err, "ldap lookup failed for user: "+tc.username)
+			} else {
+				assert.NilError(t, err, "Unexpected error for user %s: %v", tc.username, err)
+				assert.Assert(t, result != nil, "Expected non-nil result for user %s", tc.username)
+				assert.Assert(t, len(result.Entries) > 0, "Expected entries for user %s", tc.username)
+				assert.Assert(t, len(result.Entries[0].Attributes) > 0, "Expected attributes for user %s", tc.username)
+
+				memberOfAttr := result.Entries[0].Attributes[0]
+				assert.Equal(t, "memberOf", memberOfAttr.Name, "Expected 'memberOf' attribute for user %s", tc.username)
+				assert.Equal(t, tc.expectedCount, len(memberOfAttr.Values),
+					"Expected %d group values for user %s but got %d",
+					tc.expectedCount, tc.username, len(memberOfAttr.Values))
+			}
+		})
+	}
+}
+
+func TestLdapAccessImpl(t *testing.T) {
+	// Create a mock LDAP access implementation
+	mockAccess := &LdapAccessMock{
+		DialURLFunc: func(url string, options ...ldap.DialOpt) (*ldap.Conn, error) {
+			return &ldap.Conn{}, nil
+		},
+		BindFunc: func(conn *ldap.Conn, username, password string) error {
+			return nil
+		},
+		SearchFunc: func(conn *ldap.Conn, searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) {
+			return &ldap.SearchResult{}, nil
+		},
+		CloseFunc: func(conn *ldap.Conn) {},
+	}
+
+	assert.Assert(t, mockAccess != nil)
+	conn, err := mockAccess.DialURL("testurl")
+	assert.NilError(t, err)
+	assert.Assert(t, conn != nil)
+	assert.NilError(t, mockAccess.Bind(&ldap.Conn{}, "user", "pass"))
+	result, err := mockAccess.Search(&ldap.Conn{}, &ldap.SearchRequest{})
+	assert.NilError(t, err)
+	assert.Assert(t, result != nil)
+	mockAccess.Close(&ldap.Conn{})
+}
+
+// TestLdapAccessImplMethods tests the ldapAccessImpl methods
+func TestLdapAccessImplMethods(t *testing.T) {
+	// Create a real implementation
+	impl := &ldapAccessImpl{}
+
+	// We can't actually connect to an LDAP server in unit tests,
+	// but we can verify the methods don't panic when called with nil
+
+	// Test DialURL - should return error with invalid URL
+	conn, err := impl.DialURL("invalid://url")
+	assert.Assert(t, err != nil)
+	assert.Assert(t, conn == nil)
+
+	// Other methods would panic if called with nil, so we can't test them directly
+	// In a real scenario, we'd use a mock LDAP server or dependency injection
+}
diff --git a/pkg/common/security/usergroup_no_resolver_test.go b/pkg/common/security/usergroup_no_resolver_test.go
new file mode 100644
index 00000000..1e78eb07
--- /dev/null
+++ b/pkg/common/security/usergroup_no_resolver_test.go
@@ -0,0 +1,100 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package security
+
+import (
+	"os/user"
+	"testing"
+
+	"gotest.tools/v3/assert"
+)
+
+func TestNoLookupUser(t *testing.T) {
+	// Test with various usernames
+	testCases := []struct {
+		name     string
+		username string
+	}{
+		{"Empty username", ""},
+		{"Standard username", "testuser"},
+		{"Username with special chars", "test-user_123"},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			u, err := noLookupUser(tc.username)
+
+			// Should never return error
+			assert.NilError(t, err)
+
+			// Verify user properties
+			assert.Equal(t, tc.username, u.Username)
+			assert.Equal(t, "-1", u.Uid)
+			assert.Equal(t, tc.username, u.Gid)
+		})
+	}
+}
+
+func TestNoLookupGroupID(t *testing.T) {
+	// Test with various group IDs
+	testCases := []struct {
+		name string
+		gid  string
+	}{
+		{"Empty GID", ""},
+		{"Numeric GID", "1000"},
+		{"String GID", "users"},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			g, err := noLookupGroupID(tc.gid)
+
+			// Should never return error
+			assert.NilError(t, err)
+
+			// Verify group properties
+			assert.Equal(t, tc.gid, g.Gid)
+			assert.Equal(t, tc.gid, g.Name)
+		})
+	}
+}
+
+func TestNoLookupGroupIds(t *testing.T) {
+	// Test with various users
+	testCases := []struct {
+		name string
+		user *user.User
+	}{
+		{"Standard user", &user.User{Username: "testuser", Uid: "1000", Gid: "1000"}},
+		{"Empty user", &user.User{}},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			groups, err := noLookupGroupIds(tc.user)
+
+			// Should never return error
+			assert.NilError(t, err)
+
+			// Should always return empty slice
+			assert.Equal(t, 0, len(groups))
+		})
+	}
+}
diff --git a/pkg/common/constants.go b/pkg/common/security/usergroup_os_resolver_test.go
similarity index 55%
copy from pkg/common/constants.go
copy to pkg/common/security/usergroup_os_resolver_test.go
index 7eae1c79..acd7d5ab 100644
--- a/pkg/common/constants.go
+++ b/pkg/common/security/usergroup_os_resolver_test.go
@@ -16,17 +16,29 @@
  limitations under the License.
 */
 
-package common
-
-const (
-	Empty = ""
-
-	Wildcard              = "*"
-	Separator             = ","
-	Space                 = " "
-	AnonymousUser         = "nobody"
-	AnonymousGroup        = "nogroup"
-	RecoveryQueue         = "@recovery@"
-	RecoveryQueueFull     = "root." + RecoveryQueue
-	DefaultPlacementQueue = "root.default"
+package security
+
+import (
+	"os/user"
+	"testing"
 )
+
+func TestWrappedGroupIds(t *testing.T) {
+	// Create a mock user
+	// Note: This test will behave differently depending on the system
+	// We'll just verify it doesn't panic and returns the expected type
+	u := &user.User{
+		Username: "testuser",
+		Uid:      "1000",
+		Gid:      "1000",
+	}
+
+	// Call the function - we can't predict the exact result
+	groups, err := wrappedGroupIds(u)
+
+	// Log the result for informational purposes
+	t.Logf("Groups: %v, Error: %v", groups, err)
+
+	// We can only verify the function doesn't panic
+	// The actual result depends on the OS and user configuration
+}
diff --git a/pkg/common/security/usergroup_test.go b/pkg/common/security/usergroup_test.go
index 6c0d9f45..2a96fab8 100644
--- a/pkg/common/security/usergroup_test.go
+++ b/pkg/common/security/usergroup_test.go
@@ -28,6 +28,7 @@ import (
 	"gotest.tools/v3/assert"
 
 	"github.com/apache/yunikorn-core/pkg/common"
+	"github.com/apache/yunikorn-core/pkg/common/configs"
 	"github.com/apache/yunikorn-scheduler-interface/lib/go/si"
 )
 
@@ -50,308 +51,494 @@ func (c *UserGroupCache) getUGmap() map[string]*UserGroup {
 	return c.ugs
 }
 
-func TestGetUserGroupCache(t *testing.T) {
-	// get the cache with the test resolver set
-	testCache := GetUserGroupCache("test")
-	assert.Assert(t, testCache != nil, "Cache create failed")
-	assert.Equal(t, 0, testCache.getUGsize(), "Cache is not empty: %v", testCache.getUGmap())
-
-	testCache.Stop()
-	assert.Assert(t, instance == nil, "instance should be nil")
-	assert.Assert(t, stopped.Load())
-
-	// get the cache with the os resolver set
-	testCache = GetUserGroupCache("os")
-	assert.Assert(t, testCache != nil, "Cache create failed")
-	assert.Equal(t, 0, testCache.getUGsize(), "Cache is not empty: %v", testCache.getUGmap())
-
-	testCache.Stop()
-	assert.Assert(t, instance == nil, "instance should be nil")
-	assert.Assert(t, stopped.Load())
-
-	// get the cache with the default resolver set
-	testCache = GetUserGroupCache("unknown")
-	assert.Assert(t, testCache != nil, "Cache create failed")
-	assert.Equal(t, 0, testCache.getUGsize(), "Cache is not empty: %v", testCache.getUGmap())
-
-	testCache.Stop()
-	assert.Assert(t, instance == nil, "instance should be nil")
-	assert.Assert(t, stopped.Load())
-
-	// test for re stop again
-	testCache.Stop()
-	assert.Assert(t, instance == nil, "instance should be nil")
-	assert.Assert(t, stopped.Load())
+// UserGroupResolver Config for the test
+var testResolver = configs.UserGroupResolver{
+	Type: "test",
 }
 
-func TestGetUserGroup(t *testing.T) {
-	testCache := GetUserGroupCache("test")
-	testCache.resetCache()
-	// test cache should be empty now
-	assert.Equal(t, 0, testCache.getUGsize(), "Cache is not empty: %v", testCache.getUGmap())
-	ugi := &si.UserGroupInformation{
-		User:   "testuser1",
-		Groups: nil,
-	}
-	ug, err := testCache.GetUserGroup(ugi.User)
-	assert.NilError(t, err, "Lookup should not have failed: testuser1")
-	if ug.failed {
-		t.Errorf("lookup failed which should not have: %t", ug.failed)
-	}
-	if len(testCache.ugs) != 1 {
-		t.Errorf("Cache not updated should have 1 entry %d", len(testCache.ugs))
-	}
-	// check returned info: primary and secondary groups etc
-	if ug.User != ugi.User || len(ug.Groups) != 2 || ug.resolved == 0 || ug.failed {
-		t.Errorf("User 'testuser1' not resolved correctly: %v", ug)
-	}
-	testCache.lock.Lock()
-	cachedUG := testCache.ugs[ugi.User]
-	if ug.resolved != cachedUG.resolved {
-		t.Errorf("User 'testuser1' not cached correctly resolution time differs: %d got %d", ug.resolved, cachedUG.resolved)
-	}
-	// click over the clock: if we do not get the cached version the new time will differ from the cache update
-	cachedUG.resolved -= 5
-	testCache.lock.Unlock()
-
-	ug, err = testCache.GetUserGroup(ugi.User)
-	if err != nil || ug.resolved != cachedUG.resolved {
-		t.Errorf("User 'testuser1' not returned from Cache, resolution time differs: %d got %d (err = %v)", ug.resolved, cachedUG.resolved, err)
-	}
+// UserGroupResolver Config for the os resolver
+var osResolver = configs.UserGroupResolver{
+	Type: "os",
 }
 
-func TestBrokenUserGroup(t *testing.T) {
-	testCache := GetUserGroupCache("test")
-	testCache.resetCache()
-	// test cache should be empty now
-	assert.Equal(t, 0, testCache.getUGsize(), "Cache is not empty: %v", testCache.getUGmap())
-
-	ug, err := testCache.GetUserGroup("testuser2")
-	if err != nil {
-		t.Error("Lookup should not have failed: testuser2")
-	}
+// UserGroupResolver Config for the unknown resolver
+var unknownResolver = configs.UserGroupResolver{
+	Type: "unknown",
+}
 
-	assert.Equal(t, 1, testCache.getUGsize(), "Cache not updated should have 1 entry %d", testCache.getUGmap())
-	// check returned info: 3 groups etc
-	if ug.User != "testuser2" || len(ug.Groups) != 3 || ug.resolved == 0 || ug.failed {
-		t.Errorf("User 'testuser2' not resolved correctly: %v", ug)
-	}
-	// first group should have failed resolution: just the ID expected
-	if ug.Groups[0] != "100" {
-		t.Errorf("User 'testuser2' primary group resolved while it should not: %v", ug)
-	}
+// UserGroupResolver Config for the LDAP resolver
+var ldapResolver = configs.UserGroupResolver{
+	Type: "ldap",
+}
 
-	ug, err = testCache.GetUserGroup("testuser3")
-	if err != nil {
-		t.Error("Lookup should not have failed: testuser3")
+func TestGetUserGroupCache(t *testing.T) {
+	testCases := []struct {
+		name     string
+		resolver configs.UserGroupResolver
+	}{
+		{
+			name:     "TestResolver",
+			resolver: testResolver,
+		},
+		{
+			name:     "OsResolver",
+			resolver: osResolver,
+		},
+		{
+			name:     "UnknownResolver",
+			resolver: unknownResolver,
+		},
+		{
+			name:     "LdapResolver",
+			resolver: ldapResolver,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			// Get the cache with the resolver set
+			testCache := GetUserGroupCache(tc.resolver, &ConfigReaderMock{}, &LdapAccessMock{})
+			assert.Assert(t, testCache != nil, "Cache create failed")
+			assert.Equal(t, 0, testCache.getUGsize(), "Cache is not empty: %v", testCache.getUGmap())
+
+			testCache.Stop()
+			assert.Assert(t, instance == nil, "instance should be nil")
+			assert.Assert(t, stopped.Load())
+
+			// Test for re-stop
+			testCache.Stop()
+			assert.Assert(t, instance == nil, "instance should be nil")
+			assert.Assert(t, stopped.Load())
+		})
 	}
-
-	assert.Equal(t, 2, testCache.getUGsize(), "Cache not updated should have 2 entries %d", len(testCache.ugs))
-	assert.Equal(t, 4, testCache.getUGGroupSize("testuser3"), "User 'testuser3' not resolved correctly: duplicate primary group not filtered %v", ug)
-
-	ug, err = testCache.GetUserGroup("unknown")
-	assert.ErrorContains(t, err, "lookup failed for user: unknown")
-
-	ug, err = testCache.GetUserGroup("testuser4")
-	assert.NilError(t, err)
-
-	ug, err = testCache.GetUserGroup("testuser5")
-	assert.ErrorContains(t, err, "lookup failed for user: testuser5")
-
-	ug, err = testCache.GetUserGroup("invalid-gid-user")
-	assert.ErrorContains(t, err, "lookup failed for user: invalid-gid-user")
-	exceptedGroup := []string{"1_001"}
-	assert.Assert(t, reflect.DeepEqual(ug.Groups, exceptedGroup), fmt.Errorf("group should be: %v, but got: %v", exceptedGroup, ug.Groups))
 }
 
-func TestGetUserGroupFail(t *testing.T) {
-	testCache := GetUserGroupCache("test")
-	testCache.resetCache()
-	// test cache should be empty now
-	assert.Equal(t, 0, testCache.getUGsize(), "Cache is not empty: %v", testCache.getUGmap())
-
-	// resolve an empty user
-	ug, err := testCache.GetUserGroup("")
-	if err == nil {
-		t.Error("Lookup should have failed: empty user")
-	}
-	// ug is empty everything should be nil..
-	if ug.User != "" || len(ug.Groups) != 0 || ug.resolved != 0 || ug.failed {
-		t.Errorf("UserGroup is not empty: %v", ug)
-	}
+// Tests for the LDAP resolver using the mock implementation
 
-	// resolve a non existing user
-	ugi := &si.UserGroupInformation{
-		User:   "unknown",
-		Groups: nil,
-	}
-	ug, err = testCache.GetUserGroup(ugi.User)
-	if err == nil {
-		t.Error("Lookup should have failed: unknown user")
-	}
-	// ug is partially filled and failed flag is set
-	if ug.User != ugi.User || len(ug.Groups) != 0 || !ug.failed {
-		t.Errorf("UserGroup is not empty: %v", ug)
+func TestGetUserGroup(t *testing.T) {
+	testCases := []struct {
+		name     string
+		resolver configs.UserGroupResolver
+	}{
+		{
+			name:     "TestResolver",
+			resolver: testResolver,
+		},
+		{
+			name:     "OsResolver",
+			resolver: osResolver,
+		},
+		{
+			name:     "UnknownResolver",
+			resolver: unknownResolver,
+		},
+		{
+			name:     "LdapResolver",
+			resolver: ldapResolver,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			testCache := GetUserGroupCache(tc.resolver, &ConfigReaderMock{}, &LdapAccessMock{})
+			testCache.resetCache()
+			// test cache should be empty now
+			assert.Equal(t, 0, testCache.getUGsize(), "Cache is not empty: %v", testCache.getUGmap())
+			ugi := &si.UserGroupInformation{
+				User:   "testuser1",
+				Groups: nil,
+			}
+			ug, err := testCache.GetUserGroup(ugi.User)
+			assert.NilError(t, err, "Lookup should not have failed: testuser1")
+			if ug.failed {
+				t.Errorf("lookup failed which should not have: %t", ug.failed)
+			}
+			if len(testCache.ugs) != 1 {
+				t.Errorf("Cache not updated should have 1 entry %d", len(testCache.ugs))
+			}
+			// check returned info: primary and secondary groups etc
+			if ug.User != ugi.User || len(ug.Groups) != 2 || ug.resolved == 0 || ug.failed {
+				t.Errorf("User 'testuser1' not resolved correctly: %v", ug)
+			}
+			testCache.lock.Lock()
+			cachedUG := testCache.ugs[ugi.User]
+			if ug.resolved != cachedUG.resolved {
+				t.Errorf("User 'testuser1' not cached correctly resolution time differs: %d got %d", ug.resolved, cachedUG.resolved)
+			}
+			// click over the clock: if we do not get the cached version the new time will differ from the cache update
+			cachedUG.resolved -= 5
+			testCache.lock.Unlock()
+
+			ug, err = testCache.GetUserGroup(ugi.User)
+			if err != nil || ug.resolved != cachedUG.resolved {
+				t.Errorf("User 'testuser1' not returned from Cache, resolution time differs: %d got %d (err = %v)", ug.resolved, cachedUG.resolved, err)
+			}
+		})
 	}
+}
 
-	ug, err = testCache.GetUserGroup(ugi.User)
-	if err == nil {
-		t.Error("Lookup should have failed: unknown user")
-	}
-	// ug is partially filled and failed flag is set: error message should show that the cache was returned
-	if err != nil && !strings.Contains(err.Error(), "cached data returned") {
-		t.Errorf("UserGroup not returned from Cache: %v, error: %v", ug, err)
+func TestBrokenUserGroup(t *testing.T) {
+	testCases := []struct {
+		name     string
+		resolver configs.UserGroupResolver
+	}{
+		{
+			name:     "TestResolver",
+			resolver: testResolver,
+		},
+		{
+			name:     "OsResolver",
+			resolver: osResolver,
+		},
+		{
+			name:     "UnknownResolver",
+			resolver: unknownResolver,
+		},
+		{
+			name:     "LdapResolver",
+			resolver: ldapResolver,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			testCache := GetUserGroupCache(tc.resolver, &ConfigReaderMock{}, &LdapAccessMock{})
+			testCache.resetCache()
+			// test cache should be empty now
+			assert.Equal(t, 0, testCache.getUGsize(), "Cache is not empty: %v", testCache.getUGmap())
+
+			ug, err := testCache.GetUserGroup("testuser2")
+			if err != nil {
+				t.Error("Lookup should not have failed: testuser2")
+			}
+
+			assert.Equal(t, 1, testCache.getUGsize(), "Cache not updated should have 1 entry %d", testCache.getUGmap())
+			// check returned info: 3 groups etc
+			if ug.User != "testuser2" || len(ug.Groups) != 3 || ug.resolved == 0 || ug.failed {
+				t.Errorf("User 'testuser2' not resolved correctly: %v", ug)
+			}
+			// first group should have failed resolution: just the ID expected
+			if ug.Groups[0] != "100" {
+				t.Errorf("User 'testuser2' primary group resolved while it should not: %v", ug)
+			}
+
+			ug, err = testCache.GetUserGroup("testuser3")
+			if err != nil {
+				t.Error("Lookup should not have failed: testuser3")
+			}
+
+			assert.Equal(t, 2, testCache.getUGsize(), "Cache not updated should have 2 entries %d", len(testCache.ugs))
+			assert.Equal(t, 4, testCache.getUGGroupSize("testuser3"), "User 'testuser3' not resolved correctly: duplicate primary group not filtered %v", ug)
+
+			ug, err = testCache.GetUserGroup("unknown")
+			assert.ErrorContains(t, err, "lookup failed for user: unknown")
+
+			ug, err = testCache.GetUserGroup("testuser4")
+			assert.NilError(t, err)
+
+			ug, err = testCache.GetUserGroup("testuser5")
+			assert.ErrorContains(t, err, "lookup failed for user: testuser5")
+
+			ug, err = testCache.GetUserGroup("invalid-gid-user")
+			assert.ErrorContains(t, err, "lookup failed for user: invalid-gid-user")
+			exceptedGroup := []string{"1_001"}
+			assert.Assert(t, reflect.DeepEqual(ug.Groups, exceptedGroup), fmt.Errorf("group should be: %v, but got: %v", exceptedGroup, ug.Groups))
+		})
 	}
 }
 
-func TestCacheCleanUp(t *testing.T) {
-	testCache := GetUserGroupCache("test")
-	testCache.resetCache()
-	// test cache should be empty now
-	assert.Equal(t, 0, testCache.getUGsize(), "Cache is not empty: %v", testCache.getUGmap())
-
-	// resolve an existing user
-	_, err := testCache.GetUserGroup("testuser1")
-	if err != nil {
-		t.Error("Lookup should not have failed: testuser1 user")
-	}
-	_, err = testCache.GetUserGroup("testuser2")
-	if err != nil {
-		t.Error("Lookup should not have failed: testuser2 user")
+func TestGetUserGroupFail(t *testing.T) {
+	testCases := []struct {
+		name     string
+		resolver configs.UserGroupResolver
+	}{
+		{
+			name:     "TestResolver",
+			resolver: testResolver,
+		},
+		{
+			name:     "OsResolver",
+			resolver: osResolver,
+		},
+		{
+			name:     "UnknownResolver",
+			resolver: unknownResolver,
+		},
+		{
+			name:     "LdapResolver",
+			resolver: ldapResolver,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			testCache := GetUserGroupCache(tc.resolver, &ConfigReaderMock{}, &LdapAccessMock{})
+			testCache.resetCache()
+			// test cache should be empty now
+			assert.Equal(t, 0, testCache.getUGsize(), "Cache is not empty: %v", testCache.getUGmap())
+
+			// resolve an empty user
+			ug, err := testCache.GetUserGroup("")
+			if err == nil {
+				t.Error("Lookup should have failed: empty user")
+			}
+			// ug is empty everything should be nil..
+			if ug.User != "" || len(ug.Groups) != 0 || ug.resolved != 0 || ug.failed {
+				t.Errorf("UserGroup is not empty: %v", ug)
+			}
+
+			// resolve a non existing user
+			ugi := &si.UserGroupInformation{
+				User:   "unknown",
+				Groups: nil,
+			}
+			ug, err = testCache.GetUserGroup(ugi.User)
+			if err == nil {
+				t.Error("Lookup should have failed: unknown user")
+			}
+			// ug is partially filled and failed flag is set
+			if ug.User != ugi.User || len(ug.Groups) != 0 || !ug.failed {
+				t.Errorf("UserGroup is not empty: %v", ug)
+			}
+
+			ug, err = testCache.GetUserGroup(ugi.User)
+			if err == nil {
+				t.Error("Lookup should have failed: unknown user")
+			}
+			// ug is partially filled and failed flag is set: error message should show that the cache was returned
+			if err != nil && !strings.Contains(err.Error(), "cached data returned") {
+				t.Errorf("UserGroup not returned from Cache: %v, error: %v", ug, err)
+			}
+		})
 	}
+}
 
-	testCache.lock.Lock()
-	ug := testCache.ugs["testuser1"]
-	if ug.failed {
-		t.Error("User 'testuser1' not resolved as a success")
-	}
-	// expire the successful lookup
-	ug.resolved -= 2 * poscache
-	testCache.lock.Unlock()
-
-	// resolve a non existing user
-	_, err = testCache.GetUserGroup("unknown")
-	if err == nil {
-		t.Error("Lookup should have failed: unknown user")
-	}
-	testCache.lock.Lock()
-	ug = testCache.ugs["unknown"]
-	if !ug.failed {
-		t.Error("User 'unknown' not resolved as a failure")
+func TestCacheCleanUp(t *testing.T) {
+	testCases := []struct {
+		name     string
+		resolver configs.UserGroupResolver
+	}{
+		{
+			name:     "TestResolver",
+			resolver: testResolver,
+		},
+		{
+			name:     "OsResolver",
+			resolver: osResolver,
+		},
+		{
+			name:     "UnknownResolver",
+			resolver: unknownResolver,
+		},
+		{
+			name:     "LdapResolver",
+			resolver: ldapResolver,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			testCache := GetUserGroupCache(tc.resolver, &ConfigReaderMock{}, &LdapAccessMock{})
+			testCache.resetCache()
+			// test cache should be empty now
+			assert.Equal(t, 0, testCache.getUGsize(), "Cache is not empty: %v", testCache.getUGmap())
+
+			// resolve an existing user
+			_, err := testCache.GetUserGroup("testuser1")
+			if err != nil {
+				t.Error("Lookup should not have failed: testuser1 user")
+			}
+			_, err = testCache.GetUserGroup("testuser2")
+			if err != nil {
+				t.Error("Lookup should not have failed: testuser2 user")
+			}
+
+			testCache.lock.Lock()
+			ug := testCache.ugs["testuser1"]
+			if ug.failed {
+				t.Error("User 'testuser1' not resolved as a success")
+			}
+			// expire the successful lookup
+			ug.resolved -= 2 * poscache
+			testCache.lock.Unlock()
+
+			// resolve a non existing user
+			_, err = testCache.GetUserGroup("unknown")
+			if err == nil {
+				t.Error("Lookup should have failed: unknown user")
+			}
+			testCache.lock.Lock()
+			ug = testCache.ugs["unknown"]
+			if !ug.failed {
+				t.Error("User 'unknown' not resolved as a failure")
+			}
+			// expire the failed lookup
+			ug.resolved -= 2 * negcache
+			testCache.lock.Unlock()
+
+			testCache.cleanUpCache()
+			assert.Equal(t, 1, testCache.getUGsize(), "Cache is not empty: %v", testCache.getUGmap())
+		})
 	}
-	// expire the failed lookup
-	ug.resolved -= 2 * negcache
-	testCache.lock.Unlock()
-
-	testCache.cleanUpCache()
-	assert.Equal(t, 1, testCache.getUGsize(), "Cache is not empty: %v", testCache.getUGmap())
 }
 
 func TestIntervalCacheCleanUp(t *testing.T) {
-	testCache := GetUserGroupCache("test")
-	testCache.resetCache()
-	// test cache should be empty now
-	assert.Equal(t, 0, testCache.getUGsize(), "Cache is not empty: %v", testCache.getUGmap())
-
-	// resolve an existing user
-	user1ug, err := testCache.GetUserGroup("testuser1")
-	assert.NilError(t, err, "Lookup should not have failed: testuser1 user")
-	assert.Assert(t, !user1ug.failed, "User 'testuser1' not resolved as a success")
-
-	_, err = testCache.GetUserGroup("testuser2")
-	assert.NilError(t, err, "Lookup should not have failed: testuser1 user")
-
-	// expire the successful lookup
-	testCache.lock.Lock()
-	ug := testCache.ugs["testuser1"]
-	ug.resolved -= 2 * poscache
-
-	testCache.lock.Unlock()
-	// resolve a non existing user
-	_, err = testCache.GetUserGroup("unknown")
-	assert.Assert(t, err != nil, "Lookup should have failed: unknown user")
-	testCache.lock.Lock()
-	ug = testCache.ugs["unknown"]
-	assert.Assert(t, ug.failed, "User 'unknown' not resolved as a failure")
-
-	// expire the failed lookup
-	ug.resolved -= 2 * negcache
-	testCache.lock.Unlock()
-
-	// sleep to wait for interval, it will trigger cleanUpCache
-	time.Sleep(testCache.interval + time.Second)
-	assert.Equal(t, 1, testCache.getUGsize(), "Cache not cleaned up : %v", testCache.getUGmap())
+	testCases := []struct {
+		name     string
+		resolver configs.UserGroupResolver
+	}{
+		{
+			name:     "TestResolver",
+			resolver: testResolver,
+		},
+		{
+			name:     "OsResolver",
+			resolver: osResolver,
+		},
+		{
+			name:     "UnknownResolver",
+			resolver: unknownResolver,
+		},
+		{
+			name:     "LdapResolver",
+			resolver: ldapResolver,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			testCache := GetUserGroupCache(tc.resolver, &ConfigReaderMock{}, &LdapAccessMock{})
+			testCache.resetCache()
+			// test cache should be empty now
+			assert.Equal(t, 0, testCache.getUGsize(), "Cache is not empty: %v", testCache.getUGmap())
+
+			// resolve an existing user
+			user1ug, err := testCache.GetUserGroup("testuser1")
+			assert.NilError(t, err, "Lookup should not have failed: testuser1 user")
+			assert.Assert(t, !user1ug.failed, "User 'testuser1' not resolved as a success")
+
+			_, err = testCache.GetUserGroup("testuser2")
+			assert.NilError(t, err, "Lookup should not have failed: testuser1 user")
+
+			// expire the successful lookup
+			testCache.lock.Lock()
+			ug := testCache.ugs["testuser1"]
+			ug.resolved -= 2 * poscache
+
+			testCache.lock.Unlock()
+			// resolve a non existing user
+			_, err = testCache.GetUserGroup("unknown")
+			assert.Assert(t, err != nil, "Lookup should have failed: unknown user")
+			testCache.lock.Lock()
+			ug = testCache.ugs["unknown"]
+			assert.Assert(t, ug.failed, "User 'unknown' not resolved as a failure")
+
+			// expire the failed lookup
+			ug.resolved -= 2 * negcache
+			testCache.lock.Unlock()
+
+			// sleep to wait for interval, it will trigger cleanUpCache
+			time.Sleep(testCache.interval + time.Second)
+			assert.Equal(t, 1, testCache.getUGsize(), "Cache not cleaned up : %v", testCache.getUGmap())
+		})
+	}
 }
 
 func TestConvertUGI(t *testing.T) {
-	testCache := GetUserGroupCache("test")
-	testCache.resetCache()
-	// test cache should be empty now
-	assert.Equal(t, 0, testCache.getUGsize(), "Cache is not empty: %v", testCache.getUGmap())
-
-	ugi := &si.UserGroupInformation{
-		User:   "",
-		Groups: nil,
-	}
-	ug, err := testCache.ConvertUGI(ugi, false)
-	if err == nil {
-		t.Errorf("empty user convert should have failed and did not: %v", ug)
-	}
-	// try known user without groups
-	ugi.User = "testuser1"
-	ug, err = testCache.ConvertUGI(ugi, false)
-	if err != nil {
-		t.Errorf("known user, no groups, convert should not have failed: %v", err)
-	}
-	if ug.User != "testuser1" || len(ug.Groups) != 2 || ug.resolved == 0 || ug.failed {
-		t.Errorf("User 'testuser1' not resolved correctly: %v", ug)
-	}
-	// try unknown user without groups
-	ugi.User = "unknown"
-	ug, err = testCache.ConvertUGI(ugi, false)
-	if err == nil {
-		t.Errorf("unknown user, no groups, convert should have failed: %v", ug)
-	}
-	// try empty user when forced
-	ugi.User = ""
-	ug, err = testCache.ConvertUGI(ugi, true)
-	if err != nil {
-		t.Errorf("empty user but forced, convert should not have failed: %v", err)
+	testCases := []struct {
+		name     string
+		resolver configs.UserGroupResolver
+	}{
+		{
+			name:     "TestResolver",
+			resolver: testResolver,
+		},
+		{
+			name:     "OsResolver",
+			resolver: osResolver,
+		},
+		{
+			name:     "UnknownResolver",
+			resolver: unknownResolver,
+		},
+		{
+			name:     "LdapResolver",
+			resolver: ldapResolver,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			testCache := GetUserGroupCache(tc.resolver, &ConfigReaderMock{}, &LdapAccessMock{})
+			testCache.resetCache()
+			// test cache should be empty now
+			assert.Equal(t, 0, testCache.getUGsize(), "Cache is not empty: %v", testCache.getUGmap())
+
+			ugi := &si.UserGroupInformation{
+				User:   "",
+				Groups: nil,
+			}
+			ug, err := testCache.ConvertUGI(ugi, false)
+			if err == nil {
+				t.Errorf("empty user convert should have failed and did not: %v", ug)
+			}
+			// try known user without groups
+			ugi.User = "testuser1"
+			ug, err = testCache.ConvertUGI(ugi, false)
+			if err != nil {
+				t.Errorf("known user, no groups, convert should not have failed: %v", err)
+			}
+			if ug.User != "testuser1" || len(ug.Groups) != 2 || ug.resolved == 0 || ug.failed {
+				t.Errorf("User 'testuser1' not resolved correctly: %v", ug)
+			}
+			// try unknown user without groups
+			ugi.User = "unknown"
+			ug, err = testCache.ConvertUGI(ugi, false)
+			if err == nil {
+				t.Errorf("unknown user, no groups, convert should have failed: %v", ug)
+			}
+			// try empty user when forced
+			ugi.User = ""
+			ug, err = testCache.ConvertUGI(ugi, true)
+			if err != nil {
+				t.Errorf("empty user but forced, convert should not have failed: %v", err)
+			}
+			// try unknown user with groups
+			ugi.User = "unknown2"
+			group := "passedin"
+			ugi.Groups = []string{group}
+			ug, err = testCache.ConvertUGI(ugi, false)
+			if err != nil {
+				t.Errorf("unknown user with groups, convert should not have failed: %v", err)
+			}
+			if ug.User != "unknown2" || len(ug.Groups) != 1 || ug.resolved == 0 || ug.failed {
+				t.Fatalf("User 'unknown2' not resolved correctly: %v", ug)
+			}
+			if ug.Groups[0] != group {
+				t.Errorf("groups not initialised correctly on convert: expected '%s' got '%s'", group, ug.Groups[0])
+			}
+			// try valid username with groups
+			ugi.User = "validuserABCD1234@://#"
+			ugi.Groups = []string{group}
+			ug, err = testCache.ConvertUGI(ugi, false)
+			if err != nil {
+				t.Errorf("valid username with groups, convert should not have failed: %v", err)
+			}
+			// try invalid username with groups
+			ugi.User = "invaliduser><+"
+			ugi.Groups = []string{group}
+			ug, err = testCache.ConvertUGI(ugi, false)
+			if err == nil {
+				t.Errorf("invalid username, convert should have failed: %v", err)
+			}
+
+			// try unknown user with empty group when forced
+			ugi.User = "unknown"
+			ugi.Groups = []string{}
+			ug, err = testCache.ConvertUGI(ugi, true)
+			exceptedGroup := []string{common.AnonymousGroup}
+			assert.Assert(t, reflect.DeepEqual(ug.Groups, exceptedGroup), "group should be: %v, but got: %v", exceptedGroup, ug.Groups)
+			assert.NilError(t, err, "unknown user, no groups, convert should not have failed")
+		})
 	}
-	// try unknown user with groups
-	ugi.User = "unknown2"
-	group := "passedin"
-	ugi.Groups = []string{group}
-	ug, err = testCache.ConvertUGI(ugi, false)
-	if err != nil {
-		t.Errorf("unknown user with groups, convert should not have failed: %v", err)
-	}
-	if ug.User != "unknown2" || len(ug.Groups) != 1 || ug.resolved == 0 || ug.failed {
-		t.Fatalf("User 'unknown2' not resolved correctly: %v", ug)
-	}
-	if ug.Groups[0] != group {
-		t.Errorf("groups not initialised correctly on convert: expected '%s' got '%s'", group, ug.Groups[0])
-	}
-	// try valid username with groups
-	ugi.User = "validuserABCD1234@://#"
-	ugi.Groups = []string{group}
-	ug, err = testCache.ConvertUGI(ugi, false)
-	if err != nil {
-		t.Errorf("valid username with groups, convert should not have failed: %v", err)
-	}
-	// try invalid username with groups
-	ugi.User = "invaliduser><+"
-	ugi.Groups = []string{group}
-	ug, err = testCache.ConvertUGI(ugi, false)
-	if err == nil {
-		t.Errorf("invalid username, convert should have failed: %v", err)
-	}
-
-	// try unknown user with empty group when forced
-	ugi.User = "unknown"
-	ugi.Groups = []string{}
-	ug, err = testCache.ConvertUGI(ugi, true)
-	exceptedGroup := []string{common.AnonymousGroup}
-	assert.Assert(t, reflect.DeepEqual(ug.Groups, exceptedGroup), "group should be: %v, but got: %v", exceptedGroup, ug.Groups)
-	assert.NilError(t, err, "unknown user, no groups, convert should not have failed")
 }
diff --git a/pkg/common/security/usergroup_test_resolver.go b/pkg/common/security/usergroup_test_resolver.go
index c35dd6bb..47058c26 100644
--- a/pkg/common/security/usergroup_test_resolver.go
+++ b/pkg/common/security/usergroup_test_resolver.go
@@ -26,9 +26,12 @@ import (
 )
 
 const (
+	Testuser  = "testuser"
 	Testuser1 = "testuser1"
 	Testuser2 = "testuser2"
 	Testuser3 = "testuser3"
+	Testuser4 = "testuser4"
+	Testuser5 = "testuser5"
 )
 
 // Get the cache with a test resolver
diff --git a/pkg/scheduler/partition.go b/pkg/scheduler/partition.go
index 62e40130..96c58943 100644
--- a/pkg/scheduler/partition.go
+++ b/pkg/scheduler/partition.go
@@ -136,7 +136,7 @@ func (pc *PartitionContext) initialPartitionFromConfig(conf configs.PartitionCon
 	// Placing an application will not have a lock on the partition context.
 	pc.placementManager = placement.NewPlacementManager(conf.PlacementRules, pc.GetQueue, silence)
 	// get the user group cache for the partition
-	pc.userGroupCache = security.GetUserGroupCache("")
+	pc.userGroupCache = security.GetUserGroupCache(conf.UserGroupResolver, security.GetConfigReader(), security.GetLdapAccess())
 	pc.updateNodeSortingPolicy(conf, silence)
 	pc.updatePreemption(conf)
 


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@yunikorn.apache.org
For additional commands, e-mail: issues-help@yunikorn.apache.org