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

FrankChen021 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git


The following commit(s) were added to refs/heads/master by this push:
     new 3885a95fc0d feat: Add reverse group lookup for LDAP servers that do 
not return memberOf (#19432)
3885a95fc0d is described below

commit 3885a95fc0d94053af819121dd5e36001052f6df
Author: Jiang Wu <[email protected]>
AuthorDate: Tue Sep 1 14:39:34 2026 +0200

    feat: Add reverse group lookup for LDAP servers that do not return memberOf 
(#19432)
    
    * feat: Add reverse group lookup for LDAP servers that do not return 
memberOf
    
    * feat: Add reverse group lookup for LDAP servers that do not return 
memberOf
    
    * fix: Use JUnit5 for testing LDAPCredentialsValidatorTest
    
    * fix: groupSearch must contain %s placeholder
    
    * fix: StringUtils.replace instead of String.replace
    
    * fix: Change unescaped to literal for spellcheck
---
 .../extensions-core/druid-basic-security.md        |  12 ++
 docs/operations/auth-ldap.md                       |  23 ++-
 .../auth/BasicAuthLdapReverseGroupLookupTest.java  |  40 ++++
 .../auth/LdapReverseGroupLookupAuthResource.java   |  47 +++++
 .../src/test/resources/ldap-configs/bootstrap.ldif |   8 +-
 .../druid/security/basic/BasicAuthLDAPConfig.java  |  65 ++++++
 .../validator/LDAPCredentialsValidator.java        |  84 +++++++-
 .../validator/LDAPCredentialsValidatorTest.java    | 230 +++++++++++++++++----
 website/.spelling                                  |   2 +
 9 files changed, 466 insertions(+), 45 deletions(-)

diff --git a/docs/development/extensions-core/druid-basic-security.md 
b/docs/development/extensions-core/druid-basic-security.md
index ab011669ced..73adb3a80b2 100644
--- a/docs/development/extensions-core/druid-basic-security.md
+++ b/docs/development/extensions-core/druid-basic-security.md
@@ -261,6 +261,18 @@ The valid credentials cache size. The cache uses a LRU 
policy.<br />
 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;**Required**: No<br />
 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;**Default**: 100
 
+**`druid.auth.authenticator.MyBasicLDAPAuthenticator.credentialsValidator.groupBaseDn`**
+
+The base DN for searching LDAP groups. When set together with `groupSearch`, 
Druid performs a reverse group lookup to populate the `memberOf` attribute 
during authentication. This is needed when the LDAP server does not return 
`memberOf` in user search results. If not set, Druid relies on the `memberOf` 
attribute being returned directly by the user search.<br />
+&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;**Required**: No<br />
+&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;**Default**: null
+
+**`druid.auth.authenticator.MyBasicLDAPAuthenticator.credentialsValidator.groupSearch`**
+
+The LDAP search filter for finding groups that contain a user. The filter must 
contain a literal `%s` placeholder, which is replaced with the user's full DN. 
Note that `%%s` does not count, as it is a placeholder escaped for 
`String.format`. For example, `(uniqueMember=%s)` for `groupOfUniqueNames` or 
`(member=%s)` for `groupOfNames`. A filter without the placeholder is rejected 
at startup because it would match all groups under `groupBaseDn` for every 
user.<br />
+&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;**Required**: No<br />
+&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;**Default**: null
+
 **`druid.auth.authenticator.MyBasicLDAPAuthenticator.skipOnFailure`**
 
 If true and the request credential doesn't exists or isn't fully configured in 
the credentials store, the request will proceed to next Authenticator in the 
chain.<br />
diff --git a/docs/operations/auth-ldap.md b/docs/operations/auth-ldap.md
index 1f66b6ee6db..93f6fa73e72 100644
--- a/docs/operations/auth-ldap.md
+++ b/docs/operations/auth-ldap.md
@@ -64,7 +64,7 @@ memberOf: cn=mygroup,ou=groups,dc=example,dc=com
 You use this information to map the LDAP group to Druid roles in a later step. 
 
 :::info
- Druid uses the `memberOf` attribute to determine a group's membership using 
LDAP. If your LDAP server implementation doesn't include this attribute, you 
must complete some additional steps when you [map LDAP groups to Druid 
roles](#map-ldap-groups-to-druid-roles).
+ Druid uses the `memberOf` attribute to determine group membership. If your 
LDAP server does not return this attribute, you can either [map LDAP groups to 
Druid roles](#map-ldap-groups-to-druid-roles) manually or configure a [reverse 
group lookup](#group-search-reverse-lookup-configuration) to resolve groups 
automatically.
 :::
 
 ## Configure Druid for LDAP authentication
@@ -296,6 +296,27 @@ Complete the following steps to set up LDAPS for Druid. 
See [Configuration refer
 5. Restart Druid.
 
 
+## Group search reverse lookup configuration
+
+By default, Druid reads the `memberOf` attribute from the LDAP user entry to 
determine group membership. Some LDAP servers do not return `memberOf` because 
the feature is not enabled, it is stored as an operational attribute that Java 
JNDI cannot retrieve, or groups only store membership on the group entry 
itself. In these cases, group-based authorization denies all requests because 
no groups are found.
+
+To resolve this, configure a reverse group lookup so that Druid searches group 
entries to find which groups contain the user. Add the following properties to 
your `common.runtime.properties`:
+
+```
+druid.auth.authenticator.ldap.credentialsValidator.groupBaseDn=ou=Groups,dc=example,dc=com
+druid.auth.authenticator.ldap.credentialsValidator.groupSearch=(uniqueMember=%s)
+```
+
+Where:
+- `groupBaseDn`: The base DN under which your LDAP groups are stored.
+- `groupSearch`: The LDAP filter to find groups containing a user. The filter 
must contain a literal `%s` placeholder, which is replaced with the user's full 
DN (for example, `uid=myuser,ou=People,dc=example,dc=com`). Use 
`(uniqueMember=%s)` for `groupOfUniqueNames` or `(member=%s)` for 
`groupOfNames`. Druid fails to start if the filter does not contain `%s`, since 
such a filter would match every group under `groupBaseDn` and grant all mapped 
group roles to every user.
+
+When these properties are set and the user search does not return a `memberOf` 
attribute, Druid automatically performs the reverse group lookup and populates 
`memberOf` in the authentication result. The authorizer processes these groups 
as usual, requiring no additional configuration.
+
+:::info
+ If your LDAP server does return `memberOf` directly, the reverse lookup is 
skipped.
+:::
+
 ## Troubleshooting tips
 
 The following are some ideas to help you troubleshoot issues with LDAP and 
LDAPS.
diff --git 
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/auth/BasicAuthLdapReverseGroupLookupTest.java
 
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/auth/BasicAuthLdapReverseGroupLookupTest.java
new file mode 100644
index 00000000000..583b28a139f
--- /dev/null
+++ 
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/auth/BasicAuthLdapReverseGroupLookupTest.java
@@ -0,0 +1,40 @@
+/*
+ * 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 org.apache.druid.testing.embedded.auth;
+
+import org.apache.druid.testing.embedded.EmbeddedResource;
+
+/**
+ * Runs the same LDAP auth tests as {@link BasicAuthLdapConfigurationTest} but 
with
+ * the reverse group lookup feature enabled ({@code groupBaseDn} and {@code 
groupSearch}).
+ *
+ * OpenLDAP (used in the test container) does not return {@code memberOf} in 
user search
+ * results by default. Without reverse group lookup, group-based authorization 
would fail
+ * because the {@code LDAPRoleProvider} cannot resolve group memberships. This 
test verifies
+ * that enabling reverse group lookup allows all group-based authorization to 
work correctly.
+ */
+public class BasicAuthLdapReverseGroupLookupTest extends 
BasicAuthLdapConfigurationTest
+{
+  @Override
+  protected EmbeddedResource getAuthResource()
+  {
+    return new LdapReverseGroupLookupAuthResource();
+  }
+}
diff --git 
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/auth/LdapReverseGroupLookupAuthResource.java
 
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/auth/LdapReverseGroupLookupAuthResource.java
new file mode 100644
index 00000000000..910ff2ee016
--- /dev/null
+++ 
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/auth/LdapReverseGroupLookupAuthResource.java
@@ -0,0 +1,47 @@
+/*
+ * 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 org.apache.druid.testing.embedded.auth;
+
+import org.apache.druid.java.util.common.StringUtils;
+import org.apache.druid.testing.embedded.EmbeddedDruidCluster;
+
+/**
+ * LDAP auth resource that additionally configures reverse group lookup via
+ * {@code groupBaseDn} and {@code groupSearch}. This is needed for LDAP servers
+ * (such as OpenLDAP) that do not return the {@code memberOf} attribute in user
+ * search results.
+ */
+public class LdapReverseGroupLookupAuthResource extends LdapAuthResource
+{
+  private static final String AUTHENTICATOR_NAME = "ldap";
+
+  @Override
+  public void onStarted(EmbeddedDruidCluster cluster)
+  {
+    super.onStarted(cluster);
+    cluster.addCommonProperty(
+        
StringUtils.format("druid.auth.authenticator.%s.credentialsValidator.groupBaseDn",
 AUTHENTICATOR_NAME),
+        "ou=Groups,dc=example,dc=org"
+    ).addCommonProperty(
+        
StringUtils.format("druid.auth.authenticator.%s.credentialsValidator.groupSearch",
 AUTHENTICATOR_NAME),
+        "(uniqueMember=%s)"
+    );
+  }
+}
diff --git a/embedded-tests/src/test/resources/ldap-configs/bootstrap.ldif 
b/embedded-tests/src/test/resources/ldap-configs/bootstrap.ldif
index aec519658ac..a1caa3f533e 100644
--- a/embedded-tests/src/test/resources/ldap-configs/bootstrap.ldif
+++ b/embedded-tests/src/test/resources/ldap-configs/bootstrap.ldif
@@ -126,13 +126,13 @@ description: druidGroup users
 uniqueMember: uid=druid,ou=Users,dc=example,dc=org
 
 dn: uid=stateOnlyNoLdapGroup,ou=Users,dc=example,dc=org
-uid: druid
-cn: druid
-sn: druid
+uid: stateOnlyNoLdapGroup
+cn: stateOnlyNoLdapGroup
+sn: stateOnlyNoLdapGroup
 objectClass: top
 objectClass: posixAccount
 objectClass: inetOrgPerson
-homeDirectory: /home/druid
+homeDirectory: /home/stateOnlyNoLdapGroup
 uidNumber: 7
 gidNumber: 7
 userPassword: helloworld
diff --git 
a/extensions-core/druid-basic-security/src/main/java/org/apache/druid/security/basic/BasicAuthLDAPConfig.java
 
b/extensions-core/druid-basic-security/src/main/java/org/apache/druid/security/basic/BasicAuthLDAPConfig.java
index 2696fa07601..a7806a52dad 100644
--- 
a/extensions-core/druid-basic-security/src/main/java/org/apache/druid/security/basic/BasicAuthLDAPConfig.java
+++ 
b/extensions-core/druid-basic-security/src/main/java/org/apache/druid/security/basic/BasicAuthLDAPConfig.java
@@ -19,8 +19,12 @@
 
 package org.apache.druid.security.basic;
 
+import org.apache.druid.error.InvalidInput;
+import org.apache.druid.java.util.common.StringUtils;
 import org.apache.druid.metadata.PasswordProvider;
 
+import javax.annotation.Nullable;
+
 public class BasicAuthLDAPConfig
 {
   private final String url;
@@ -33,6 +37,10 @@ public class BasicAuthLDAPConfig
   private final Integer credentialVerifyDuration;
   private final Integer credentialMaxDuration;
   private final Integer credentialCacheSize;
+  @Nullable
+  private final String groupBaseDn;
+  @Nullable
+  private final String groupSearch;
 
   public BasicAuthLDAPConfig(
       final String url,
@@ -46,6 +54,37 @@ public class BasicAuthLDAPConfig
       final Integer credentialMaxDuration,
       final Integer credentialCacheSize
   )
+  {
+    this(
+        url,
+        bindUser,
+        bindPassword,
+        baseDn,
+        userSearch,
+        userAttribute,
+        credentialIterations,
+        credentialVerifyDuration,
+        credentialMaxDuration,
+        credentialCacheSize,
+        null,
+        null
+    );
+  }
+
+  public BasicAuthLDAPConfig(
+      final String url,
+      final String bindUser,
+      final PasswordProvider bindPassword,
+      final String baseDn,
+      final String userSearch,
+      final String userAttribute,
+      final int credentialIterations,
+      final Integer credentialVerifyDuration,
+      final Integer credentialMaxDuration,
+      final Integer credentialCacheSize,
+      @Nullable final String groupBaseDn,
+      @Nullable final String groupSearch
+  )
   {
     this.url = url;
     this.bindUser = bindUser;
@@ -57,6 +96,15 @@ public class BasicAuthLDAPConfig
     this.credentialVerifyDuration = credentialVerifyDuration;
     this.credentialMaxDuration = credentialMaxDuration;
     this.credentialCacheSize = credentialCacheSize;
+    this.groupBaseDn = groupBaseDn;
+    this.groupSearch = groupSearch;
+
+    if (groupSearch != null && !StringUtils.replace(groupSearch, "%%", 
"").contains("%s")) {
+      throw InvalidInput.exception(
+          "groupSearch filter[%s] must contain the placeholder[%%s] for the 
user DN.",
+          groupSearch
+      );
+    }
   }
 
   public String getUrl()
@@ -108,4 +156,21 @@ public class BasicAuthLDAPConfig
   {
     return credentialCacheSize;
   }
+
+  @Nullable
+  public String getGroupBaseDn()
+  {
+    return groupBaseDn;
+  }
+
+  @Nullable
+  public String getGroupSearch()
+  {
+    return groupSearch;
+  }
+
+  public boolean isGroupSearchConfigured()
+  {
+    return groupBaseDn != null && groupSearch != null;
+  }
 }
diff --git 
a/extensions-core/druid-basic-security/src/main/java/org/apache/druid/security/basic/authentication/validator/LDAPCredentialsValidator.java
 
b/extensions-core/druid-basic-security/src/main/java/org/apache/druid/security/basic/authentication/validator/LDAPCredentialsValidator.java
index 2c32ee231a4..db6f585d747 100644
--- 
a/extensions-core/druid-basic-security/src/main/java/org/apache/druid/security/basic/authentication/validator/LDAPCredentialsValidator.java
+++ 
b/extensions-core/druid-basic-security/src/main/java/org/apache/druid/security/basic/authentication/validator/LDAPCredentialsValidator.java
@@ -41,6 +41,8 @@ import javax.naming.Context;
 import javax.naming.Name;
 import javax.naming.NamingEnumeration;
 import javax.naming.NamingException;
+import javax.naming.directory.BasicAttribute;
+import javax.naming.directory.BasicAttributes;
 import javax.naming.directory.DirContext;
 import javax.naming.directory.InitialDirContext;
 import javax.naming.directory.SearchControls;
@@ -77,7 +79,9 @@ public class LDAPCredentialsValidator implements 
CredentialsValidator
       @JsonProperty("credentialIterations") Integer credentialIterations,
       @JsonProperty("credentialVerifyDuration") Integer 
credentialVerifyDuration,
       @JsonProperty("credentialMaxDuration") Integer credentialMaxDuration,
-      @JsonProperty("credentialCacheSize") Integer credentialCacheSize
+      @JsonProperty("credentialCacheSize") Integer credentialCacheSize,
+      @JsonProperty("groupBaseDn") String groupBaseDn,
+      @JsonProperty("groupSearch") String groupSearch
   )
   {
     this.ldapConfig = new BasicAuthLDAPConfig(
@@ -90,7 +94,9 @@ public class LDAPCredentialsValidator implements 
CredentialsValidator
         credentialIterations == null ? BasicAuthUtils.DEFAULT_KEY_ITERATIONS : 
credentialIterations,
         credentialVerifyDuration == null ? 
BasicAuthUtils.DEFAULT_CREDENTIAL_VERIFY_DURATION_SECONDS : 
credentialVerifyDuration,
         credentialMaxDuration == null ? 
BasicAuthUtils.DEFAULT_CREDENTIAL_MAX_DURATION_SECONDS : credentialMaxDuration,
-        credentialCacheSize == null ? 
BasicAuthUtils.DEFAULT_CREDENTIAL_CACHE_SIZE : credentialCacheSize
+        credentialCacheSize == null ? 
BasicAuthUtils.DEFAULT_CREDENTIAL_CACHE_SIZE : credentialCacheSize,
+        groupBaseDn,
+        groupSearch
     );
 
     this.cache = new LruBlockCache(
@@ -200,6 +206,10 @@ public class LDAPCredentialsValidator implements 
CredentialsValidator
         throw new 
BasicSecurityAuthenticationException(Access.DEFAULT_ERROR_MESSAGE);
       }
 
+      if (this.ldapConfig.isGroupSearchConfigured() && 
!hasMemberOfAttribute(userResult)) {
+        enrichWithGroupSearch(userResult);
+      }
+
       byte[] salt = BasicAuthUtils.generateSalt();
       byte[] hash = hashGenerator.getOrComputePasswordHash(password, salt, 
this.ldapConfig.getCredentialIterations());
       LdapUserPrincipal newPrincipal = new LdapUserPrincipal(
@@ -237,15 +247,17 @@ public class LDAPCredentialsValidator implements 
CredentialsValidator
           ldapConfig.getBaseDn(),
           StringUtils.format(ldapConfig.getUserSearch(), encodedUsername),
           sc);
+      final SearchResult userResult;
       try {
         if (!results.hasMore()) {
           return null;
         }
-        return results.next();
+        userResult = results.next();
       }
       finally {
         results.close();
       }
+      return userResult;
     }
     catch (NamingException e) {
       LOG.debug(e, "Unable to find user '%s'", username);
@@ -253,6 +265,72 @@ public class LDAPCredentialsValidator implements 
CredentialsValidator
     }
   }
 
+  private static boolean hasMemberOfAttribute(SearchResult userResult)
+  {
+    return userResult.getAttributes() != null
+        && userResult.getAttributes().get("memberOf") != null;
+  }
+
+  @SuppressWarnings("BanJNDI")
+  private void enrichWithGroupSearch(SearchResult userResult)
+  {
+    final ClassLoader currentClassLoader = 
Thread.currentThread().getContextClassLoader();
+    InitialDirContext dirContext = null;
+    try {
+      
Thread.currentThread().setContextClassLoader(this.getClass().getClassLoader());
+      dirContext = new InitialDirContext(bindProperties(this.ldapConfig));
+
+      final String userDn = userResult.getNameInNamespace();
+      final SearchControls sc = new SearchControls();
+      sc.setSearchScope(SearchControls.SUBTREE_SCOPE);
+      sc.setReturningAttributes(new String[]{"1.1"});
+
+      final String filter = 
StringUtils.format(this.ldapConfig.getGroupSearch(), encodeForLDAP(userDn, 
true));
+      final NamingEnumeration<SearchResult> groupResults = dirContext.search(
+          this.ldapConfig.getGroupBaseDn(),
+          filter,
+          sc
+      );
+
+      final BasicAttribute memberOfAttr = new BasicAttribute("memberOf");
+      try {
+        while (groupResults.hasMore()) {
+          memberOfAttr.add(groupResults.next().getNameInNamespace());
+        }
+      }
+      finally {
+        groupResults.close();
+      }
+
+      if (memberOfAttr.size() > 0) {
+        if (userResult.getAttributes() == null) {
+          userResult.setAttributes(new BasicAttributes(true));
+        }
+        userResult.getAttributes().put(memberOfAttr);
+        LOG.debug(
+            "Populated memberOf for user '%s' with %d groups from reverse 
group search",
+            userDn,
+            memberOfAttr.size()
+        );
+      }
+    }
+    catch (NamingException e) {
+      LOG.error(e, "Exception during reverse group lookup, proceeding without 
group memberships");
+    }
+    finally {
+      try {
+        if (dirContext != null) {
+          dirContext.close();
+        }
+      }
+      catch (Exception ignored) {
+        LOG.warn("Exception closing LDAP context");
+        // ignored
+      }
+      Thread.currentThread().setContextClassLoader(currentClassLoader);
+    }
+  }
+
   boolean validatePassword(BasicAuthLDAPConfig ldapConfig, LdapName userDn, 
char[] password)
   {
     InitialDirContext context = null;
diff --git 
a/extensions-core/druid-basic-security/src/test/java/org/apache/druid/security/authentication/validator/LDAPCredentialsValidatorTest.java
 
b/extensions-core/druid-basic-security/src/test/java/org/apache/druid/security/authentication/validator/LDAPCredentialsValidatorTest.java
index 2bdb2f15459..fdfaf599a6c 100644
--- 
a/extensions-core/druid-basic-security/src/test/java/org/apache/druid/security/authentication/validator/LDAPCredentialsValidatorTest.java
+++ 
b/extensions-core/druid-basic-security/src/test/java/org/apache/druid/security/authentication/validator/LDAPCredentialsValidatorTest.java
@@ -19,11 +19,13 @@
 
 package org.apache.druid.security.authentication.validator;
 
+import org.apache.druid.error.DruidException;
 import org.apache.druid.java.util.common.StringUtils;
 import org.apache.druid.metadata.DefaultPasswordProvider;
 import org.apache.druid.security.basic.BasicAuthLDAPConfig;
 import org.apache.druid.security.basic.BasicAuthUtils;
 import 
org.apache.druid.security.basic.authentication.validator.LDAPCredentialsValidator;
+import org.apache.druid.server.security.AuthenticationResult;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentMatchers;
@@ -32,13 +34,17 @@ import org.mockito.Mockito;
 import javax.naming.Context;
 import javax.naming.NamingEnumeration;
 import javax.naming.NamingException;
+import javax.naming.directory.Attribute;
+import javax.naming.directory.BasicAttributes;
 import javax.naming.directory.SearchControls;
 import javax.naming.directory.SearchResult;
 import javax.naming.ldap.LdapContext;
 import javax.naming.spi.InitialContextFactory;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.Hashtable;
 import java.util.Iterator;
+import java.util.List;
 import java.util.Properties;
 
 public class LDAPCredentialsValidatorTest
@@ -96,56 +102,206 @@ public class LDAPCredentialsValidatorTest
     validator.validateCredentials("ldap", "ldap", "validUser", 
"password".toCharArray());
   }
 
+  private static final BasicAuthLDAPConfig LDAP_CONFIG_WITH_GROUP_SEARCH = new 
BasicAuthLDAPConfig(
+      "ldaps://my-ldap-url",
+      "bindUser",
+      new DefaultPasswordProvider("bindPassword"),
+      "",
+      "",
+      "",
+      BasicAuthUtils.DEFAULT_KEY_ITERATIONS,
+      BasicAuthUtils.DEFAULT_CREDENTIAL_VERIFY_DURATION_SECONDS,
+      BasicAuthUtils.DEFAULT_CREDENTIAL_MAX_DURATION_SECONDS,
+      BasicAuthUtils.DEFAULT_CREDENTIAL_CACHE_SIZE,
+      "ou=Groups,dc=example,dc=org",
+      "(uniqueMember=%s)"
+  );
+
+  @Test
+  public void testValidateCredentialsWithGroupSearch()
+  {
+    Properties properties = new Properties();
+    properties.put(Context.INITIAL_CONTEXT_FACTORY, 
MockGroupSearchContextFactory.class.getName());
+    LDAPCredentialsValidator validator = new LDAPCredentialsValidator(
+        LDAP_CONFIG_WITH_GROUP_SEARCH,
+        new LDAPCredentialsValidator.LruBlockCache(
+            3600,
+            3600,
+            100
+        ),
+        properties
+    );
+    final AuthenticationResult result =
+        validator.validateCredentials("ldap", "ldap", "validUser", 
"password".toCharArray());
+    Assertions.assertNotNull(result);
+    Assertions.assertNotNull(result.getContext());
+
+    final Object searchResultObj = 
result.getContext().get(BasicAuthUtils.SEARCH_RESULT_CONTEXT_KEY);
+    Assertions.assertNotNull(searchResultObj);
+    Assertions.assertInstanceOf(SearchResult.class, searchResultObj);
+
+    final SearchResult sr = (SearchResult) searchResultObj;
+    final Attribute memberOf = sr.getAttributes().get("memberOf");
+    Assertions.assertNotNull(memberOf, "memberOf should be populated by 
reverse group search");
+    Assertions.assertEquals(2, memberOf.size());
+  }
+
+  @Test
+  public void testGroupSearchWithoutUserDnPlaceholderIsRejected()
+  {
+    // Filters without an unescaped %s, including escaped sequences that 
String.format emits literally
+    final List<String> invalidFilters = Arrays.asList(
+        "(uniqueMember=*)",
+        "(uniqueMember=%%s*)",
+        "(uniqueMember=%%%%s)",
+        "(uniqueMember=%d)"
+    );
+    for (String groupSearch : invalidFilters) {
+      final DruidException e = Assertions.assertThrows(
+          DruidException.class,
+          () -> configWithGroupSearch(groupSearch),
+          groupSearch
+      );
+      Assertions.assertEquals(DruidException.Category.INVALID_INPUT, 
e.getCategory());
+      Assertions.assertEquals(
+          StringUtils.format("groupSearch filter[%s] must contain the 
placeholder[%%s] for the user DN.", groupSearch),
+          e.getMessage()
+      );
+    }
+  }
+
+  @Test
+  public void testGroupSearchWithUserDnPlaceholderIsAccepted()
+  {
+    final List<String> validFilters = Arrays.asList(
+        "(uniqueMember=%s)",
+        "(member=%s)",
+        "(&(uniqueMember=%s)(cn=%%s))",
+        "(uniqueMember=%%%s)"
+    );
+    for (String groupSearch : validFilters) {
+      Assertions.assertEquals(groupSearch, 
configWithGroupSearch(groupSearch).getGroupSearch());
+    }
+  }
+
+  private static BasicAuthLDAPConfig configWithGroupSearch(String groupSearch)
+  {
+    return new BasicAuthLDAPConfig(
+        "ldaps://my-ldap-url",
+        "bindUser",
+        new DefaultPasswordProvider("bindPassword"),
+        "",
+        "",
+        "",
+        BasicAuthUtils.DEFAULT_KEY_ITERATIONS,
+        BasicAuthUtils.DEFAULT_CREDENTIAL_VERIFY_DURATION_SECONDS,
+        BasicAuthUtils.DEFAULT_CREDENTIAL_MAX_DURATION_SECONDS,
+        BasicAuthUtils.DEFAULT_CREDENTIAL_CACHE_SIZE,
+        "ou=Groups,dc=example,dc=org",
+        groupSearch
+    );
+  }
+
+  public static class MockGroupSearchContextFactory implements 
InitialContextFactory
+  {
+    @SuppressWarnings("BanJNDI")
+    @Override
+    public Context getInitialContext(Hashtable<?, ?> environment) throws 
NamingException
+    {
+      final LdapContext context = Mockito.mock(LdapContext.class);
+
+      // User search result with no memberOf attribute
+      final String encodedUsername = 
LDAPCredentialsValidator.encodeForLDAP("validUser", true);
+      final BasicAttributes userAttrs = new BasicAttributes(true);
+      userAttrs.put("uid", "validUser");
+      final SearchResult userResult = new SearchResult(
+          "uid=validUser,ou=Users,dc=example,dc=org",
+          null,
+          userAttrs
+      );
+      
userResult.setNameInNamespace("uid=validUser,ou=Users,dc=example,dc=org");
+      final Iterator<SearchResult> userResults = 
Collections.singletonList(userResult).iterator();
+
+      Mockito.when(
+          context.search(
+              ArgumentMatchers.eq(LDAP_CONFIG_WITH_GROUP_SEARCH.getBaseDn()),
+              
ArgumentMatchers.eq(StringUtils.format(LDAP_CONFIG_WITH_GROUP_SEARCH.getUserSearch(),
 encodedUsername)),
+              ArgumentMatchers.any(SearchControls.class))
+      ).thenReturn(makeNamingEnum(userResults));
+
+      final SearchResult group1 = new 
SearchResult("cn=admins,ou=Groups,dc=example,dc=org", null, new 
BasicAttributes(true));
+      group1.setNameInNamespace("cn=admins,ou=Groups,dc=example,dc=org");
+      final SearchResult group2 = new 
SearchResult("cn=developers,ou=Groups,dc=example,dc=org", null, new 
BasicAttributes(true));
+      group2.setNameInNamespace("cn=developers,ou=Groups,dc=example,dc=org");
+      final List<SearchResult> groupList = Arrays.asList(group1, group2);
+      final Iterator<SearchResult> groupResults = groupList.iterator();
+
+      final String escapedDn = 
LDAPCredentialsValidator.encodeForLDAP("uid=validUser,ou=Users,dc=example,dc=org",
 true);
+      Mockito.when(
+          context.search(
+              
ArgumentMatchers.eq(LDAP_CONFIG_WITH_GROUP_SEARCH.getGroupBaseDn()),
+              
ArgumentMatchers.eq(StringUtils.format(LDAP_CONFIG_WITH_GROUP_SEARCH.getGroupSearch(),
 escapedDn)),
+              ArgumentMatchers.any(SearchControls.class))
+      ).thenReturn(makeNamingEnum(groupResults));
+
+      return context;
+    }
+  }
+
+  private static NamingEnumeration<SearchResult> makeNamingEnum(final 
Iterator<SearchResult> iter)
+  {
+    return new NamingEnumeration<SearchResult>()
+    {
+      @Override
+      public SearchResult next()
+      {
+        return iter.next();
+      }
+
+      @Override
+      public boolean hasMore()
+      {
+        return iter.hasNext();
+      }
+
+      @Override
+      public void close()
+      {
+      }
+
+      @Override
+      public boolean hasMoreElements()
+      {
+        return iter.hasNext();
+      }
+
+      @Override
+      public SearchResult nextElement()
+      {
+        return iter.next();
+      }
+    };
+  }
+
   public static class MockContextFactory implements InitialContextFactory
   {
-    @SuppressWarnings("BanJNDI") // False positive: usage here is a mock in 
tests.
+    @SuppressWarnings("BanJNDI")
     @Override
     public Context getInitialContext(Hashtable<?, ?> environment) throws 
NamingException
     {
-      LdapContext context = Mockito.mock(LdapContext.class);
+      final LdapContext context = Mockito.mock(LdapContext.class);
 
-      String encodedUsername = 
LDAPCredentialsValidator.encodeForLDAP("validUser", true);
-      SearchResult result = Mockito.mock(SearchResult.class);
+      final String encodedUsername = 
LDAPCredentialsValidator.encodeForLDAP("validUser", true);
+      final SearchResult result = Mockito.mock(SearchResult.class);
       
Mockito.when(result.getNameInNamespace()).thenReturn("uid=user,ou=Users,dc=example,dc=org");
-      Iterator<SearchResult> results = 
Collections.singletonList(result).iterator();
+      final Iterator<SearchResult> results = 
Collections.singletonList(result).iterator();
 
       Mockito.when(
           context.search(
               ArgumentMatchers.eq(LDAP_CONFIG.getBaseDn()),
               
ArgumentMatchers.eq(StringUtils.format(LDAP_CONFIG.getUserSearch(), 
encodedUsername)),
               ArgumentMatchers.any(SearchControls.class))
-      ).thenReturn(new NamingEnumeration<>()
-      {
-        @Override
-        public SearchResult next()
-        {
-          return results.next();
-        }
-
-        @Override
-        public boolean hasMore()
-        {
-          return results.hasNext();
-        }
-
-        @Override
-        public void close()
-        {
-          // No-op
-        }
-
-        @Override
-        public boolean hasMoreElements()
-        {
-          return results.hasNext();
-        }
-
-        @Override
-        public SearchResult nextElement()
-        {
-          return results.next();
-        }
-      });
+      ).thenReturn(makeNamingEnum(results));
 
       return context;
     }
diff --git a/website/.spelling b/website/.spelling
index 665f7b5197f..0689e3c47a6 100644
--- a/website/.spelling
+++ b/website/.spelling
@@ -74,6 +74,7 @@ CUME_DIST
 DDL
 DENSE_RANK
 DML
+DN
 DNS
 DRUIDVERSION
 DataSketches
@@ -162,6 +163,7 @@ JDK7
 JDK8
 JKS
 jks
+JNDI
 JMX
 JRE
 JS


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to