This is an automated email from the ASF dual-hosted git repository.
tbonelee pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git
The following commit(s) were added to refs/heads/master by this push:
new 943a3fb8df [ZEPPELIN-6171] Resolve nested LDAP groups on FreeIPA via
memberOf
943a3fb8df is described below
commit 943a3fb8dfaa6d05cf4b92c013a01f75ad8a940d
Author: HwangRock <[email protected]>
AuthorDate: Mon Oct 5 22:55:49 2026 +0900
[ZEPPELIN-6171] Resolve nested LDAP groups on FreeIPA via memberOf
### What is this PR for?
`LdapRealm` resolves a user's groups in one of two ways today: the Active
Directory `LDAP_MATCHING_RULE_IN_CHAIN` operator, or a group search over the
`member` attribute. Neither works for nested groups on FreeIPA / 389 Directory
Server — the AD matching rule isn't supported there, and the plain `member`
search only sees direct members. So if a user is in `dev` and `dev` is a member
of `eng`, the user's `eng` membership (and any role mapped to it) silently
disappears.
This adds a third path. When `ldapRealm.groupSearchEnableMemberOf = true`,
the realm reads the user entry's own `memberOf` attribute instead of walking
the group tree. FreeIPA/389 DS already flattens direct **and** nested
membership onto `memberOf`, so a single lookup of the user entry gives you the
full set, nested groups included.
While adding it I split `rolesFor()` into three small per-strategy methods
(matching-rule / group-membership / memberOf) so each path is readable on its
own. The two existing paths are moved as-is, no behavior change.
New settings:
```
# resolve nested groups via the user's memberOf attribute (e.g. FreeIPA /
389 DS)
ldapRealm.groupSearchEnableMemberOf = true
ldapRealm.memberOfAttribute = memberOf
```
A few decisions worth calling out:
- **Precedence when both are on.** If
`groupSearchEnableMatchingRuleInChain` and `groupSearchEnableMemberOf` are both
set, the matching-rule path wins and we log a one-time warning, rather than
refusing to start. This keeps existing AD setups behaving exactly as before;
the cost is that a contradictory config isn't hard-rejected, just warned.
- **Group name comes from the leaf RDN only.** A `memberOf` value is a full
group DN, and we take the group name from its leaf RDN without scanning
ancestors. On FreeIPA a container on the path (`cn=groups,cn=accounts,...`) has
the same `cn=` type as the group itself, so scanning would pick the wrong one.
A malformed DN is skipped (and logged) instead of failing the login.
- **No behavior change for the existing paths.** The matching-rule branch
still doesn't populate the session group-name set the way the default branch
does — that's pre-existing and I left it alone on purpose rather than "fixing"
it in a refactor.
One prerequisite (documented in `shiro_authentication.md`): `memberOf` is
only returned to an authenticated bind, which Zeppelin already does via
`systemUsername`/`systemPassword`. Deployments that split groups and members
across separate backends may also need server-side memberOf scope configuration.
### What type of PR is it?
Improvement
### Todos
* [x] memberOf-based group resolution path
* [x] split `rolesFor()` into per-strategy methods
* [x] unit tests
* [x] docs
### What is the Jira issue?
[ZEPPELIN-6171](https://issues.apache.org/jira/browse/ZEPPELIN-6171)
### How should this be tested?
`LdapRealmTest` (11 tests, mock-based) covers the memberOf path: nested
resolution, matching-rule precedence, leaf-RDN fallback, and the empty /
missing `memberOf` cases. The existing tests pass unchanged, which is what pins
down that the two old paths still behave the same.
I also ran it end-to-end against a real 389 Directory Server (FreeIPA's
LDAP engine) with a nested setup — `alice` ∈ `dev`, and `dev` ∈ `eng`:
```
# alice's memberOf at the directory level (server-flattened, ground truth)
$ ldapsearch ... -b 'uid=alice,ou=people,dc=example,dc=com' -s base memberOf
dn: uid=alice,ou=people,dc=example,dc=com
memberOf: cn=dev,ou=groups,dc=example,dc=com
memberOf: cn=eng,ou=groups,dc=example,dc=com
# LdapRealm.rolesFor(alice) against that server, only the new flag differs:
case A groupSearchEnableMemberOf=false -> [dev] # nested cn=eng
missing
case B groupSearchEnableMemberOf=true -> [dev, eng] # nested cn=eng
resolved
```
So the nested group is recovered only with the new flag on.
### Questions:
* Does the license files need to update? No
* Is there breaking changes for older versions? No
* Does this needs documentation? Yes —
`docs/setup/security/shiro_authentication.md` is updated.
Closes #5505 from HwangRock/ZEPPELIN-6171-freeipa-memberof-nested-groups.
Signed-off-by: ChanHo Lee <[email protected]>
---
docs/setup/security/shiro_authentication.md | 16 +-
.../java/org/apache/zeppelin/realm/LdapRealm.java | 254 +++++++++++++++++----
.../org/apache/zeppelin/realm/LdapRealmTest.java | 186 +++++++++++++++
3 files changed, 406 insertions(+), 50 deletions(-)
diff --git a/docs/setup/security/shiro_authentication.md
b/docs/setup/security/shiro_authentication.md
index d5ded4e171..69cb423c99 100644
--- a/docs/setup/security/shiro_authentication.md
+++ b/docs/setup/security/shiro_authentication.md
@@ -174,8 +174,22 @@ ldapRealm.groupSearchScope = subtree;
ldapRealm.memberAttributeValueTemplate =
cn={0},ou=people,dc=hadoop,dc=apache,dc=org
ldapRealm.contextFactory.systemUsername =
uid=guest,ou=people,dc=hadoop,dc=apache,dc=org
ldapRealm.contextFactory.systemPassword = S{ALIAS=ldcSystemPassword}
-# enable support for nested groups using the LDAP_MATCHING_RULE_IN_CHAIN
operator
+# enable support for nested groups using the LDAP_MATCHING_RULE_IN_CHAIN
operator (Active Directory only)
ldapRealm.groupSearchEnableMatchingRuleInChain = true
+# enable support for nested groups on directories that lack
LDAP_MATCHING_RULE_IN_CHAIN
+# (e.g. FreeIPA / 389 Directory Server) by reading the user entry's own
memberOf attribute,
+# which the MemberOf plugin pre-flattens to include direct and indirect group
membership.
+# If both this and groupSearchEnableMatchingRuleInChain are enabled, the
matching-rule-in-chain
+# path takes precedence and this setting is ignored.
+# Note: the LDAP bind used by ldapRealm.contextFactory must be authenticated
(not anonymous) or
+# the directory may not return memberOf; if group members span multiple
backends/replicas, the
+# directory's own server-side scope configuration must be set up for memberOf
to be complete.
+# Only memberOf values under an explicitly configured groupSearchBase are
treated as groups (other
+# entries such as HBAC/sudo rules or roles are ignored); without
groupSearchBase no groups are resolved.
+# The leaf RDN type of each group DN must match groupIdAttribute, otherwise
the value is skipped.
+ldapRealm.groupSearchEnableMemberOf = false
+# customize the attribute name read by groupSearchEnableMemberOf (defaults to
memberOf)
+ldapRealm.memberOfAttribute = memberOf
# optional mapping from physical groups to logical application roles
ldapRealm.rolesByGroup = LDN_USERS: user_role, NYK_USERS: user_role,
HKG_USERS: user_role, GLOBAL_ADMIN: admin_role
# optional list of roles that are allowed to authenticate. Incase not present
all groups are allowed to authenticate (login).
diff --git
a/zeppelin-server/src/main/java/org/apache/zeppelin/realm/LdapRealm.java
b/zeppelin-server/src/main/java/org/apache/zeppelin/realm/LdapRealm.java
index 5c7ff9a1f3..e4c0df1496 100644
--- a/zeppelin-server/src/main/java/org/apache/zeppelin/realm/LdapRealm.java
+++ b/zeppelin-server/src/main/java/org/apache/zeppelin/realm/LdapRealm.java
@@ -33,6 +33,7 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.naming.AuthenticationException;
import javax.naming.Context;
+import javax.naming.InvalidNameException;
import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
import javax.naming.PartialResultException;
@@ -44,6 +45,7 @@ import javax.naming.ldap.Control;
import javax.naming.ldap.LdapContext;
import javax.naming.ldap.LdapName;
import javax.naming.ldap.PagedResultsControl;
+import javax.naming.ldap.Rdn;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.security.alias.CredentialProvider;
import org.apache.hadoop.security.alias.CredentialProviderFactory;
@@ -165,6 +167,8 @@ public class LdapRealm extends DefaultLdapRealm {
private String userSearchScope = "subtree";
private String groupSearchScope = "subtree";
private boolean groupSearchEnableMatchingRuleInChain;
+ private boolean groupSearchEnableMemberOf;
+ private String memberOfAttribute = "memberOf";
private String groupSearchBase;
@@ -217,6 +221,15 @@ public class LdapRealm extends DefaultLdapRealm {
@Override
protected void onInit() {
super.onInit();
+ if (groupSearchEnableMatchingRuleInChain && groupSearchEnableMemberOf) {
+ LOGGER.warn("Both groupSearchEnableMatchingRuleInChain and
groupSearchEnableMemberOf are "
+ + "enabled; groupSearchEnableMatchingRuleInChain takes precedence
and "
+ + "groupSearchEnableMemberOf is ignored.");
+ }
+ if (groupSearchEnableMemberOf
+ && org.apache.commons.lang3.StringUtils.isEmpty(groupSearchBase)) {
+ LOGGER.warn("memberOf mode requires groupSearchBase; no groups will be
resolved.");
+ }
if
(!org.apache.commons.lang3.StringUtils.isEmpty(this.hadoopSecurityCredentialPath)
&& getContextFactory() != null) {
((JndiLdapContextFactory) getContextFactory()).setSystemPassword(
@@ -356,62 +369,18 @@ public class LdapRealm extends DefaultLdapRealm {
// ldapsearch -h localhost -p 33389 -D
// uid=guest,ou=people,dc=hadoop,dc=apache,dc=org -w guest-password
// -b dc=hadoop,dc=apache,dc=org -s sub '(objectclass=*)'
- NamingEnumeration<SearchResult> searchResultEnum = null;
SearchControls searchControls = getGroupSearchControls();
try {
if (groupSearchEnableMatchingRuleInChain) {
- searchResultEnum = ldapCtx.search(
- getGroupSearchBase(),
- String.format(
- MATCHING_RULE_IN_CHAIN_FORMAT,
- LdapFilterEncoder.escapeFilterValue(groupObjectClass),
- LdapFilterEncoder.escapeFilterValue(memberAttribute),
- LdapFilterEncoder.escapeFilterValue(userDn)),
- searchControls);
- while (searchResultEnum != null && searchResultEnum.hasMore()) {
- // searchResults contains all the groups in search scope
- numResults++;
- final SearchResult group = searchResultEnum.next();
-
- Attribute attribute =
group.getAttributes().get(getGroupIdAttribute());
- String groupName = attribute.get().toString();
-
- String roleName = roleNameFor(groupName);
- if (roleName != null) {
- roleNames.add(roleName);
- } else {
- roleNames.add(groupName);
- }
- }
+ numResults += rolesForMatchingRuleInChain(userDn, ldapCtx,
searchControls, roleNames);
+ } else if (groupSearchEnableMemberOf) {
+ numResults += rolesForMemberOf(userDn, ldapCtx, roleNames,
groupNames);
} else {
- // Default group search filter
- String searchFilter = String.format("(objectclass=%1$s)",
- LdapFilterEncoder.escapeFilterValue(groupObjectClass));
-
- // If group search filter is defined in Shiro config, then use it
- if (groupSearchFilter != null) {
- searchFilter = expandFilterTemplate(groupSearchFilter, userName);
- //searchFilter = String.format("%1$s", groupSearchFilter);
- }
- LOGGER.debug("Group SearchBase|SearchFilter|GroupSearchScope: " +
"{}|{}|{}",
- getGroupSearchBase(), searchFilter, groupSearchScope);
- searchResultEnum = ldapCtx.search(
- getGroupSearchBase(),
- searchFilter,
- searchControls);
- while (searchResultEnum != null && searchResultEnum.hasMore()) {
- // searchResults contains all the groups in search scope
- numResults++;
- final SearchResult group = searchResultEnum.next();
- addRoleIfMember(userDn, group, roleNames, groupNames,
ldapContextFactory);
- }
+ numResults += rolesForGroupMembership(userName, userDn, ldapCtx,
searchControls,
+ ldapContextFactory, roleNames, groupNames);
}
} catch (PartialResultException e) {
LOGGER.debug("Ignoring PartitalResultException");
- } finally {
- if (searchResultEnum != null) {
- searchResultEnum.close();
- }
}
// Re-activate paged results
ldapCtx.setRequestControls(new Control[]{new
PagedResultsControl(pageSize,
@@ -432,6 +401,177 @@ public class LdapRealm extends DefaultLdapRealm {
return roleNames;
}
+ // AD-only path: LDAP_MATCHING_RULE_IN_CHAIN walks group ancestry
server-side.
+ private int rolesForMatchingRuleInChain(String userDn, LdapContext ldapCtx,
+ SearchControls searchControls, Set<String> roleNames) throws
NamingException {
+ int numResults = 0;
+ NamingEnumeration<SearchResult> searchResultEnum = null;
+ try {
+ searchResultEnum = ldapCtx.search(
+ getGroupSearchBase(),
+ String.format(
+ MATCHING_RULE_IN_CHAIN_FORMAT,
+ LdapFilterEncoder.escapeFilterValue(groupObjectClass),
+ LdapFilterEncoder.escapeFilterValue(memberAttribute),
+ LdapFilterEncoder.escapeFilterValue(userDn)),
+ searchControls);
+ while (searchResultEnum != null && searchResultEnum.hasMore()) {
+ // searchResults contains all the groups in search scope
+ numResults++;
+ final SearchResult group = searchResultEnum.next();
+
+ Attribute attribute = group.getAttributes().get(getGroupIdAttribute());
+ String groupName = attribute.get().toString();
+
+ String roleName = roleNameFor(groupName);
+ if (roleName != null) {
+ roleNames.add(roleName);
+ } else {
+ roleNames.add(groupName);
+ }
+ }
+ } finally {
+ if (searchResultEnum != null) {
+ searchResultEnum.close();
+ }
+ }
+ return numResults;
+ }
+
+ private int rolesForGroupMembership(String userName, String userDn,
LdapContext ldapCtx,
+ SearchControls searchControls, LdapContextFactory ldapContextFactory,
+ Set<String> roleNames, Set<String> groupNames) throws NamingException {
+ int numResults = 0;
+ NamingEnumeration<SearchResult> searchResultEnum = null;
+ try {
+ // Default group search filter
+ String searchFilter = String.format("(objectclass=%1$s)",
+ LdapFilterEncoder.escapeFilterValue(groupObjectClass));
+
+ // If group search filter is defined in Shiro config, then use it
+ if (groupSearchFilter != null) {
+ searchFilter = expandFilterTemplate(groupSearchFilter, userName);
+ //searchFilter = String.format("%1$s", groupSearchFilter);
+ }
+ LOGGER.debug("Group SearchBase|SearchFilter|GroupSearchScope: " +
"{}|{}|{}",
+ getGroupSearchBase(), searchFilter, groupSearchScope);
+ searchResultEnum = ldapCtx.search(
+ getGroupSearchBase(),
+ searchFilter,
+ searchControls);
+ while (searchResultEnum != null && searchResultEnum.hasMore()) {
+ // searchResults contains all the groups in search scope
+ numResults++;
+ final SearchResult group = searchResultEnum.next();
+ addRoleIfMember(userDn, group, roleNames, groupNames,
ldapContextFactory);
+ }
+ } finally {
+ if (searchResultEnum != null) {
+ searchResultEnum.close();
+ }
+ }
+ return numResults;
+ }
+
+ /**
+ * FreeIPA/389 DS path: reads the user entry's {@code memberOf} attribute,
+ * which the directory pre-flattens with nested (indirect) group membership.
+ */
+ private int rolesForMemberOf(String userDn, LdapContext ldapCtx,
+ Set<String> roleNames, Set<String> groupNames) throws NamingException {
+ SearchControls memberOfControls = new SearchControls();
+ memberOfControls.setSearchScope(SearchControls.OBJECT_SCOPE);
+ memberOfControls.setReturningAttributes(new String[]{memberOfAttribute});
+
+ // Only an explicitly configured groupSearchBase bounds the group DNs; the
+ // searchBase fallback would accept every DN, including non-group entries.
+ final LdapName groupBase =
org.apache.commons.lang3.StringUtils.isEmpty(groupSearchBase)
+ ? null : new LdapName(groupSearchBase);
+
+ int numResults = 0;
+ NamingEnumeration<SearchResult> searchResultEnum = null;
+ try {
+ searchResultEnum = ldapCtx.search(userDn, "(objectclass=*)",
memberOfControls);
+ if (searchResultEnum != null && searchResultEnum.hasMore()) {
+ numResults++;
+ final SearchResult userEntry = searchResultEnum.next();
+ Attribute memberOf = userEntry.getAttributes().get(memberOfAttribute);
+ if (memberOf != null) {
+ NamingEnumeration<?> memberOfValues = memberOf.getAll();
+ try {
+ while (memberOfValues.hasMore()) {
+ String groupDn = memberOfValues.next().toString();
+ if (!isUnderGroupSearchBase(groupDn, groupBase)) {
+ LOGGER.debug("Skipping memberOf value '{}' outside
groupSearchBase", groupDn);
+ continue;
+ }
+ String groupName = groupNameFromMemberOfDn(groupDn);
+ if (groupName != null) {
+ recordGroupRole(groupName, roleNames, groupNames);
+ }
+ }
+ } finally {
+ memberOfValues.close();
+ }
+ }
+ }
+ } finally {
+ if (searchResultEnum != null) {
+ searchResultEnum.close();
+ }
+ }
+ return numResults;
+ }
+
+ /**
+ * Extracts the group name from a memberOf DN value using its leaf RDN.
+ * Ancestor RDNs are not scanned: a container RDN on the path (e.g. FreeIPA's
+ * {@code cn=groups,cn=accounts}) shares the group's RDN type and would be
+ * mistaken for the group. Returns null for an unparseable DN or a leaf RDN
whose
+ * type differs from groupIdAttribute so the caller skips it.
+ */
+ String groupNameFromMemberOfDn(String groupDn) {
+ try {
+ LdapName groupLdapName = new LdapName(groupDn);
+ List<Rdn> rdns = groupLdapName.getRdns();
+ if (rdns.isEmpty()) {
+ return null;
+ }
+ Rdn leafRdn = rdns.get(rdns.size() - 1);
+ if (!getGroupIdAttribute().equalsIgnoreCase(leafRdn.getType())) {
+ LOGGER.debug("Skipping memberOf value '{}': leaf RDN type '{}' does
not match "
+ + "groupIdAttribute '{}'", groupDn, leafRdn.getType(),
getGroupIdAttribute());
+ return null;
+ }
+ return leafRdn.getValue().toString();
+ } catch (InvalidNameException e) {
+ LOGGER.warn("Skipping malformed memberOf value '{}': {}", groupDn,
e.getMessage());
+ return null;
+ }
+ }
+
+ private static boolean isUnderGroupSearchBase(String groupDn, LdapName
groupBase) {
+ if (groupBase == null) {
+ return false;
+ }
+ try {
+ return new LdapName(groupDn).startsWith(groupBase);
+ } catch (InvalidNameException e) {
+ LOGGER.warn("Skipping malformed memberOf value '{}': {}", groupDn,
e.getMessage());
+ return false;
+ }
+ }
+
+ private void recordGroupRole(String groupName, Set<String> roleNames,
Set<String> groupNames) {
+ groupNames.add(groupName);
+ String roleName = roleNameFor(groupName);
+ if (roleName != null) {
+ roleNames.add(roleName);
+ } else {
+ roleNames.add(groupName);
+ }
+ }
+
protected String getUserDnForSearch(String userName) {
if (userSearchAttributeName == null || userSearchAttributeName.isEmpty()) {
// memberAttributeValuePrefix and memberAttributeValueSuffix
@@ -821,6 +961,22 @@ public class LdapRealm extends DefaultLdapRealm {
this.groupSearchEnableMatchingRuleInChain =
groupSearchEnableMatchingRuleInChain;
}
+ public boolean isGroupSearchEnableMemberOf() {
+ return groupSearchEnableMemberOf;
+ }
+
+ public void setGroupSearchEnableMemberOf(boolean groupSearchEnableMemberOf) {
+ this.groupSearchEnableMemberOf = groupSearchEnableMemberOf;
+ }
+
+ public String getMemberOfAttribute() {
+ return memberOfAttribute;
+ }
+
+ public void setMemberOfAttribute(String memberOfAttribute) {
+ this.memberOfAttribute = memberOfAttribute;
+ }
+
private SearchControls getUserSearchControls() {
SearchControls searchControls = SUBTREE_SCOPE;
if ("onelevel".equalsIgnoreCase(userSearchScope)) {
diff --git
a/zeppelin-server/src/test/java/org/apache/zeppelin/realm/LdapRealmTest.java
b/zeppelin-server/src/test/java/org/apache/zeppelin/realm/LdapRealmTest.java
index b6213cbc77..415d80e6ec 100644
--- a/zeppelin-server/src/test/java/org/apache/zeppelin/realm/LdapRealmTest.java
+++ b/zeppelin-server/src/test/java/org/apache/zeppelin/realm/LdapRealmTest.java
@@ -19,8 +19,12 @@
package org.apache.zeppelin.realm;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -37,6 +41,7 @@ import java.util.Set;
import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
+import javax.naming.directory.BasicAttribute;
import javax.naming.directory.BasicAttributes;
import javax.naming.directory.SearchControls;
import javax.naming.directory.SearchResult;
@@ -137,6 +142,187 @@ class LdapRealmTest {
assertEquals("gid=\\{0}\\", realm.getUserSearchFilter());
}
+ @Test
+ void testRolesForMemberOfNestedGroups() throws NamingException {
+ LdapRealm realm = new LdapRealm();
+ realm.setGroupSearchEnableMemberOf(true);
+ realm.setGroupSearchBase("cn=groups,cn=accounts,dc=example,dc=com");
+ HashMap<String, String> rolesByGroups = new HashMap<>();
+ rolesByGroups.put("nested-group", "nested-role");
+ realm.setRolesByGroup(rolesByGroups);
+
+ LdapContextFactory ldapContextFactory = mock(LdapContextFactory.class);
+ LdapContext ldapCtx = mock(LdapContext.class);
+ Session session = mock(Session.class);
+
+ String userDn = realm.getUserDnForSearch("principal");
+
+ // 389 DS MemberOf plugin already flattens direct + nested membership onto
+ // the user entry, so a single base-scope search returns both group DNs.
+ BasicAttribute memberOf = new BasicAttribute("memberOf");
+ memberOf.add("cn=direct-group,cn=groups,cn=accounts,dc=example,dc=com");
+ memberOf.add("cn=nested-group,cn=groups,cn=accounts,dc=example,dc=com");
+ BasicAttributes userEntry = new BasicAttributes();
+ userEntry.put(memberOf);
+
+ NamingEnumeration<SearchResult> results = enumerationOf(userEntry);
+ when(ldapCtx.search(eq(userDn), eq("(objectclass=*)"),
any(SearchControls.class)))
+ .thenReturn(results);
+
+ Set<String> roles = realm.rolesFor(
+ new SimplePrincipalCollection("principal", "ldapRealm"),
+ "principal", ldapCtx, ldapContextFactory, session);
+
+ assertEquals(new HashSet<>(Arrays.asList("direct-group", "nested-role")),
roles);
+ }
+
+ @Test
+ void testRolesForMatchingRuleInChainTakesPrecedenceOverMemberOf() throws
NamingException {
+ LdapRealm realm = new LdapRealm();
+ realm.setGroupSearchEnableMatchingRuleInChain(true);
+ realm.setGroupSearchEnableMemberOf(true);
+ realm.setGroupSearchBase("cn=groups,dc=apache");
+
+ LdapContextFactory ldapContextFactory = mock(LdapContextFactory.class);
+ LdapContext ldapCtx = mock(LdapContext.class);
+ Session session = mock(Session.class);
+
+ BasicAttributes group1 = new BasicAttributes();
+ group1.put(realm.getGroupIdAttribute(), "group-one");
+
+ NamingEnumeration<SearchResult> results = enumerationOf(group1);
+ when(ldapCtx.search(any(String.class), any(String.class),
any(SearchControls.class)))
+ .thenReturn(results);
+
+ realm.rolesFor(
+ new SimplePrincipalCollection("principal", "ldapRealm"),
+ "principal", ldapCtx, ldapContextFactory, session);
+
+ verify(ldapCtx, never()).search(anyString(), eq("(objectclass=*)"),
any(SearchControls.class));
+ }
+
+ @Test
+ void testRolesForMemberOfWithNoMemberOfAttribute() throws NamingException {
+ LdapRealm realm = new LdapRealm();
+ realm.setGroupSearchEnableMemberOf(true);
+
+ LdapContextFactory ldapContextFactory = mock(LdapContextFactory.class);
+ LdapContext ldapCtx = mock(LdapContext.class);
+ Session session = mock(Session.class);
+
+ String userDn = realm.getUserDnForSearch("principal");
+
+ // The user entry is found, but it carries no memberOf attribute at all
+ // (e.g. the user belongs to no groups) -> must not NPE, just no roles.
+ BasicAttributes userEntry = new BasicAttributes();
+
+ NamingEnumeration<SearchResult> results = enumerationOf(userEntry);
+ when(ldapCtx.search(eq(userDn), eq("(objectclass=*)"),
any(SearchControls.class)))
+ .thenReturn(results);
+
+ Set<String> roles = realm.rolesFor(
+ new SimplePrincipalCollection("principal", "ldapRealm"),
+ "principal", ldapCtx, ldapContextFactory, session);
+
+ assertEquals(new HashSet<>(), roles);
+ }
+
+ @Test
+ void testRolesForMemberOfWhenUserEntryNotFound() throws NamingException {
+ LdapRealm realm = new LdapRealm();
+ realm.setGroupSearchEnableMemberOf(true);
+
+ LdapContextFactory ldapContextFactory = mock(LdapContextFactory.class);
+ LdapContext ldapCtx = mock(LdapContext.class);
+ Session session = mock(Session.class);
+
+ String userDn = realm.getUserDnForSearch("principal");
+
+ // The base-scope search for the user entry itself returns nothing
+ // (e.g. the user DN doesn't exist) -> must not NPE, just no roles.
+ NamingEnumeration<SearchResult> results = enumerationOf();
+ when(ldapCtx.search(eq(userDn), eq("(objectclass=*)"),
any(SearchControls.class)))
+ .thenReturn(results);
+
+ Set<String> roles = realm.rolesFor(
+ new SimplePrincipalCollection("principal", "ldapRealm"),
+ "principal", ldapCtx, ldapContextFactory, session);
+
+ assertEquals(new HashSet<>(), roles);
+ }
+
+ @Test
+ void testRolesForMemberOfExcludesDnsOutsideGroupSearchBase() throws
NamingException {
+ LdapRealm realm = new LdapRealm();
+ realm.setGroupSearchEnableMemberOf(true);
+ realm.setGroupSearchBase("cn=groups,cn=accounts,dc=example,dc=com");
+
+ LdapContextFactory ldapContextFactory = mock(LdapContextFactory.class);
+ LdapContext ldapCtx = mock(LdapContext.class);
+ Session session = mock(Session.class);
+
+ String userDn = realm.getUserDnForSearch("principal");
+
+ // FreeIPA mixes HBAC/sudo rule and role DNs into memberOf; only DNs under
+ // groupSearchBase are groups.
+ BasicAttribute memberOf = new BasicAttribute("memberOf");
+ memberOf.add("cn=admins,cn=groups,cn=accounts,dc=example,dc=com");
+ memberOf.add("ipaUniqueID=aaaa-1111,cn=hbac,dc=example,dc=com");
+ memberOf.add("cn=helpdesk,cn=roles,cn=accounts,dc=example,dc=com");
+ BasicAttributes userEntry = new BasicAttributes();
+ userEntry.put(memberOf);
+
+ when(ldapCtx.search(eq(userDn), eq("(objectclass=*)"),
any(SearchControls.class)))
+ .thenReturn(enumerationOf(userEntry));
+
+ Set<String> roles = realm.rolesFor(
+ new SimplePrincipalCollection("principal", "ldapRealm"),
+ "principal", ldapCtx, ldapContextFactory, session);
+
+ assertEquals(new HashSet<>(Arrays.asList("admins")), roles);
+ }
+
+ @Test
+ void testRolesForMemberOfWithoutGroupSearchBaseResolvesNoGroups() throws
NamingException {
+ LdapRealm realm = new LdapRealm();
+ realm.setGroupSearchEnableMemberOf(true);
+ realm.setSearchBase("dc=example,dc=com");
+
+ LdapContextFactory ldapContextFactory = mock(LdapContextFactory.class);
+ LdapContext ldapCtx = mock(LdapContext.class);
+ Session session = mock(Session.class);
+
+ String userDn = realm.getUserDnForSearch("principal");
+
+ BasicAttribute memberOf = new BasicAttribute("memberOf");
+ memberOf.add("cn=admins,cn=groups,cn=accounts,dc=example,dc=com");
+ BasicAttributes userEntry = new BasicAttributes();
+ userEntry.put(memberOf);
+
+ when(ldapCtx.search(eq(userDn), eq("(objectclass=*)"),
any(SearchControls.class)))
+ .thenReturn(enumerationOf(userEntry));
+
+ Set<String> roles = realm.rolesFor(
+ new SimplePrincipalCollection("principal", "ldapRealm"),
+ "principal", ldapCtx, ldapContextFactory, session);
+
+ assertEquals(new HashSet<>(), roles);
+ }
+
+ @Test
+ void testGroupNameFromMemberOfDnFallback() {
+ LdapRealm realm = new LdapRealm();
+
+ // The leaf RDN type ("cn") does not match groupIdAttribute -> skipped.
+ realm.setGroupIdAttribute("gidNumber");
+ assertNull(
+
realm.groupNameFromMemberOfDn("cn=admins,cn=groups,cn=accounts,dc=example,dc=com"));
+
+ // A malformed DN must be skipped, not thrown, so one bad memberOf value
+ // doesn't fail the whole login.
+ assertNull(realm.groupNameFromMemberOfDn(",,,"));
+ }
+
private NamingEnumeration<SearchResult> enumerationOf(BasicAttributes...
attrs) {
final Iterator<BasicAttributes> iterator = Arrays.asList(attrs).iterator();
return new NamingEnumeration<SearchResult>() {