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>() {

Reply via email to