Skip to content

Commit 05053ed

Browse files
committed
feat: Add reverse group lookup for LDAP servers that do not return memberOf
1 parent 09e5e93 commit 05053ed

8 files changed

Lines changed: 394 additions & 41 deletions

File tree

docs/development/extensions-core/druid-basic-security.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,18 @@ The valid credentials cache size. The cache uses a LRU policy.<br />
261261
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;**Required**: No<br />
262262
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;**Default**: 100
263263

264+
**`druid.auth.authenticator.MyBasicLDAPAuthenticator.credentialsValidator.groupBaseDn`**
265+
266+
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 />
267+
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;**Required**: No<br />
268+
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;**Default**: null
269+
270+
**`druid.auth.authenticator.MyBasicLDAPAuthenticator.credentialsValidator.groupSearch`**
271+
272+
The LDAP search filter for finding groups that contain a user. The `%s` placeholder is replaced with the user's full DN. For example, `(uniqueMember=%s)` for `groupOfUniqueNames` or `(member=%s)` for `groupOfNames`.<br />
273+
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;**Required**: No<br />
274+
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;**Default**: null
275+
264276
**`druid.auth.authenticator.MyBasicLDAPAuthenticator.skipOnFailure`**
265277

266278
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 />

docs/operations/auth-ldap.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ memberOf: cn=mygroup,ou=groups,dc=example,dc=com
6464
You use this information to map the LDAP group to Druid roles in a later step.
6565

6666
:::info
67-
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).
67+
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.
6868
:::
6969

