Skip to content

ZEPPELIN-6171 Add FreeIPA authentication with memberOf attribute for groups maping #4917

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/setup/security/shiro_authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
218 changes: 157 additions & 61 deletions zeppelin-server/src/main/java/org/apache/zeppelin/realm/LdapRealm.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -343,80 +348,155 @@ protected Set<String> 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<SearchResult> 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<SearchResult> 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);
Expand Down Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> 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<SearchResult> userSearchResults = enumerationOf(userAttrs);
when(ldapCtx.search(any(String.class), any(String.class), any(SearchControls.class)))
.thenReturn(userSearchResults);

// Call the method being tested
Set<String> roles = realm.rolesFor(
new SimplePrincipalCollection("testuser", "ldapRealm"),
"testuser", ldapCtx, ldapContextFactory, session);

// Verify correct roles are assigned based on memberOf attribute values
Set<String> 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<String, String> 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<SearchResult> userSearchResults = enumerationOf(userAttrs);
when(ldapCtx.search(any(String.class), any(String.class), any(SearchControls.class)))
.thenReturn(userSearchResults);

// Call the method being tested
Set<String> roles = realm.rolesFor(
new SimplePrincipalCollection("testuser", "ldapRealm"),
"testuser", ldapCtx, ldapContextFactory, session);

// Verify all group names were correctly extracted from the DNs
Set<String> expectedRoles = new HashSet<>(Arrays.asList(
"standard_role", "admin_role", "special_role"));
assertEquals(expectedRoles, roles);
}

private NamingEnumeration<SearchResult> enumerationOf(BasicAttributes... attrs) {
final Iterator<BasicAttributes> iterator = Arrays.asList(attrs).iterator();
return new NamingEnumeration<SearchResult>() {
Expand Down
Loading