From d7680a6a711d8d724c0620e8f85765eb2766ebd0 Mon Sep 17 00:00:00 2001 From: HwangRock Date: Sat, 26 Sep 2026 23:44:35 +0900 Subject: [PATCH] [ZEPPELIN-6171] Resolve nested LDAP groups on FreeIPA via memberOf On FreeIPA (389 Directory Server) the AD LDAP_MATCHING_RULE_IN_CHAIN operator isn't supported, and the default member-attribute search only sees direct members, so a user's nested group memberships and their roles were dropped. Add a third resolution path: when groupSearchEnableMemberOf is set, read the user entry's own memberOf attribute, which the directory pre-flattens with nested membership. Split rolesFor() into three per-strategy private methods so each path stays readable; the two existing paths are moved unchanged and matchingRuleInChain keeps precedence when both are enabled. --- docs/setup/security/shiro_authentication.md | 13 +- .../org/apache/zeppelin/realm/LdapRealm.java | 233 ++++++++++++++---- .../apache/zeppelin/realm/LdapRealmTest.java | 158 ++++++++++++ 3 files changed, 354 insertions(+), 50 deletions(-) diff --git a/docs/setup/security/shiro_authentication.md b/docs/setup/security/shiro_authentication.md index d5ded4e1711..d1749c3e4c0 100644 --- a/docs/setup/security/shiro_authentication.md +++ b/docs/setup/security/shiro_authentication.md @@ -174,8 +174,19 @@ 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. +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 5c7ff9a1f36..6c2579bf0b3 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.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.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,9 @@ public class LdapRealm extends DefaultLdapRealm { private String userSearchScope = "subtree"; private String groupSearchScope = "subtree"; private boolean groupSearchEnableMatchingRuleInChain; + private boolean groupSearchEnableMemberOf; + private String memberOfAttribute = "memberOf"; + private volatile boolean warnedBothGroupSearchModes; private String groupSearchBase; @@ -343,6 +348,14 @@ protected Set rolesFor(PrincipalCollection principals, String userNameIn String userDn = getUserDnForSearch(userName); + if (groupSearchEnableMatchingRuleInChain && groupSearchEnableMemberOf + && !warnedBothGroupSearchModes) { + LOGGER.warn("Both groupSearchEnableMatchingRuleInChain and groupSearchEnableMemberOf are " + + "enabled; groupSearchEnableMatchingRuleInChain takes precedence and " + + "groupSearchEnableMemberOf is ignored."); + warnedBothGroupSearchModes = true; + } + // Activate paged results int pageSize = getPagingSize(); LOGGER.debug("Ldap PagingSize: {}", pageSize); @@ -356,62 +369,18 @@ protected Set rolesFor(PrincipalCollection principals, String userNameIn // 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 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,156 @@ protected Set rolesFor(PrincipalCollection principals, String userNameIn 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 roleNames) throws NamingException { + int numResults = 0; + NamingEnumeration 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; + } + + // Default path: search groups and check the member attribute for the user DN. + private int rolesForGroupMembership(String userName, String userDn, LdapContext ldapCtx, + SearchControls searchControls, LdapContextFactory ldapContextFactory, + Set roleNames, Set groupNames) throws NamingException { + int numResults = 0; + NamingEnumeration 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 roleNames, Set groupNames) throws NamingException { + SearchControls memberOfControls = new SearchControls(); + memberOfControls.setSearchScope(SearchControls.OBJECT_SCOPE); + memberOfControls.setReturningAttributes(new String[]{memberOfAttribute}); + + int numResults = 0; + NamingEnumeration 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(); + 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 so the caller skips it. + */ + String groupNameFromMemberOfDn(String groupDn) { + try { + LdapName groupLdapName = new LdapName(groupDn); + List rdns = groupLdapName.getRdns(); + if (rdns.isEmpty()) { + return null; + } + Rdn leafRdn = rdns.get(rdns.size() - 1); + if (!getGroupIdAttribute().equalsIgnoreCase(leafRdn.getType())) { + LOGGER.warn("memberOf value '{}' leaf RDN type '{}' does not match groupIdAttribute " + + "'{}'; using the leaf RDN value anyway.", + groupDn, leafRdn.getType(), getGroupIdAttribute()); + } + return leafRdn.getValue().toString(); + } catch (InvalidNameException e) { + LOGGER.warn("Skipping malformed memberOf value '{}': {}", groupDn, e.getMessage()); + return null; + } + } + + private void recordGroupRole(String groupName, Set roleNames, Set 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 +940,22 @@ public void setGroupSearchEnableMatchingRuleInChain( 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 b6213cbc770..248bf44d3c5 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 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,159 @@ void testFilterEscaping() { assertEquals("gid=\\{0}\\", realm.getUserSearchFilter()); } + @Test + void testRolesForMemberOfNestedGroups() throws NamingException { + LdapRealm realm = new LdapRealm(); + realm.setGroupSearchEnableMemberOf(true); + HashMap 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 results = enumerationOf(userEntry); + when(ldapCtx.search(eq(userDn), eq("(objectclass=*)"), any(SearchControls.class))) + .thenReturn(results); + + Set 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 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 results = enumerationOf(userEntry); + when(ldapCtx.search(eq(userDn), eq("(objectclass=*)"), any(SearchControls.class))) + .thenReturn(results); + + Set 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 results = enumerationOf(); + when(ldapCtx.search(eq(userDn), eq("(objectclass=*)"), any(SearchControls.class))) + .thenReturn(results); + + Set roles = realm.rolesFor( + new SimplePrincipalCollection("principal", "ldapRealm"), + "principal", ldapCtx, ldapContextFactory, session); + + assertEquals(new HashSet<>(), roles); + } + + @Test + void testWarnBothGroupSearchModesLogsOnlyOnce() 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"); + + // Fresh enumeration per call since NamingEnumeration is single-use. + when(ldapCtx.search(any(String.class), any(String.class), any(SearchControls.class))) + .thenAnswer(invocation -> enumerationOf(group1)); + + // Repeated calls with both flags enabled must keep working the same way + // after the WARN-once guard trips on the first call. + Set firstCall = realm.rolesFor( + new SimplePrincipalCollection("principal", "ldapRealm"), + "principal", ldapCtx, ldapContextFactory, session); + Set secondCall = realm.rolesFor( + new SimplePrincipalCollection("principal", "ldapRealm"), + "principal", ldapCtx, ldapContextFactory, session); + + assertEquals(firstCall, secondCall); + } + + @Test + void testGroupNameFromMemberOfDnFallback() { + LdapRealm realm = new LdapRealm(); + + // groupIdAttribute (default "cn") does not match any RDN type in the DN + // below -> fall back to the leaf (left-most) RDN value. + realm.setGroupIdAttribute("gidNumber"); + assertEquals("admins", + 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 enumerationOf(BasicAttributes... attrs) { final Iterator iterator = Arrays.asList(attrs).iterator(); return new NamingEnumeration() {