diff --git a/docs/setup/security/shiro_authentication.md b/docs/setup/security/shiro_authentication.md index ed99cf813d9..06d12592de1 100644 --- a/docs/setup/security/shiro_authentication.md +++ b/docs/setup/security/shiro_authentication.md @@ -176,6 +176,9 @@ ldapRealm.contextFactory.systemUsername = uid=guest,ou=people,dc=hadoop,dc=apach ldapRealm.contextFactory.systemPassword = S{ALIAS=ldcSystemPassword} # enable support for nested groups using the LDAP_MATCHING_RULE_IN_CHAIN operator ldapRealm.groupSearchEnableMatchingRuleInChain = true +# enable support for nested groups using memberOf attribute (typically for FreeIPA) +ldapRealm.useMemberOfForNestedGroups = true +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 e1d6d694c73..9425b37a7e9 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,11 +33,13 @@ 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; import javax.naming.SizeLimitExceededException; import javax.naming.directory.Attribute; +import javax.naming.directory.Attributes; import javax.naming.directory.SearchControls; import javax.naming.directory.SearchResult; import javax.naming.ldap.Control; @@ -165,6 +167,9 @@ public class LdapRealm extends DefaultLdapRealm { private String userSearchScope = "subtree"; private String groupSearchScope = "subtree"; private boolean groupSearchEnableMatchingRuleInChain; + // Support for FreeIPA nested groups using memberOf attribute + private boolean useMemberOfForNestedGroups; + private String memberOfAttribute = "memberOf"; private String groupSearchBase; @@ -343,80 +348,155 @@ protected Set rolesFor(PrincipalCollection principals, String userNameIn String userDn = getUserDnForSearch(userName); - // Activate paged results - int pageSize = getPagingSize(); - LOGGER.debug("Ldap PagingSize: {}", pageSize); - int numResults = 0; - try { - ldapCtx.addToEnvironment(Context.REFERRAL, "ignore"); - - ldapCtx.setRequestControls(new Control[]{new PagedResultsControl(pageSize, - Control.NONCRITICAL)}); - - // 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=*)' + // Check if we should use memberOf attribute for nested groups (FreeIPA support) + if (useMemberOfForNestedGroups) { + // Search for the user to get memberOf attribute values + SearchControls searchControls = getUserSearchControls(); + searchControls.setReturningAttributes(new String[]{memberOfAttribute}); + NamingEnumeration searchResultEnum = null; - SearchControls searchControls = getGroupSearchControls(); try { - if (groupSearchEnableMatchingRuleInChain) { - searchResultEnum = ldapCtx.search( - getGroupSearchBase(), - String.format( - MATCHING_RULE_IN_CHAIN_FORMAT, groupObjectClass, memberAttribute, 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); - } + // Search for the user + String searchFilter; + if (userSearchFilter == null) { + if (userSearchAttributeName == null) { + searchFilter = String.format("(objectclass=%1$s)", getUserObjectClass()); + } else { + searchFilter = String.format("(&(objectclass=%1$s)(%2$s=%3$s))", getUserObjectClass(), + userSearchAttributeName, expandTemplate(getUserSearchAttributeTemplate(), userName)); } } else { - // Default group search filter - String searchFilter = String.format("(objectclass=%1$s)", groupObjectClass); - - // If group search filter is defined in Shiro config, then use it - if (groupSearchFilter != null) { - searchFilter = expandTemplate(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); + searchFilter = expandTemplate(userSearchFilter, userName); + } + + LOGGER.debug("MemberOf Attribute Search - SearchBase|SearchFilter: {}|{}", + getUserSearchBase(), searchFilter); + searchResultEnum = ldapCtx.search(getUserSearchBase(), searchFilter, searchControls); + + if (searchResultEnum.hasMore()) { + SearchResult searchResult = searchResultEnum.next(); + Attributes attrs = searchResult.getAttributes(); + + if (attrs != null && attrs.get(memberOfAttribute) != null) { + Attribute memberOfAttr = attrs.get(memberOfAttribute); + NamingEnumeration values = memberOfAttr.getAll(); + + while (values.hasMore()) { + String groupDn = (String) values.next(); + // Extract the group name from the full DN + String groupName = groupDn; + try { + LdapName ldapName = new LdapName(groupDn); + // LdapName components are in reverse order, so we need to start from the leftmost component + // which is actually the last one in the list + for (int i = ldapName.size() - 1; i >= 0; i--) { + String rdn = ldapName.get(i); + if (rdn.startsWith(getGroupIdAttribute() + "=")) { + groupName = rdn.substring(getGroupIdAttribute().length() + 1); + break; + } + } + } catch (InvalidNameException e) { + // If parsing fails, use the full DN as fallback + LOGGER.warn("Unable to parse group DN: {}, using full DN as group name", groupDn, e); + } + + LOGGER.debug("Found group via memberOf: {}", groupName); + groupNames.add(groupName); + + String roleName = roleNameFor(groupName); + if (roleName != null) { + roleNames.add(roleName); + } else { + roleNames.add(groupName); + } + } } } } catch (PartialResultException e) { - LOGGER.debug("Ignoring PartitalResultException"); + LOGGER.debug("Ignoring PartialResultException for memberOf search"); } finally { if (searchResultEnum != null) { searchResultEnum.close(); } } - // Re-activate paged results - ldapCtx.setRequestControls(new Control[]{new PagedResultsControl(pageSize, - null, Control.CRITICAL)}); - } catch (SizeLimitExceededException e) { - LOGGER.info("Only retrieved first {} groups due to SizeLimitExceededException.", numResults); - } catch (IOException e) { - LOGGER.error("Unabled to setup paged results"); + } else { + // Activate paged results + int pageSize = getPagingSize(); + LOGGER.debug("Ldap PagingSize: {}", pageSize); + int numResults = 0; + try { + ldapCtx.addToEnvironment(Context.REFERRAL, "ignore"); + + ldapCtx.setRequestControls(new Control[]{new PagedResultsControl(pageSize, + Control.NONCRITICAL)}); + + // 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, groupObjectClass, memberAttribute, 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); + } + } + } else { + // Default group search filter + String searchFilter = String.format("(objectclass=%1$s)", groupObjectClass); + + // If group search filter is defined in Shiro config, then use it + if (groupSearchFilter != null) { + searchFilter = expandTemplate(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); + } + } + } catch (PartialResultException e) { + LOGGER.debug("Ignoring PartitalResultException"); + } finally { + if (searchResultEnum != null) { + searchResultEnum.close(); + } + } + // Re-activate paged results + ldapCtx.setRequestControls(new Control[]{new PagedResultsControl(pageSize, + null, Control.CRITICAL)}); + } catch (SizeLimitExceededException e) { + LOGGER.info("Only retrieved first {} groups due to SizeLimitExceededException.", numResults); + } catch (IOException e) { + LOGGER.error("Unabled to setup paged results"); + } } + // save role names and group names in session so that they can be // easily looked up outside of this object session.setAttribute(SUBJECT_USER_ROLES, roleNames); @@ -817,6 +897,22 @@ public void setGroupSearchEnableMatchingRuleInChain( this.groupSearchEnableMatchingRuleInChain = groupSearchEnableMatchingRuleInChain; } + public boolean isUseMemberOfForNestedGroups() { + return useMemberOfForNestedGroups; + } + + public void setUseMemberOfForNestedGroups(boolean useMemberOfForNestedGroups) { + this.useMemberOfForNestedGroups = useMemberOfForNestedGroups; + } + + 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 9abce28d4b4..d2d3eed9a30 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 @@ -132,6 +132,109 @@ void testFilterEscaping() { assertEquals("gid=\\5C{0}\\5C", realm.getUserSearchFilter()); } + @Test + void testMemberOfAttributeRolesFor() throws NamingException { + LdapRealm realm = new LdapRealm(); + realm.setUseMemberOfForNestedGroups(true); + realm.setMemberOfAttribute("memberOf"); + realm.setGroupIdAttribute("cn"); + realm.setUserSearchBase("cn=users,cn=accounts,dc=ipa,dc=mgt"); + realm.setUserObjectClass("person"); + realm.setUserSearchAttributeName("uid"); + + HashMap rolesByGroups = new HashMap<>(); + rolesByGroups.put("res_zeppelin_sqx_admin", "admins_role"); + rolesByGroups.put("ipausers", "users_role"); + realm.setRolesByGroup(rolesByGroups); + + LdapContextFactory ldapContextFactory = mock(LdapContextFactory.class); + LdapContext ldapCtx = mock(LdapContext.class); + Session session = mock(Session.class); + + // Create user with memberOf attributes + BasicAttributes userAttrs = new BasicAttributes(); + userAttrs.put("uid", "testuser"); + + // Add memberOf attribute with two group DNs + javax.naming.directory.BasicAttribute memberOfAttr = + new javax.naming.directory.BasicAttribute("memberOf"); + memberOfAttr.add("cn=res_zeppelin_sqx_admin,cn=groups,cn=accounts,dc=ipa,dc=mgt"); + memberOfAttr.add("cn=ipausers,cn=groups,cn=accounts,dc=ipa,dc=mgt"); + userAttrs.put(memberOfAttr); + + // Create SearchResult with the user attributes + SearchResult userSearchResult = new SearchResult( + "uid=testuser,cn=users,cn=accounts,dc=ipa,dc=mgt", null, userAttrs); + + // Configure mock to return our test user when searching + NamingEnumeration userSearchResults = enumerationOf(userAttrs); + when(ldapCtx.search(any(String.class), any(String.class), any(SearchControls.class))) + .thenReturn(userSearchResults); + + // Call the method being tested + Set roles = realm.rolesFor( + new SimplePrincipalCollection("testuser", "ldapRealm"), + "testuser", ldapCtx, ldapContextFactory, session); + + // Verify correct roles are assigned based on memberOf attribute values + Set expectedRoles = new HashSet<>(Arrays.asList("admins_role", "users_role")); + assertEquals(expectedRoles, roles); + + // Verify session attributes are set with the correct roles and groups + verify(session).setAttribute("subject.userRoles", roles); + verify(session).setAttribute(any(String.class), any(Set.class)); + } + + @Test + void testGroupNameExtractionFromDN() throws Exception { + LdapRealm realm = new LdapRealm(); + realm.setUseMemberOfForNestedGroups(true); + realm.setMemberOfAttribute("memberOf"); + realm.setGroupIdAttribute("cn"); + + // Create test data with different DN structures + LdapContextFactory ldapContextFactory = mock(LdapContextFactory.class); + LdapContext ldapCtx = mock(LdapContext.class); + Session session = mock(Session.class); + + // Create user with complex memberOf structure + BasicAttributes userAttrs = new BasicAttributes(); + userAttrs.put("uid", "testuser"); + + // Add memberOf attribute with various DN formats to test extraction logic + javax.naming.directory.BasicAttribute memberOfAttr = + new javax.naming.directory.BasicAttribute("memberOf"); + // Standard group DN - cn is the leftmost part + memberOfAttr.add("cn=standard_group,ou=groups,dc=example,dc=com"); + // Complex DN - where cn appears multiple times, should extract the leftmost one + memberOfAttr.add("cn=admin_group,cn=nested,ou=groups,dc=example,dc=com"); + // DN with cn in middle - tests the fix for component ordering + memberOfAttr.add("ou=external,cn=special_group,dc=example,dc=com"); + userAttrs.put(memberOfAttr); + + // Set up role mappings to verify behavior + HashMap rolesByGroups = new HashMap<>(); + rolesByGroups.put("standard_group", "standard_role"); + rolesByGroups.put("admin_group", "admin_role"); + rolesByGroups.put("special_group", "special_role"); + realm.setRolesByGroup(rolesByGroups); + + // Configure mock to return our test user when searching + NamingEnumeration userSearchResults = enumerationOf(userAttrs); + when(ldapCtx.search(any(String.class), any(String.class), any(SearchControls.class))) + .thenReturn(userSearchResults); + + // Call the method being tested + Set roles = realm.rolesFor( + new SimplePrincipalCollection("testuser", "ldapRealm"), + "testuser", ldapCtx, ldapContextFactory, session); + + // Verify all group names were correctly extracted from the DNs + Set expectedRoles = new HashSet<>(Arrays.asList( + "standard_role", "admin_role", "special_role")); + assertEquals(expectedRoles, roles); + } + private NamingEnumeration enumerationOf(BasicAttributes... attrs) { final Iterator iterator = Arrays.asList(attrs).iterator(); return new NamingEnumeration() {