From c187474499a6cb628eb3ae4b2f5a00c140a07752 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 25 May 2023 14:25:17 -0700 Subject: [PATCH] Use groupSearch.userAttributeForFilter during LDAP group searches Load the setting in the controller. Use the setting during authentication and during refreshes. --- .../active_directory_upstream_watcher.go | 6 +- .../ldap_upstream_watcher.go | 15 +- .../ldap_upstream_watcher_test.go | 134 ++--- .../upstreamwatchers/upstream_watchers.go | 3 +- internal/upstreamldap/upstreamldap.go | 73 ++- internal/upstreamldap/upstreamldap_test.go | 466 +++++++++++++++++- 6 files changed, 607 insertions(+), 90 deletions(-) diff --git a/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher.go b/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher.go index 105f1ed7..38a677f0 100644 --- a/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher.go +++ b/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher.go @@ -1,4 +1,4 @@ -// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2021-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 // Package activedirectoryupstreamwatcher implements a controller which watches ActiveDirectoryIdentityProviders. @@ -203,6 +203,10 @@ func (g *activeDirectoryUpstreamGenericLDAPGroupSearch) Filter() string { return g.groupSearch.Filter } +func (g *activeDirectoryUpstreamGenericLDAPGroupSearch) UserAttributeForFilter() string { + return "" +} + func (g *activeDirectoryUpstreamGenericLDAPGroupSearch) GroupNameAttribute() string { if len(g.groupSearch.Attributes.GroupName) == 0 { return defaultActiveDirectoryGroupNameAttributeName diff --git a/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher.go b/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher.go index 6d370e26..424a9028 100644 --- a/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher.go +++ b/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher.go @@ -1,4 +1,4 @@ -// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2021-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 // Package ldapupstreamwatcher implements a controller which watches LDAPIdentityProviders. @@ -115,6 +115,10 @@ func (g *ldapUpstreamGenericLDAPGroupSearch) Filter() string { return g.groupSearch.Filter } +func (g *ldapUpstreamGenericLDAPGroupSearch) UserAttributeForFilter() string { + return g.groupSearch.UserAttributeForFilter +} + func (g *ldapUpstreamGenericLDAPGroupSearch) GroupNameAttribute() string { return g.groupSearch.Attributes.GroupName } @@ -236,10 +240,11 @@ func (c *ldapWatcherController) validateUpstream(ctx context.Context, upstream * UIDAttribute: spec.UserSearch.Attributes.UID, }, GroupSearch: upstreamldap.GroupSearchConfig{ - Base: spec.GroupSearch.Base, - Filter: spec.GroupSearch.Filter, - GroupNameAttribute: spec.GroupSearch.Attributes.GroupName, - SkipGroupRefresh: spec.GroupSearch.SkipGroupRefresh, + Base: spec.GroupSearch.Base, + Filter: spec.GroupSearch.Filter, + UserAttributeForFilter: spec.GroupSearch.UserAttributeForFilter, + GroupNameAttribute: spec.GroupSearch.Attributes.GroupName, + SkipGroupRefresh: spec.GroupSearch.SkipGroupRefresh, }, Dialer: c.ldapDialer, } diff --git a/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher_test.go b/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher_test.go index 0f57bc2f..3eff7621 100644 --- a/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher_test.go +++ b/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package ldapupstreamwatcher @@ -148,20 +148,25 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { now := metav1.NewTime(time.Now().UTC()) const ( - testNamespace = "test-namespace" - testName = "test-name" - testResourceUID = "test-resource-uid" - testSecretName = "test-bind-secret" - testBindUsername = "test-bind-username" - testBindPassword = "test-bind-password" - testHost = "ldap.example.com:123" - testUserSearchBase = "test-user-search-base" - testUserSearchFilter = "test-user-search-filter" - testGroupSearchBase = "test-group-search-base" - testGroupSearchFilter = "test-group-search-filter" - testUsernameAttrName = "test-username-attr" - testGroupNameAttrName = "test-group-name-attr" - testUIDAttrName = "test-uid-attr" + testNamespace = "test-namespace" + testName = "test-name" + testResourceUID = "test-resource-uid" + + testHost = "ldap.example.com:123" + + testBindSecretName = "test-bind-secret" + testBindUsername = "test-bind-username" + testBindPassword = "test-bind-password" + + testUserSearchBase = "test-user-search-base" + testUserSearchFilter = "test-user-search-filter" + testUserSearchUsernameAttrName = "test-username-attr" + testUserSearchUIDAttrName = "test-uid-attr" + + testGroupSearchBase = "test-group-search-base" + testGroupSearchFilter = "test-group-search-filter" + testGroupSearchUserAttributeForFilter = "test-group-search-filter-user-attr-for-filter" + testGroupSearchNameAttrName = "test-group-name-attr" ) testValidSecretData := map[string][]byte{"username": []byte(testBindUsername), "password": []byte(testBindPassword)} @@ -181,20 +186,21 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { Spec: v1alpha1.LDAPIdentityProviderSpec{ Host: testHost, TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testCABundleBase64Encoded}, - Bind: v1alpha1.LDAPIdentityProviderBind{SecretName: testSecretName}, + Bind: v1alpha1.LDAPIdentityProviderBind{SecretName: testBindSecretName}, UserSearch: v1alpha1.LDAPIdentityProviderUserSearch{ Base: testUserSearchBase, Filter: testUserSearchFilter, Attributes: v1alpha1.LDAPIdentityProviderUserSearchAttributes{ - Username: testUsernameAttrName, - UID: testUIDAttrName, + Username: testUserSearchUsernameAttrName, + UID: testUserSearchUIDAttrName, }, }, GroupSearch: v1alpha1.LDAPIdentityProviderGroupSearch{ - Base: testGroupSearchBase, - Filter: testGroupSearchFilter, + Base: testGroupSearchBase, + Filter: testGroupSearchFilter, + UserAttributeForFilter: testGroupSearchUserAttributeForFilter, Attributes: v1alpha1.LDAPIdentityProviderGroupSearchAttributes{ - GroupName: testGroupNameAttrName, + GroupName: testGroupSearchNameAttrName, }, SkipGroupRefresh: false, }, @@ -217,13 +223,14 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { UserSearch: upstreamldap.UserSearchConfig{ Base: testUserSearchBase, Filter: testUserSearchFilter, - UsernameAttribute: testUsernameAttrName, - UIDAttribute: testUIDAttrName, + UsernameAttribute: testUserSearchUsernameAttrName, + UIDAttribute: testUserSearchUIDAttrName, }, GroupSearch: upstreamldap.GroupSearchConfig{ - Base: testGroupSearchBase, - Filter: testGroupSearchFilter, - GroupNameAttribute: testGroupNameAttrName, + Base: testGroupSearchBase, + Filter: testGroupSearchFilter, + UserAttributeForFilter: testGroupSearchUserAttributeForFilter, + GroupNameAttribute: testGroupSearchNameAttrName, }, } @@ -250,7 +257,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { Reason: "Success", Message: fmt.Sprintf( `successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`, - testHost, testBindUsername, testSecretName, secretVersion), + testHost, testBindUsername, testBindSecretName, secretVersion), ObservedGeneration: gen, } } @@ -282,7 +289,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { validBindUserSecret := func(secretVersion string) *corev1.Secret { return &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: testSecretName, Namespace: testNamespace, ResourceVersion: secretVersion}, + ObjectMeta: metav1.ObjectMeta{Name: testBindSecretName, Namespace: testNamespace, ResourceVersion: secretVersion}, Type: corev1.SecretTypeBasicAuth, Data: testValidSecretData, } @@ -346,7 +353,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { Status: "False", LastTransitionTime: now, Reason: "SecretNotFound", - Message: fmt.Sprintf(`secret "%s" not found`, testSecretName), + Message: fmt.Sprintf(`secret "%s" not found`, testBindSecretName), ObservedGeneration: 1234, }, tlsConfigurationValidLoadedTrueCondition(1234), @@ -358,7 +365,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { name: "secret has wrong type", inputUpstreams: []runtime.Object{validUpstream}, inputSecrets: []runtime.Object{&corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: testSecretName, Namespace: testNamespace}, + ObjectMeta: metav1.ObjectMeta{Name: testBindSecretName, Namespace: testNamespace}, Type: "some-other-type", Data: testValidSecretData, }}, @@ -374,7 +381,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { Status: "False", LastTransitionTime: now, Reason: "SecretWrongType", - Message: fmt.Sprintf(`referenced Secret "%s" has wrong type "some-other-type" (should be "kubernetes.io/basic-auth")`, testSecretName), + Message: fmt.Sprintf(`referenced Secret "%s" has wrong type "some-other-type" (should be "kubernetes.io/basic-auth")`, testBindSecretName), ObservedGeneration: 1234, }, tlsConfigurationValidLoadedTrueCondition(1234), @@ -386,7 +393,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { name: "secret is missing key", inputUpstreams: []runtime.Object{validUpstream}, inputSecrets: []runtime.Object{&corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: testSecretName, Namespace: testNamespace}, + ObjectMeta: metav1.ObjectMeta{Name: testBindSecretName, Namespace: testNamespace}, Type: corev1.SecretTypeBasicAuth, }}, wantErr: controllerlib.ErrSyntheticRequeue.Error(), @@ -401,7 +408,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { Status: "False", LastTransitionTime: now, Reason: "SecretMissingKeys", - Message: fmt.Sprintf(`referenced Secret "%s" is missing required keys ["username" "password"]`, testSecretName), + Message: fmt.Sprintf(`referenced Secret "%s" is missing required keys ["username" "password"]`, testBindSecretName), ObservedGeneration: 1234, }, tlsConfigurationValidLoadedTrueCondition(1234), @@ -484,13 +491,14 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { UserSearch: upstreamldap.UserSearchConfig{ Base: testUserSearchBase, Filter: testUserSearchFilter, - UsernameAttribute: testUsernameAttrName, - UIDAttribute: testUIDAttrName, + UsernameAttribute: testUserSearchUsernameAttrName, + UIDAttribute: testUserSearchUIDAttrName, }, GroupSearch: upstreamldap.GroupSearchConfig{ - Base: testGroupSearchBase, - Filter: testGroupSearchFilter, - GroupNameAttribute: testGroupNameAttrName, + Base: testGroupSearchBase, + Filter: testGroupSearchFilter, + UserAttributeForFilter: testGroupSearchUserAttributeForFilter, + GroupNameAttribute: testGroupSearchNameAttrName, }, }, }, @@ -548,13 +556,14 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { UserSearch: upstreamldap.UserSearchConfig{ Base: testUserSearchBase, Filter: testUserSearchFilter, - UsernameAttribute: testUsernameAttrName, - UIDAttribute: testUIDAttrName, + UsernameAttribute: testUserSearchUsernameAttrName, + UIDAttribute: testUserSearchUIDAttrName, }, GroupSearch: upstreamldap.GroupSearchConfig{ - Base: testGroupSearchBase, - Filter: testGroupSearchFilter, - GroupNameAttribute: testGroupNameAttrName, + Base: testGroupSearchBase, + Filter: testGroupSearchFilter, + UserAttributeForFilter: testGroupSearchUserAttributeForFilter, + GroupNameAttribute: testGroupSearchNameAttrName, }, }, }, @@ -571,7 +580,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { Reason: "Success", Message: fmt.Sprintf( `successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`, - "ldap.example.com", testBindUsername, testSecretName, "4242"), + "ldap.example.com", testBindUsername, testBindSecretName, "4242"), ObservedGeneration: 1234, }, tlsConfigurationValidLoadedTrueCondition(1234), @@ -590,7 +599,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { Reason: "Success", Message: fmt.Sprintf( `successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`, - "ldap.example.com", testBindUsername, testSecretName, "4242"), + "ldap.example.com", testBindUsername, testBindSecretName, "4242"), }, }}, }, @@ -619,13 +628,14 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { UserSearch: upstreamldap.UserSearchConfig{ Base: testUserSearchBase, Filter: testUserSearchFilter, - UsernameAttribute: testUsernameAttrName, - UIDAttribute: testUIDAttrName, + UsernameAttribute: testUserSearchUsernameAttrName, + UIDAttribute: testUserSearchUIDAttrName, }, GroupSearch: upstreamldap.GroupSearchConfig{ - Base: testGroupSearchBase, - Filter: testGroupSearchFilter, - GroupNameAttribute: testGroupNameAttrName, + Base: testGroupSearchBase, + Filter: testGroupSearchFilter, + UserAttributeForFilter: testGroupSearchUserAttributeForFilter, + GroupNameAttribute: testGroupSearchNameAttrName, }, }, }, @@ -675,13 +685,14 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { UserSearch: upstreamldap.UserSearchConfig{ Base: testUserSearchBase, Filter: testUserSearchFilter, - UsernameAttribute: testUsernameAttrName, - UIDAttribute: testUIDAttrName, + UsernameAttribute: testUserSearchUsernameAttrName, + UIDAttribute: testUserSearchUIDAttrName, }, GroupSearch: upstreamldap.GroupSearchConfig{ - Base: testGroupSearchBase, - Filter: testGroupSearchFilter, - GroupNameAttribute: testGroupNameAttrName, + Base: testGroupSearchBase, + Filter: testGroupSearchFilter, + UserAttributeForFilter: testGroupSearchUserAttributeForFilter, + GroupNameAttribute: testGroupSearchNameAttrName, }, }, }, @@ -1077,14 +1088,15 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { UserSearch: upstreamldap.UserSearchConfig{ Base: testUserSearchBase, Filter: testUserSearchFilter, - UsernameAttribute: testUsernameAttrName, - UIDAttribute: testUIDAttrName, + UsernameAttribute: testUserSearchUsernameAttrName, + UIDAttribute: testUserSearchUIDAttrName, }, GroupSearch: upstreamldap.GroupSearchConfig{ - Base: testGroupSearchBase, - Filter: testGroupSearchFilter, - GroupNameAttribute: testGroupNameAttrName, - SkipGroupRefresh: true, + Base: testGroupSearchBase, + Filter: testGroupSearchFilter, + UserAttributeForFilter: testGroupSearchUserAttributeForFilter, + GroupNameAttribute: testGroupSearchNameAttrName, + SkipGroupRefresh: true, }, }, }, diff --git a/internal/controller/supervisorconfig/upstreamwatchers/upstream_watchers.go b/internal/controller/supervisorconfig/upstreamwatchers/upstream_watchers.go index 5c33ea2d..33107dd8 100644 --- a/internal/controller/supervisorconfig/upstreamwatchers/upstream_watchers.go +++ b/internal/controller/supervisorconfig/upstreamwatchers/upstream_watchers.go @@ -1,4 +1,4 @@ -// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2021-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package upstreamwatchers @@ -126,6 +126,7 @@ type UpstreamGenericLDAPUserSearch interface { type UpstreamGenericLDAPGroupSearch interface { Base() string Filter() string + UserAttributeForFilter() string GroupNameAttribute() string } diff --git a/internal/upstreamldap/upstreamldap.go b/internal/upstreamldap/upstreamldap.go index 9c8dd1d6..80d02b00 100644 --- a/internal/upstreamldap/upstreamldap.go +++ b/internal/upstreamldap/upstreamldap.go @@ -1,4 +1,4 @@ -// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2021-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 // Package upstreamldap implements an abstraction of upstream LDAP IDP interactions. @@ -149,6 +149,10 @@ type GroupSearchConfig struct { // Filter is the filter to use for the group search in the upstream LDAP IDP. Empty means to use `member={}`. Filter string + // UserAttributeForFilter is the name of the user attribute whose value should be used to replace the placeholder + // in the Filter. Empty means to use 'dn'. + UserAttributeForFilter string + // GroupNameAttribute is the attribute in the LDAP group entry from which the group name should be // retrieved. Empty means to use 'cn'. GroupNameAttribute string @@ -166,13 +170,13 @@ type Provider struct { var _ provider.UpstreamLDAPIdentityProviderI = &Provider{} var _ authenticators.UserAuthenticator = &Provider{} -// Create a Provider. The config is not a pointer to ensure that a copy of the config is created, +// New creates a Provider. The config is not a pointer to ensure that a copy of the config is created, // making the resulting Provider use an effectively read-only configuration. func New(config ProviderConfig) *Provider { return &Provider{c: config} } -// A reader for the config. Returns a copy of the config to keep the underlying config read-only. +// GetConfig is a reader for the config. Returns a copy of the config to keep the underlying config read-only. func (p *Provider) GetConfig() ProviderConfig { return p.c } @@ -245,7 +249,15 @@ func (p *Provider) PerformRefresh(ctx context.Context, storedRefreshAttributes p return nil, nil } - mappedGroupNames, err := p.searchGroupsForUserDN(conn, userDN) + var groupSearchUserAttributeForFilterValue string + if p.useGroupSearchUserAttributeForFilter() { + groupSearchUserAttributeForFilterValue, err = p.getSearchResultAttributeValue(p.c.GroupSearch.UserAttributeForFilter, userEntry, newUsername) + if err != nil { + return nil, err + } + } + + mappedGroupNames, err := p.searchGroupsForUserMembership(conn, userDN, groupSearchUserAttributeForFilterValue) if err != nil { return nil, err } @@ -358,7 +370,7 @@ func (p *Provider) tlsConfig() (*tls.Config, error) { return ptls.DefaultLDAP(rootCAs), nil } -// A name for this upstream provider. +// GetName returns a name for this upstream provider. func (p *Provider) GetName() string { return p.c.Name } @@ -367,7 +379,7 @@ func (p *Provider) GetResourceUID() types.UID { return p.c.ResourceUID } -// Return a URL which uniquely identifies this LDAP provider, e.g. "ldaps://host.example.com:1234?base=user-search-base". +// GetURL returns a URL which uniquely identifies this LDAP provider, e.g. "ldaps://host.example.com:1234?base=user-search-base". // This URL is not used for connecting to the provider, but rather is used for creating a globally unique user // identifier by being combined with the user's UID, since user UIDs are only unique within one provider. func (p *Provider) GetURL() *url.URL { @@ -412,7 +424,7 @@ func (p *Provider) DryRunAuthenticateUser(ctx context.Context, username string, return p.authenticateUserImpl(ctx, username, grantedScopes, endUserBindFunc) } -// Authenticate an end user and return their mapped username, groups, and UID. Implements authenticators.UserAuthenticator. +// AuthenticateUser authenticates an end user and returns their mapped username, groups, and UID. Implements authenticators.UserAuthenticator. func (p *Provider) AuthenticateUser(ctx context.Context, username, password string, grantedScopes []string) (*authenticators.Response, bool, error) { endUserBindFunc := func(conn Conn, foundUserDN string) error { return conn.Bind(foundUserDN, password) @@ -463,13 +475,13 @@ func (p *Provider) authenticateUserImpl(ctx context.Context, username string, gr return response, true, nil } -func (p *Provider) searchGroupsForUserDN(conn Conn, userDN string) ([]string, error) { +func (p *Provider) searchGroupsForUserMembership(conn Conn, userDN string, groupSearchUserAttributeForFilterValue string) ([]string, error) { // If we do not have group search configured, skip this search. if len(p.c.GroupSearch.Base) == 0 { return []string{}, nil } - searchResult, err := conn.SearchWithPaging(p.groupSearchRequest(userDN), groupSearchPageSize) + searchResult, err := conn.SearchWithPaging(p.groupSearchRequest(userDN, groupSearchUserAttributeForFilterValue), groupSearchPageSize) if err != nil { return nil, fmt.Errorf(`error searching for group memberships for user with DN %q: %w`, userDN, err) } @@ -594,7 +606,15 @@ func (p *Provider) searchAndBindUser(conn Conn, username string, grantedScopes [ var mappedGroupNames []string if slices.Contains(grantedScopes, oidcapi.ScopeGroups) { - mappedGroupNames, err = p.searchGroupsForUserDN(conn, userEntry.DN) + var groupSearchUserAttributeForFilterValue string + if p.useGroupSearchUserAttributeForFilter() { + groupSearchUserAttributeForFilterValue, err = p.getSearchResultAttributeValue(p.c.GroupSearch.UserAttributeForFilter, userEntry, username) + if err != nil { + return nil, err + } + } + + mappedGroupNames, err = p.searchGroupsForUserMembership(conn, userEntry.DN, groupSearchUserAttributeForFilterValue) if err != nil { return nil, err } @@ -668,7 +688,7 @@ func (p *Provider) userSearchRequest(username string) *ldap.SearchRequest { } } -func (p *Provider) groupSearchRequest(userDN string) *ldap.SearchRequest { +func (p *Provider) groupSearchRequest(userDN string, groupSearchUserAttributeForFilterValue string) *ldap.SearchRequest { // See https://ldap.com/the-ldap-search-operation for general documentation of LDAP search options. return &ldap.SearchRequest{ BaseDN: p.c.GroupSearch.Base, @@ -677,7 +697,7 @@ func (p *Provider) groupSearchRequest(userDN string) *ldap.SearchRequest { SizeLimit: 0, // unlimited size because we will search with paging TimeLimit: 90, TypesOnly: false, - Filter: p.groupSearchFilter(userDN), + Filter: p.groupSearchFilter(userDN, groupSearchUserAttributeForFilterValue), Attributes: p.groupSearchRequestedAttributes(), Controls: nil, // nil because ldap.SearchWithPaging() will set the appropriate controls for us } @@ -698,6 +718,11 @@ func (p *Provider) refreshUserSearchRequest(dn string) *ldap.SearchRequest { } } +func (p *Provider) useGroupSearchUserAttributeForFilter() bool { + return len(p.c.GroupSearch.UserAttributeForFilter) > 0 && + p.c.GroupSearch.UserAttributeForFilter != distinguishedNameAttributeName +} + func (p *Provider) userSearchRequestedAttributes() []string { attributes := make([]string, 0, len(p.c.RefreshAttributeChecks)+2) if p.c.UserSearch.UsernameAttribute != distinguishedNameAttributeName { @@ -706,6 +731,9 @@ func (p *Provider) userSearchRequestedAttributes() []string { if p.c.UserSearch.UIDAttribute != distinguishedNameAttributeName { attributes = append(attributes, p.c.UserSearch.UIDAttribute) } + if p.useGroupSearchUserAttributeForFilter() { + attributes = append(attributes, p.c.GroupSearch.UserAttributeForFilter) + } for k := range p.c.RefreshAttributeChecks { attributes = append(attributes, k) } @@ -733,15 +761,20 @@ func (p *Provider) userSearchFilter(username string) string { return interpolateSearchFilter(p.c.UserSearch.Filter, safeUsername) } -func (p *Provider) groupSearchFilter(userDN string) string { - // The DN can contain characters that are considered special characters by LDAP searches, so it should be - // escaped before being included in the search filter to prevent bad search syntax. - // E.g. for the DN `CN=My User (Admin),OU=Users,OU=my,DC=my,DC=domain` we must escape the parens. - safeUserDN := p.escapeForSearchFilter(userDN) - if len(p.c.GroupSearch.Filter) == 0 { - return fmt.Sprintf("(member=%s)", safeUserDN) +func (p *Provider) groupSearchFilter(userDN string, groupSearchUserAttributeForFilterValue string) string { + valueToInterpolate := userDN + if p.useGroupSearchUserAttributeForFilter() { + // Instead of using the DN in placeholder substitution, use the value of the specified attribute. + valueToInterpolate = groupSearchUserAttributeForFilterValue } - return interpolateSearchFilter(p.c.GroupSearch.Filter, safeUserDN) + // The value to interpolate can contain characters that are considered special characters by LDAP searches, + // so it should be escaped before being included in the search filter to prevent bad search syntax. + // E.g. for the DN `CN=My User (Admin),OU=Users,OU=my,DC=my,DC=domain` we must escape the parens. + escapedValueToInterpolate := p.escapeForSearchFilter(valueToInterpolate) + if len(p.c.GroupSearch.Filter) == 0 { + return fmt.Sprintf("(member=%s)", escapedValueToInterpolate) + } + return interpolateSearchFilter(p.c.GroupSearch.Filter, escapedValueToInterpolate) } func interpolateSearchFilter(filterFormat, valueToInterpolateIntoFilter string) string { diff --git a/internal/upstreamldap/upstreamldap_test.go b/internal/upstreamldap/upstreamldap_test.go index 469b5bff..c6d1d126 100644 --- a/internal/upstreamldap/upstreamldap_test.go +++ b/internal/upstreamldap/upstreamldap_test.go @@ -56,11 +56,13 @@ const ( testUserDNWithSpecialCharsEscaped = `user DN with \2a \5c special characters \28\29` expectedGroupSearchPageSize = uint32(250) + + testGroupSearchFilterInterpolationSpec = "(some-group-filter=%s-and-more-filter=%s)" ) var ( testUserSearchFilterInterpolated = fmt.Sprintf("(some-user-filter=%s-and-more-filter=%s)", testUpstreamUsername, testUpstreamUsername) - testGroupSearchFilterInterpolated = fmt.Sprintf("(some-group-filter=%s-and-more-filter=%s)", testUserSearchResultDNValue, testUserSearchResultDNValue) + testGroupSearchFilterInterpolated = fmt.Sprintf(testGroupSearchFilterInterpolationSpec, testUserSearchResultDNValue, testUserSearchResultDNValue) ) func TestEndUserAuthentication(t *testing.T) { @@ -714,6 +716,236 @@ func TestEndUserAuthentication(t *testing.T) { }, wantError: testutil.WantExactErrorString("found 0 values for attribute \"some-attribute-to-check-during-refresh\" while searching for user \"some-upstream-username\", but expected 1 result"), }, + { + name: "when the UserAttributeForFilter is set to something other than dn", + username: testUpstreamUsername, + password: testUpstreamPassword, + providerConfig: providerConfig(func(p *ProviderConfig) { + p.GroupSearch.UserAttributeForFilter = "someUserAttrName" + }), + searchMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) { + // need to additionally ask for the attribute when performing user search + r.Attributes = []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute, "someUserAttrName"} + })).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: testUserSearchResultDNValue, + Attributes: []*ldap.EntryAttribute{ + ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testUserSearchResultUsernameAttributeValue}), + ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testUserSearchResultUIDAttributeValue}), + // additionally get back the attr from the user search + ldap.NewEntryAttribute("someUserAttrName", []string{"someUserAttrValue"}), + }, + }, + }, + }, nil).Times(1) + conn.EXPECT().SearchWithPaging(expectedGroupSearch(func(r *ldap.SearchRequest) { + // need to interpolate the attr's value into the filter instead of interpolating the default (user's dn) + r.Filter = fmt.Sprintf(testGroupSearchFilterInterpolationSpec, "someUserAttrValue", "someUserAttrValue") + }), expectedGroupSearchPageSize). + Return(exampleGroupSearchResult, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + bindEndUserMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) + }, + wantAuthResponse: expectedAuthResponse(nil), + }, + { + name: "when the UserAttributeForFilter is set to something other than dn but groups scope is not granted so skips validating UserAttributeForFilter attribute value", + username: testUpstreamUsername, + password: testUpstreamPassword, + grantedScopes: []string{}, // no groups scope + providerConfig: providerConfig(func(p *ProviderConfig) { + p.GroupSearch.UserAttributeForFilter = "someUserAttrName" + }), + searchMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) { + // need to additionally ask for the attribute when performing user search + r.Attributes = []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute, "someUserAttrName"} + })).Return(exampleUserSearchResult, nil).Times(1) // result does not contain someUserAttrName, but does not matter since group search is skipped + conn.EXPECT().Close().Times(1) + }, + bindEndUserMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) + }, + wantAuthResponse: expectedAuthResponse(func(r *authenticators.Response) { + info := r.User.(*user.DefaultInfo) + info.Groups = nil + }), + }, + { + name: "when the UserAttributeForFilter is set to something other than dn but that attribute is not returned by the user search", + username: testUpstreamUsername, + password: testUpstreamPassword, + providerConfig: providerConfig(func(p *ProviderConfig) { + p.GroupSearch.UserAttributeForFilter = "someUserAttrName" + }), + searchMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) { + // need to additionally ask for the attribute when performing user search + r.Attributes = []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute, "someUserAttrName"} + })).Return(exampleUserSearchResult, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantError: testutil.WantExactErrorString("found 0 values for attribute \"someUserAttrName\" while searching for user \"some-upstream-username\", but expected 1 result"), + }, + { + name: "when the UserAttributeForFilter is set to something other than dn but that attribute is returned by the user search with an empty value", + username: testUpstreamUsername, + password: testUpstreamPassword, + providerConfig: providerConfig(func(p *ProviderConfig) { + p.GroupSearch.UserAttributeForFilter = "someUserAttrName" + }), + searchMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) { + // need to additionally ask for the attribute when performing user search + r.Attributes = []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute, "someUserAttrName"} + })).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: testUserSearchResultDNValue, + Attributes: []*ldap.EntryAttribute{ + ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testUserSearchResultUsernameAttributeValue}), + ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testUserSearchResultUIDAttributeValue}), + // additionally get back the attr from the user search + ldap.NewEntryAttribute("someUserAttrName", []string{""}), // empty value! + }, + }, + }, + }, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantError: testutil.WantExactErrorString("found empty value for attribute \"someUserAttrName\" while searching for user \"some-upstream-username\", but expected value to be non-empty"), + }, + { + name: "when the UserAttributeForFilter is set to something other than dn but that attribute is returned by the user search with multiple values", + username: testUpstreamUsername, + password: testUpstreamPassword, + providerConfig: providerConfig(func(p *ProviderConfig) { + p.GroupSearch.UserAttributeForFilter = "someUserAttrName" + }), + searchMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) { + // need to additionally ask for the attribute when performing user search + r.Attributes = []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute, "someUserAttrName"} + })).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: testUserSearchResultDNValue, + Attributes: []*ldap.EntryAttribute{ + ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testUserSearchResultUsernameAttributeValue}), + ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testUserSearchResultUIDAttributeValue}), + // additionally get back the attr from the user search + ldap.NewEntryAttribute("someUserAttrName", []string{"someUserAttrValue1", "someUserAttrValue2"}), // oops, multiple values! + }, + }, + }, + }, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantError: testutil.WantExactErrorString("found 2 values for attribute \"someUserAttrName\" while searching for user \"some-upstream-username\", but expected 1 result"), + }, + { + name: "when the UserAttributeForFilter is set to dn, it should act the same as when it is not set", + username: testUpstreamUsername, + password: testUpstreamPassword, + providerConfig: providerConfig(func(p *ProviderConfig) { + p.GroupSearch.UserAttributeForFilter = "dn" + }), + searchMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedUserSearch(nil)).Return(exampleUserSearchResult, nil).Times(1) + conn.EXPECT().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). + Return(exampleGroupSearchResult, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + bindEndUserMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) + }, + wantAuthResponse: expectedAuthResponse(nil), + }, + { + name: "when the UserAttributeForFilter is set to something other than dn and the value of that attr contains special characters which need to be escaped for an LDAP filter", + username: testUpstreamUsername, + password: testUpstreamPassword, + providerConfig: providerConfig(func(p *ProviderConfig) { + p.GroupSearch.UserAttributeForFilter = "someUserAttrName" + }), + searchMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) { + // need to additionally ask for the attribute when performing user search + r.Attributes = []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute, "someUserAttrName"} + })).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: testUserSearchResultDNValue, + Attributes: []*ldap.EntryAttribute{ + ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testUserSearchResultUsernameAttributeValue}), + ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testUserSearchResultUIDAttributeValue}), + // additionally get back the attr from the user search + ldap.NewEntryAttribute("someUserAttrName", []string{"someUserAttrValue&(abc)"}), + }, + }, + }, + }, nil).Times(1) + conn.EXPECT().SearchWithPaging(expectedGroupSearch(func(r *ldap.SearchRequest) { + // need to interpolate the attr's value into the filter instead of interpolating the default (user's dn) + r.Filter = fmt.Sprintf(testGroupSearchFilterInterpolationSpec, `someUserAttrValue&\28abc\29`, `someUserAttrValue&\28abc\29`) + }), expectedGroupSearchPageSize). + Return(exampleGroupSearchResult, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + bindEndUserMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) + }, + wantAuthResponse: expectedAuthResponse(nil), + }, + { + name: "when the UserAttributeForFilter is set to something other than dn but the group search filter is not set", + username: testUpstreamUsername, + password: testUpstreamPassword, + providerConfig: providerConfig(func(p *ProviderConfig) { + p.GroupSearch.Filter = "" + p.GroupSearch.UserAttributeForFilter = "someUserAttrName" + }), + searchMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) { + // need to additionally ask for the attribute when performing user search + r.Attributes = []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute, "someUserAttrName"} + })).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: testUserSearchResultDNValue, + Attributes: []*ldap.EntryAttribute{ + ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testUserSearchResultUsernameAttributeValue}), + ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testUserSearchResultUIDAttributeValue}), + // additionally get back the attr from the user search + ldap.NewEntryAttribute("someUserAttrName", []string{"someUserAttrValue&(abc)"}), + }, + }, + }, + }, nil).Times(1) + conn.EXPECT().SearchWithPaging(expectedGroupSearch(func(r *ldap.SearchRequest) { + // need to interpolate the attr's value into the filter instead of interpolating the default (user's dn) + r.Filter = `(member=someUserAttrValue&\28abc\29)` // note that "member={}" is the default group search filter + }), expectedGroupSearchPageSize). + Return(exampleGroupSearchResult, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + bindEndUserMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) + }, + wantAuthResponse: expectedAuthResponse(nil), + }, { name: "when dial fails", username: testUpstreamUsername, @@ -1486,7 +1718,8 @@ func TestUpstreamRefresh(t *testing.T) { }), setupMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(happyPathUserSearchResult, nil).Times(1) // note that group search is not expected + conn.EXPECT().Search(expectedUserSearch(nil)).Return(happyPathUserSearchResult, nil).Times(1) + // note that group search is not expected conn.EXPECT().Close().Times(1) }, wantGroups: nil, // do not update groups @@ -1497,11 +1730,240 @@ func TestUpstreamRefresh(t *testing.T) { setupMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) conn.EXPECT().Search(expectedUserSearch(nil)).Return(happyPathUserSearchResult, nil).Times(1) + // note that group search is not expected conn.EXPECT().Close().Times(1) }, grantedScopes: []string{}, wantGroups: nil, }, + { + name: "happy path where group search is configured but skipGroupRefresh is set, when the UserAttributeForFilter is set to something other than dn, still skips group refresh, and skips validating UserAttributeForFilter attribute value", + providerConfig: providerConfig(func(p *ProviderConfig) { + p.GroupSearch.SkipGroupRefresh = true + p.GroupSearch.UserAttributeForFilter = "someUserAttrName" + }), + setupMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) { + // need to additionally ask for the attribute when performing user search + r.Attributes = []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute, "someUserAttrName", pwdLastSetAttribute} + })).Return(happyPathUserSearchResult, nil).Times(1) + // note that group search is not expected + conn.EXPECT().Close().Times(1) + }, + wantGroups: nil, // do not update groups + }, + { + name: "happy path where group search is configured but groups scope isn't included, when the UserAttributeForFilter is set to something other than dn, still skips group refresh, and skips validating UserAttributeForFilter attribute value", + providerConfig: providerConfig(func(p *ProviderConfig) { + p.GroupSearch.UserAttributeForFilter = "someUserAttrName" + }), + setupMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) { + // need to additionally ask for the attribute when performing user search + r.Attributes = []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute, "someUserAttrName", pwdLastSetAttribute} + })).Return(happyPathUserSearchResult, nil).Times(1) + // note that group search is not expected + conn.EXPECT().Close().Times(1) + }, + grantedScopes: []string{}, + wantGroups: nil, + }, + { + name: "happy path when the UserAttributeForFilter is set to something other than dn", + providerConfig: providerConfig(func(p *ProviderConfig) { + p.GroupSearch.UserAttributeForFilter = "someUserAttrName" + }), + setupMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) { + // need to additionally ask for the attribute when performing user search + r.Attributes = []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute, "someUserAttrName", pwdLastSetAttribute} + })).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: testUserSearchResultDNValue, + Attributes: []*ldap.EntryAttribute{ + ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testUserSearchResultUsernameAttributeValue}), + { + Name: testUserSearchUIDAttribute, + ByteValues: [][]byte{[]byte(testUserSearchResultUIDAttributeValue)}, + }, + { + Name: pwdLastSetAttribute, + Values: []string{"132801740800000000"}, + ByteValues: [][]byte{[]byte("132801740800000000")}, + }, + // additionally get back the attr from the user search + ldap.NewEntryAttribute("someUserAttrName", []string{"someUserAttrValue"}), + }, + }, + }, + }, nil).Times(1) + conn.EXPECT().SearchWithPaging(expectedGroupSearch(func(r *ldap.SearchRequest) { + // need to interpolate the attr's value into the filter instead of interpolating the default (user's dn) + r.Filter = fmt.Sprintf(testGroupSearchFilterInterpolationSpec, "someUserAttrValue", "someUserAttrValue") + }), expectedGroupSearchPageSize).Return(happyPathGroupSearchResult, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantGroups: []string{testGroupSearchResultGroupNameAttributeValue1, testGroupSearchResultGroupNameAttributeValue2}, + }, + { + name: "happy path when the UserAttributeForFilter is set to something other than dn but the group search filter is not set", + providerConfig: providerConfig(func(p *ProviderConfig) { + p.GroupSearch.Filter = "" + p.GroupSearch.UserAttributeForFilter = "someUserAttrName" + }), + setupMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) { + // need to additionally ask for the attribute when performing user search + r.Attributes = []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute, "someUserAttrName", pwdLastSetAttribute} + })).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: testUserSearchResultDNValue, + Attributes: []*ldap.EntryAttribute{ + ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testUserSearchResultUsernameAttributeValue}), + { + Name: testUserSearchUIDAttribute, + ByteValues: [][]byte{[]byte(testUserSearchResultUIDAttributeValue)}, + }, + { + Name: pwdLastSetAttribute, + Values: []string{"132801740800000000"}, + ByteValues: [][]byte{[]byte("132801740800000000")}, + }, + // additionally get back the attr from the user search + ldap.NewEntryAttribute("someUserAttrName", []string{"someUserAttrValue&(abc)"}), + }, + }, + }, + }, nil).Times(1) + conn.EXPECT().SearchWithPaging(expectedGroupSearch(func(r *ldap.SearchRequest) { + // need to interpolate the attr's value into the filter instead of interpolating the default (user's dn) + r.Filter = `(member=someUserAttrValue&\28abc\29)` // member={} is the default, and special chars are escaped + }), expectedGroupSearchPageSize).Return(happyPathGroupSearchResult, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantGroups: []string{testGroupSearchResultGroupNameAttributeValue1, testGroupSearchResultGroupNameAttributeValue2}, + }, + { + name: "when the UserAttributeForFilter is set to something other than dn but that attribute is not returned by the user search", + providerConfig: providerConfig(func(p *ProviderConfig) { + p.GroupSearch.UserAttributeForFilter = "someUserAttrName" + }), + setupMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) { + // need to additionally ask for the attribute when performing user search + r.Attributes = []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute, "someUserAttrName", pwdLastSetAttribute} + })).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: testUserSearchResultDNValue, + Attributes: []*ldap.EntryAttribute{ + ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testUserSearchResultUsernameAttributeValue}), + { + Name: testUserSearchUIDAttribute, + ByteValues: [][]byte{[]byte(testUserSearchResultUIDAttributeValue)}, + }, + { + Name: pwdLastSetAttribute, + Values: []string{"132801740800000000"}, + ByteValues: [][]byte{[]byte("132801740800000000")}, + }, + // did not return "someUserAttrName" attribute + }, + }, + }, + }, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantErr: "found 0 values for attribute \"someUserAttrName\" while searching for user \"some-upstream-username-value\", but expected 1 result", + }, + { + name: "when the UserAttributeForFilter is set to something other than dn but that attribute is returned by the user search with an empty value", + providerConfig: providerConfig(func(p *ProviderConfig) { + p.GroupSearch.UserAttributeForFilter = "someUserAttrName" + }), + setupMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) { + // need to additionally ask for the attribute when performing user search + r.Attributes = []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute, "someUserAttrName", pwdLastSetAttribute} + })).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: testUserSearchResultDNValue, + Attributes: []*ldap.EntryAttribute{ + ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testUserSearchResultUsernameAttributeValue}), + { + Name: testUserSearchUIDAttribute, + ByteValues: [][]byte{[]byte(testUserSearchResultUIDAttributeValue)}, + }, + { + Name: pwdLastSetAttribute, + Values: []string{"132801740800000000"}, + ByteValues: [][]byte{[]byte("132801740800000000")}, + }, + ldap.NewEntryAttribute("someUserAttrName", []string{""}), + }, + }, + }, + }, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantErr: "found empty value for attribute \"someUserAttrName\" while searching for user \"some-upstream-username-value\", but expected value to be non-empty", + }, + { + name: "when the UserAttributeForFilter is set to something other than dn but that attribute is returned by the user search with a multiple values", + providerConfig: providerConfig(func(p *ProviderConfig) { + p.GroupSearch.UserAttributeForFilter = "someUserAttrName" + }), + setupMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) { + // need to additionally ask for the attribute when performing user search + r.Attributes = []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute, "someUserAttrName", pwdLastSetAttribute} + })).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: testUserSearchResultDNValue, + Attributes: []*ldap.EntryAttribute{ + ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testUserSearchResultUsernameAttributeValue}), + { + Name: testUserSearchUIDAttribute, + ByteValues: [][]byte{[]byte(testUserSearchResultUIDAttributeValue)}, + }, + { + Name: pwdLastSetAttribute, + Values: []string{"132801740800000000"}, + ByteValues: [][]byte{[]byte("132801740800000000")}, + }, + ldap.NewEntryAttribute("someUserAttrName", []string{"someUserAttrValue1", "someUserAttrValue2"}), + }, + }, + }, + }, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantErr: "found 2 values for attribute \"someUserAttrName\" while searching for user \"some-upstream-username-value\", but expected 1 result", + }, + { + name: "happy path when the UserAttributeForFilter is set to dn, it should act the same as when it is not set", + providerConfig: providerConfig(func(p *ProviderConfig) { + p.GroupSearch.UserAttributeForFilter = "dn" + }), + setupMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedUserSearch(nil)).Return(happyPathUserSearchResult, nil).Times(1) + conn.EXPECT().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize).Return(happyPathGroupSearchResult, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantGroups: []string{testGroupSearchResultGroupNameAttributeValue1, testGroupSearchResultGroupNameAttributeValue2}, + }, { name: "error where dial fails", providerConfig: providerConfig(nil),