7070
## Configure Druid for LDAP authentication
@@ -296,6 +296,27 @@ Complete the following steps to set up LDAPS for Druid. See [Configuration refer
296296
5. Restart Druid.
297297

298298

299+
## Group search reverse lookup configuration
300+
301+
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.
302+
303+
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`:
304+
305+
```
306+
druid.auth.authenticator.ldap.credentialsValidator.groupBaseDn=ou=Groups,dc=example,dc=com
307+
druid.auth.authenticator.ldap.credentialsValidator.groupSearch=(uniqueMember=%s)
308+
```
309+
310+
Where:
311+
- `groupBaseDn`: The base DN under which your LDAP groups are stored.
312+
- `groupSearch`: The LDAP filter to find groups containing a user. The `%s` placeholder 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`.
313+
314+
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.
315+
316+
:::info
317+
If your LDAP server does return `memberOf` directly, the reverse lookup is skipped.
318+
:::
319+
299320
## Troubleshooting tips
300321

301322
The following are some ideas to help you troubleshoot issues with LDAP and LDAPS.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
package org.apache.druid.testing.embedded.auth;
21+
22+
import org.apache.druid.testing.embedded.EmbeddedResource;
23+
24+
/**
25+
* Runs the same LDAP auth tests as {@link BasicAuthLdapConfigurationTest} but with
26+
* the reverse group lookup feature enabled ({@code groupBaseDn} and {@code groupSearch}).
27+
*
28+
* OpenLDAP (used in the test container) does not return {@code memberOf} in user search
29+
* results by default. Without reverse group lookup, group-based authorization would fail
30+
* because the {@code LDAPRoleProvider} cannot resolve group memberships. This test verifies
31+
* that enabling reverse group lookup allows all group-based authorization to work correctly.
32+
*/
33+
public class BasicAuthLdapReverseGroupLookupTest extends BasicAuthLdapConfigurationTest
34+
{
35+
@Override
36+
protected EmbeddedResource getAuthResource()
37+
{
38+
return new LdapReverseGroupLookupAuthResource();
39+
}
40+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
package org.apache.druid.testing.embedded.auth;
21+
22+
import org.apache.druid.java.util.common.StringUtils;
23+
import org.apache.druid.testing.embedded.EmbeddedDruidCluster;
24+
25+
/**
26+
* LDAP auth resource that additionally configures reverse group lookup via
27+
* {@code groupBaseDn} and {@code groupSearch}. This is needed for LDAP servers
28+
* (such as OpenLDAP) that do not return the {@code memberOf} attribute in user
29+
* search results.
30+
*/
31+
public class LdapReverseGroupLookupAuthResource extends LdapAuthResource
32+
{
33+
private static final String AUTHENTICATOR_NAME = "ldap";
34+
35+
@Override
36+
public void onStarted(EmbeddedDruidCluster cluster)
37+
{
38+
super.onStarted(cluster);
39+
cluster.addCommonProperty(
40+
StringUtils.format("druid.auth.authenticator.%s.credentialsValidator.groupBaseDn", AUTHENTICATOR_NAME),
41+
"ou=Groups,dc=example,dc=org"
42+
).addCommonProperty(
43+
StringUtils.format("druid.auth.authenticator.%s.credentialsValidator.groupSearch", AUTHENTICATOR_NAME),
44+
"(uniqueMember=%s)"
45+
);
46+
}
47+
}

extensions-core/druid-basic-security/src/main/java/org/apache/druid/security/basic/BasicAuthLDAPConfig.java

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121

2222
import org.apache.druid.metadata.PasswordProvider;
2323

24+
import javax.annotation.Nullable;
25+
2426
public class BasicAuthLDAPConfig
2527
{
2628
private final String url;
@@ -33,6 +35,10 @@ public class BasicAuthLDAPConfig
3335
private final Integer credentialVerifyDuration;
3436
private final Integer credentialMaxDuration;
3537
private final Integer credentialCacheSize;
38+
@Nullable
39+
private final String groupBaseDn;
40+
@Nullable
41+
private final String groupSearch;
3642

3743
public BasicAuthLDAPConfig(
3844
final String url,
@@ -46,6 +52,37 @@ public BasicAuthLDAPConfig(
4652
final Integer credentialMaxDuration,
4753
final Integer credentialCacheSize
4854
)
55+
{
56+
this(
57+
url,
58+
bindUser,
59+
bindPassword,
60+
baseDn,
61+
userSearch,
62+
userAttribute,
63+
credentialIterations,
64+
credentialVerifyDuration,
65+
credentialMaxDuration,
66+
credentialCacheSize,
67+
null,
68+
null
69+
);
70+
}
71+
72+
public BasicAuthLDAPConfig(
73+
final String url,
74+
final String bindUser,
75+
final PasswordProvider bindPassword,
76+
final String baseDn,
77+
final String userSearch,
78+
final String userAttribute,
79+
final int credentialIterations,
80+
final Integer credentialVerifyDuration,
81+
final Integer credentialMaxDuration,
82+
final Integer credentialCacheSize,
83+
@Nullable final String groupBaseDn,
84+
@Nullable final String groupSearch
85+
)
4986
{
5087
this.url = url;
5188
this.bindUser = bindUser;
@@ -57,6 +94,8 @@ public BasicAuthLDAPConfig(
5794
this.credentialVerifyDuration = credentialVerifyDuration;
5895
this.credentialMaxDuration = credentialMaxDuration;
5996
this.credentialCacheSize = credentialCacheSize;
97+
this.groupBaseDn = groupBaseDn;
98+
this.groupSearch = groupSearch;
6099
}
61100

62101
public String getUrl()
@@ -108,4 +147,21 @@ public Integer getCredentialCacheSize()
108147
{
109148
return credentialCacheSize;
110149
}
150+
151+
@Nullable
152+
public String getGroupBaseDn()
153+
{
154+
return groupBaseDn;
155+
}
156+
157+
@Nullable
158+
public String getGroupSearch()
159+
{
160+
return groupSearch;
161+
}
162+
163+
public boolean isGroupSearchConfigured()
164+
{
165+
return groupBaseDn != null && groupSearch != null;
166+
}
111167
}

extensions-core/druid-basic-security/src/main/java/org/apache/druid/security/basic/authentication/validator/LDAPCredentialsValidator.java

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
import javax.naming.Name;
4242
import javax.naming.NamingEnumeration;
4343
import javax.naming.NamingException;
44+
import javax.naming.directory.BasicAttribute;
45+
import javax.naming.directory.BasicAttributes;
4446
import javax.naming.directory.DirContext;
4547
import javax.naming.directory.InitialDirContext;
4648
import javax.naming.directory.SearchControls;
@@ -77,7 +79,9 @@ public LDAPCredentialsValidator(
7779
@JsonProperty("credentialIterations") Integer credentialIterations,
7880
@JsonProperty("credentialVerifyDuration") Integer credentialVerifyDuration,
7981
@JsonProperty("credentialMaxDuration") Integer credentialMaxDuration,
80-
@JsonProperty("credentialCacheSize") Integer credentialCacheSize
82+
@JsonProperty("credentialCacheSize") Integer credentialCacheSize,
83+
@JsonProperty("groupBaseDn") String groupBaseDn,
84+
@JsonProperty("groupSearch") String groupSearch
8185
)
8286
{
8387
this.ldapConfig = new BasicAuthLDAPConfig(
@@ -90,7 +94,9 @@ public LDAPCredentialsValidator(
9094
credentialIterations == null ? BasicAuthUtils.DEFAULT_KEY_ITERATIONS : credentialIterations,
9195
credentialVerifyDuration == null ? BasicAuthUtils.DEFAULT_CREDENTIAL_VERIFY_DURATION_SECONDS : credentialVerifyDuration,
9296
credentialMaxDuration == null ? BasicAuthUtils.DEFAULT_CREDENTIAL_MAX_DURATION_SECONDS : credentialMaxDuration,
93-
credentialCacheSize == null ? BasicAuthUtils.DEFAULT_CREDENTIAL_CACHE_SIZE : credentialCacheSize
97+
credentialCacheSize == null ? BasicAuthUtils.DEFAULT_CREDENTIAL_CACHE_SIZE : credentialCacheSize,
98+
groupBaseDn,
99+
groupSearch
94100
);
95101

96102
this.cache = new LruBlockCache(
@@ -237,22 +243,92 @@ SearchResult getLdapUserObject(BasicAuthLDAPConfig ldapConfig, DirContext contex
237243
ldapConfig.getBaseDn(),
238244
StringUtils.format(ldapConfig.getUserSearch(), encodedUsername),
239245
sc);
246+
final SearchResult userResult;
240247
try {
241248
if (!results.hasMore()) {
242249
return null;
243250
}
244-
return results.next();
251+
userResult = results.next();
245252
}
246253
finally {
247254
results.close();
248255
}
256+
257+
// Some LDAP servers do not return memberOf in search results. When memberOf is
258+
// absent and group search configuration is provided, fall back to a reverse
259+
// group lookup.
260+
if (ldapConfig.isGroupSearchConfigured() && !hasMemberOfAttribute(userResult)) {
261+
populateMemberOfFromGroupSearch(ldapConfig, context, userResult);
262+
}
263+
264+
return userResult;
249265
}
250266
catch (NamingException e) {
251267
LOG.debug(e, "Unable to find user '%s'", username);
252268
return null;
253269
}
254270
}
255271

272+
private static boolean hasMemberOfAttribute(SearchResult userResult)
273+
{
274+
return userResult.getAttributes() != null
275+
&& userResult.getAttributes().get("memberOf") != null;
276+
}
277+
278+
@SuppressWarnings("BanJNDI")
279+
private void populateMemberOfFromGroupSearch(
280+
BasicAuthLDAPConfig ldapConfig,
281+
DirContext context,
282+
SearchResult userResult
283+
)
284+
{
285+
try {
286+
final String userDn = userResult.getNameInNamespace();
287+
final SearchControls sc = new SearchControls();
288+
sc.setSearchScope(SearchControls.SUBTREE_SCOPE);
289+
// Request only the DN by returning a minimal attribute. The group DN is
290+
// obtained via getNameInNamespace() and does not depend on any returned attribute.
291+
sc.setReturningAttributes(new String[]{"1.1"});
292+
293+
final String filter = StringUtils.format(ldapConfig.getGroupSearch(), encodeForLDAP(userDn, true));
294+
final NamingEnumeration<SearchResult> groupResults = context.search(
295+
ldapConfig.getGroupBaseDn(),
296+
filter,
297+
sc
298+
);
299+
300+
final BasicAttribute memberOfAttr = new BasicAttribute("memberOf");
301+
try {
302+
while (groupResults.hasMore()) {
303+
final SearchResult groupResult = groupResults.next();
304+
final String groupDn = groupResult.getNameInNamespace();
305+
memberOfAttr.add(groupDn);
306+
}
307+
}
308+
finally {
309+
groupResults.close();
310+
}
311+
312+
if (memberOfAttr.size() > 0) {
313+
if (userResult.getAttributes() != null) {
314+
userResult.getAttributes().put(memberOfAttr);
315+
} else {
316+
final BasicAttributes attrs = new BasicAttributes(true);
317+
attrs.put(memberOfAttr);
318+
userResult.setAttributes(attrs);
319+
}
320+
LOG.debug(
321+
"Populated memberOf for user '%s' with %d groups from reverse group search",
322+
userDn,
323+
memberOfAttr.size()
324+
);
325+
}
326+
}
327+
catch (NamingException e) {
328+
LOG.error(e, "Exception during reverse group lookup, proceeding without group memberships");
329+
}
330+
}
331+
256332
boolean validatePassword(BasicAuthLDAPConfig ldapConfig, LdapName userDn, char[] password)
257333
{
258334
InitialDirContext context = null;

0 commit comments

Comments
 (0)