This is an automated email from the ASF dual-hosted git repository. smolnar82 pushed a commit to branch knox_idf in repository https://gitbox.apache.org/repos/asf/knox.git
commit 5b5246b8f6a5b5b92ca1c09450bb5fba0110b247 Author: Sandor Molnar <[email protected]> AuthorDate: Wed Jun 3 06:39:48 2026 +0200 KNOX-3328: Implement recursive group resolution for LDAP proxy (#1236) --- .../workflows/build/conf/topologies/knoxldap.xml | 6 +- .github/workflows/build/gateway-site.xml | 36 +-- .../tests/test_knox_auth_service_and_LDAP.py | 37 ++- .../pom.xml | 4 + .../groups/filter/HadoopGroupProviderFilter.java | 60 ++-- .../groups/filter/HadoopGroupProviderMessages.java | 3 + .../filter/HadoopGroupProviderFilterTest.java | 61 ++++ .../gateway/shirorealm/KnoxLdapContextFactory.java | 15 +- .../shirorealm/KnoxLdapContextFactoryTest.java | 16 +- gateway-release/home/conf/users.ldif | 32 ++ .../gateway/config/impl/GatewayConfigImpl.java | 10 + .../knox/gateway/services/CLIGatewayServices.java | 8 + .../services/ldap/KnoxLDAPServerManager.java | 12 + .../gateway/services/ldap/KnoxLDAPService.java | 11 +- .../knox/gateway/services/ldap/LdapMessages.java | 24 ++ .../services/ldap/backend/LdapProxyBackend.java | 302 ++++++++++++++---- .../java/org/apache/knox/gateway/util/KnoxCLI.java | 103 +++++-- .../src/main/resources/conf/gateway-site.xml | 6 + .../gateway/services/ldap/KnoxLDAPServiceTest.java | 82 ++--- .../ldap/backend/LdapProxyBackendTest.java | 340 +++++++++++---------- .../src/test/resources/ldap-recursive-test.ldif | 95 ++++++ .../org/apache/knox/gateway/GatewayTestConfig.java | 10 + .../apache/knox/gateway/config/GatewayConfig.java | 12 + .../security/principal/SimplePrincipalMapper.java | 2 - 24 files changed, 916 insertions(+), 371 deletions(-) diff --git a/.github/workflows/build/conf/topologies/knoxldap.xml b/.github/workflows/build/conf/topologies/knoxldap.xml index dc1b32237..659c31c84 100644 --- a/.github/workflows/build/conf/topologies/knoxldap.xml +++ b/.github/workflows/build/conf/topologies/knoxldap.xml @@ -48,12 +48,16 @@ limitations under the License. </provider> <provider> <role>identity-assertion</role> - <name>Default</name> + <name>HadoopGroupProvider</name> <enabled>true</enabled> <param> <name>group.principal.mapping</name> <value>admin=longGroupName1,longGroupName2,longGroupName3,longGroupName4</value> </param> + <param> + <name>CENTRAL_GROUP_CONFIG_PREFIX</name> + <value>gateway.group.config.</value> + </param> </provider> </gateway> <service> diff --git a/.github/workflows/build/gateway-site.xml b/.github/workflows/build/gateway-site.xml index 74a13a4a0..d8c1bbbd1 100644 --- a/.github/workflows/build/gateway-site.xml +++ b/.github/workflows/build/gateway-site.xml @@ -104,36 +104,8 @@ limitations under the License. <value>org.apache.hadoop.security.LdapGroupsMapping</value> </property> <property> - <name>gateway.group.config.hadoop.security.group.mapping.ldap.bind.user</name> - <value>uid=guest,ou=people,dc=hadoop,dc=apache,dc=org</value> - </property> - <property> - <name>gateway.group.config.hadoop.security.group.mapping.ldap.bind.password</name> - <value>guest-password</value> - </property> - <property> - <name>gateway.group.config.hadoop.security.group.mapping.ldap.url</name> - <value>ldap://localhost:33389</value> - </property> - <property> - <name>gateway.group.config.hadoop.security.group.mapping.ldap.base</name> - <value></value> - </property> - <property> - <name>gateway.group.config.hadoop.security.group.mapping.ldap.search.filter.user</name> - <value>(&(|(objectclass=person)(objectclass=applicationProcess))(cn={0}))</value> - </property> - <property> - <name>gateway.group.config.hadoop.security.group.mapping.ldap.search.filter.group</name> - <value>(objectclass=groupOfNames)</value> - </property> - <property> - <name>gateway.group.config.hadoop.security.group.mapping.ldap.search.attr.member</name> - <value>member</value> - </property> - <property> - <name>gateway.group.config.hadoop.security.group.mapping.ldap.search.attr.group.name</name> - <value>cn</value> + <name>gateway.group.config.use.ldap.service</name> + <value>true</value> </property> <property> <name>gateway.dispatch.whitelist.services</name> @@ -177,6 +149,10 @@ limitations under the License. <name>gateway.ldap.backend.type</name> <value>ldap</value> </property> + <property> + <name>gateway.ldap.recursive.group.resolution</name> + <value>true</value> + </property> <!-- LDAP Backend specific configuration (proxying to demo ldap) --> <property> diff --git a/.github/workflows/tests/test_knox_auth_service_and_LDAP.py b/.github/workflows/tests/test_knox_auth_service_and_LDAP.py index 43fb886ab..575d63a92 100644 --- a/.github/workflows/tests/test_knox_auth_service_and_LDAP.py +++ b/.github/workflows/tests/test_knox_auth_service_and_LDAP.py @@ -53,6 +53,11 @@ class TestKnoxAuthService(unittest.TestCase): self.assertEqual(response.headers[actor_id_header], 'guest') print(f"Verified {actor_id_header}: {response.headers[actor_id_header]}") + # Check for Actor Group header - should be empty for guest + prefix = 'x-knox-actor-groups' + all_groups = collect_actor_group_values(response, prefix=prefix) + self.assertEqual(len(all_groups), 0, f"Guest user should not have any group headers starting with {prefix}") + def test_auth_service_admin_groups(self): """ Verify that admin user gets actor ID and group headers. @@ -81,10 +86,38 @@ class TestKnoxAuthService(unittest.TestCase): if h.lower().startswith(prefix.lower()): print(f"Found group header {h}: {response.headers[h]}") - expected_groups = ['longGroupName1', 'longGroupName2', 'longGroupName3', 'longGroupName4'] + expected_groups = ['admin', 'longGroupName1', 'longGroupName2', 'longGroupName3', 'longGroupName4'] + for group in expected_groups: + self.assertIn(group, all_groups) + + def test_auth_service_recursive_user_groups(self): + """ + Verify that recursiveUser user gets actor ID and recursive group headers. + """ + print(f"\nTesting recursiveUser authentication against {self.topology_url}") + response = knox_get( + self.topology_url, + auth=HTTPBasicAuth('recursiveUser', 'recursiveUser-password'), + ) + + print(f"Status Code: {response.status_code}") + self.assertEqual(response.status_code, 200) + + # Check for Actor ID header + actor_id_header = 'x-knox-actor-username' + self.assertIn(actor_id_header, response.headers) + self.assertEqual(response.headers[actor_id_header], 'recursiveUser') + print(f"Verified {actor_id_header}: {response.headers[actor_id_header]}") + + # Check for Actor Group headers + prefix = 'x-knox-actor-groups' + all_groups = collect_actor_group_values(response, prefix=prefix) + + expected_groups = ['level1', 'level2', 'level3'] + self.assertEqual(len(all_groups), len(expected_groups)) for group in expected_groups: self.assertIn(group, all_groups) + print(f"Verified recursive groups: {all_groups}") if __name__ == '__main__': unittest.main() - diff --git a/gateway-provider-identity-assertion-hadoop-groups/pom.xml b/gateway-provider-identity-assertion-hadoop-groups/pom.xml index 31766b1cf..fa42ccc98 100644 --- a/gateway-provider-identity-assertion-hadoop-groups/pom.xml +++ b/gateway-provider-identity-assertion-hadoop-groups/pom.xml @@ -41,6 +41,10 @@ <groupId>org.apache.knox</groupId> <artifactId>gateway-provider-identity-assertion-common</artifactId> </dependency> + <dependency> + <groupId>org.apache.knox</groupId> + <artifactId>gateway-server</artifactId> + </dependency> <dependency> <groupId>commons-io</groupId> diff --git a/gateway-provider-identity-assertion-hadoop-groups/src/main/java/org/apache/knox/gateway/identityasserter/hadoop/groups/filter/HadoopGroupProviderFilter.java b/gateway-provider-identity-assertion-hadoop-groups/src/main/java/org/apache/knox/gateway/identityasserter/hadoop/groups/filter/HadoopGroupProviderFilter.java index b53695c83..e819e1a7a 100644 --- a/gateway-provider-identity-assertion-hadoop-groups/src/main/java/org/apache/knox/gateway/identityasserter/hadoop/groups/filter/HadoopGroupProviderFilter.java +++ b/gateway-provider-identity-assertion-hadoop-groups/src/main/java/org/apache/knox/gateway/identityasserter/hadoop/groups/filter/HadoopGroupProviderFilter.java @@ -17,7 +17,6 @@ */ package org.apache.knox.gateway.identityasserter.hadoop.groups.filter; -import java.io.IOException; import java.util.Enumeration; import java.util.List; @@ -26,10 +25,15 @@ import javax.servlet.FilterConfig; import javax.servlet.ServletException; import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.security.LdapGroupsMapping; +import org.apache.knox.gateway.config.GatewayConfig; import org.apache.knox.gateway.i18n.messages.MessagesFactory; import org.apache.knox.gateway.identityasserter.common.filter.CommonIdentityAssertionFilter; import org.apache.hadoop.security.GroupMappingServiceProvider; import org.apache.hadoop.security.Groups; +import org.apache.knox.gateway.services.GatewayServices; +import org.apache.knox.gateway.services.ServiceType; +import org.apache.knox.gateway.services.ldap.KnoxLDAPService; /** * A filter that integrates the Hadoop {@link GroupMappingServiceProvider} for @@ -42,8 +46,9 @@ public class HadoopGroupProviderFilter extends CommonIdentityAssertionFilter { /** * Logging */ - public static final HadoopGroupProviderMessages LOG = MessagesFactory - .get(HadoopGroupProviderMessages.class); + public static final HadoopGroupProviderMessages LOG = MessagesFactory.get(HadoopGroupProviderMessages.class); + + static final String USE_LDAP_SERVICE = "use.ldap.service"; /** * Configuration object needed by for hadoop classes @@ -55,6 +60,8 @@ public class HadoopGroupProviderFilter extends CommonIdentityAssertionFilter { */ private Groups hadoopGroups; + private KnoxLDAPService ldapService; + /* create an instance */ public HadoopGroupProviderFilter() { super(); @@ -64,26 +71,34 @@ public class HadoopGroupProviderFilter extends CommonIdentityAssertionFilter { public void init(final FilterConfig filterConfig) throws ServletException { super.init(filterConfig); - try { - hadoopConfig = new Configuration(false); - - if (filterConfig.getInitParameterNames() != null) { - - for (final Enumeration<String> keys = filterConfig - .getInitParameterNames(); keys.hasMoreElements();) { + if (shouldUseLdapService(filterConfig)) { + final GatewayServices services = (GatewayServices) filterConfig.getServletContext().getAttribute(GatewayServices.GATEWAY_SERVICES_ATTRIBUTE); + ldapService = services.getService(ServiceType.LDAP_SERVICE); + } else { + try { + hadoopConfig = new Configuration(false); - final String key = keys.nextElement(); - hadoopConfig.set(key, filterConfig.getInitParameter(key)); + if (filterConfig.getInitParameterNames() != null) { + for (final Enumeration<String> keys = filterConfig.getInitParameterNames(); keys.hasMoreElements(); ) { + final String key = keys.nextElement(); + hadoopConfig.set(key, filterConfig.getInitParameter(key)); + } } - + hadoopGroups = new Groups(hadoopConfig); + } catch (final Exception e) { + throw new ServletException(e); } - hadoopGroups = new Groups(hadoopConfig); - - } catch (final Exception e) { - throw new ServletException(e); } + } + private boolean shouldUseLdapService(final FilterConfig filterConfig) { + final String groupMappingsProvider = filterConfig.getInitParameter(GroupMappingServiceProvider.GROUP_MAPPING_CONFIG_PREFIX); + final boolean ldapGroupsMappingEnabled = LdapGroupsMapping.class.getName().equals(groupMappingsProvider); + final boolean useLdapService = Boolean.parseBoolean(filterConfig.getInitParameter(USE_LDAP_SERVICE)); + final GatewayConfig gatewayConfig = (GatewayConfig) filterConfig.getServletContext().getAttribute(GatewayConfig.GATEWAY_CONFIG_ATTRIBUTE); + final boolean ldapEnabled = gatewayConfig != null && gatewayConfig.isLDAPEnabled(); + return ldapGroupsMappingEnabled && useLdapService && ldapEnabled; } /** @@ -100,7 +115,7 @@ public class HadoopGroupProviderFilter extends CommonIdentityAssertionFilter { LOG.groupsFound(mappedPrincipalName, groupList.toString()); groups = groupList.toArray(new String[0]); - } catch (final IOException e) { + } catch (final Exception e) { if (e.toString().contains("No groups found for user")) { /* no groups found move on */ LOG.noGroupsFound(mappedPrincipalName); @@ -113,8 +128,13 @@ public class HadoopGroupProviderFilter extends CommonIdentityAssertionFilter { return groups; } - protected List<String> hadoopGroups(String mappedPrincipalName) throws IOException { - return hadoopGroups.getGroups(mappedPrincipalName); + protected List<String> hadoopGroups(String mappedPrincipalName) throws Exception { + if (ldapService == null) { + return hadoopGroups.getGroups(mappedPrincipalName); + } else { + LOG.useKnoxLDAPService(); + return ldapService.getUserGroups(mappedPrincipalName); + } } @Override diff --git a/gateway-provider-identity-assertion-hadoop-groups/src/main/java/org/apache/knox/gateway/identityasserter/hadoop/groups/filter/HadoopGroupProviderMessages.java b/gateway-provider-identity-assertion-hadoop-groups/src/main/java/org/apache/knox/gateway/identityasserter/hadoop/groups/filter/HadoopGroupProviderMessages.java index c014c9424..86ce89d02 100644 --- a/gateway-provider-identity-assertion-hadoop-groups/src/main/java/org/apache/knox/gateway/identityasserter/hadoop/groups/filter/HadoopGroupProviderMessages.java +++ b/gateway-provider-identity-assertion-hadoop-groups/src/main/java/org/apache/knox/gateway/identityasserter/hadoop/groups/filter/HadoopGroupProviderMessages.java @@ -41,4 +41,7 @@ public interface HadoopGroupProviderMessages { @Message( level = MessageLevel.DEBUG, text = "Found group mapping configuration in gateway-site" ) void groupMappingFound(); + + @Message( level = MessageLevel.INFO, text = "Using Knox LDAP service to fetch groups..." ) + void useKnoxLDAPService(); } diff --git a/gateway-provider-identity-assertion-hadoop-groups/src/test/java/org/apache/knox/gateway/identityasserter/hadoop/groups/filter/HadoopGroupProviderFilterTest.java b/gateway-provider-identity-assertion-hadoop-groups/src/test/java/org/apache/knox/gateway/identityasserter/hadoop/groups/filter/HadoopGroupProviderFilterTest.java index 39848a23e..f85d3f260 100644 --- a/gateway-provider-identity-assertion-hadoop-groups/src/test/java/org/apache/knox/gateway/identityasserter/hadoop/groups/filter/HadoopGroupProviderFilterTest.java +++ b/gateway-provider-identity-assertion-hadoop-groups/src/test/java/org/apache/knox/gateway/identityasserter/hadoop/groups/filter/HadoopGroupProviderFilterTest.java @@ -39,8 +39,14 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequestWrapper; import javax.servlet.http.HttpServletResponse; +import org.apache.hadoop.security.GroupMappingServiceProvider; +import org.apache.hadoop.security.LdapGroupsMapping; +import org.apache.knox.gateway.config.GatewayConfig; import org.apache.knox.gateway.identityasserter.common.filter.CommonIdentityAssertionFilter; import org.apache.knox.gateway.security.PrimaryPrincipal; +import org.apache.knox.gateway.services.GatewayServices; +import org.apache.knox.gateway.services.ServiceType; +import org.apache.knox.gateway.services.ldap.KnoxLDAPService; import org.easymock.EasyMock; import org.junit.Test; @@ -265,4 +271,59 @@ public class HadoopGroupProviderFilterTest { assertEquals( new HashSet<>(Arrays.asList("hadoop-group", "test-virtual-group")), calculatedGroups); } + + @Test + public void testLdapServiceIntegration() throws Exception { + KnoxLDAPService ldapService = EasyMock.createNiceMock(KnoxLDAPService.class); + doTestLdapServiceIntegration(true, Arrays.asList("group1", "group2"), ldapService); + } + + @Test + public void testFallbackToHadoopGroupsWhenLdapDisabled() throws Exception { + doTestLdapServiceIntegration(false, Collections.singletonList("hadoop-group"), null); + } + + private void doTestLdapServiceIntegration(boolean ldapEnabled, List<String> expectedGroups, KnoxLDAPService ldapService) throws Exception { + final String principalName = "test-user"; + + FilterConfig config = EasyMock.createNiceMock(FilterConfig.class); + EasyMock.expect(config.getInitParameter(GroupMappingServiceProvider.GROUP_MAPPING_CONFIG_PREFIX)).andReturn(LdapGroupsMapping.class.getName()).anyTimes(); + EasyMock.expect(config.getInitParameter(HadoopGroupProviderFilter.USE_LDAP_SERVICE)).andReturn("true").anyTimes(); + ServletContext context = EasyMock.createNiceMock(ServletContext.class); + GatewayConfig gatewayConfig = EasyMock.createNiceMock(GatewayConfig.class); + + EasyMock.expect(config.getServletContext()).andReturn(context).anyTimes(); + EasyMock.expect(context.getAttribute(GatewayConfig.GATEWAY_CONFIG_ATTRIBUTE)).andReturn(gatewayConfig).anyTimes(); + EasyMock.expect(gatewayConfig.isLDAPEnabled()).andReturn(ldapEnabled).anyTimes(); + + if (ldapEnabled) { + GatewayServices services = EasyMock.createNiceMock(GatewayServices.class); + EasyMock.expect(context.getAttribute(GatewayServices.GATEWAY_SERVICES_ATTRIBUTE)).andReturn(services).anyTimes(); + EasyMock.expect(services.getService(ServiceType.LDAP_SERVICE)).andReturn(ldapService).anyTimes(); + EasyMock.expect(ldapService.getUserGroups(principalName)).andReturn(expectedGroups).anyTimes(); + EasyMock.replay(services, ldapService); + } else { + EasyMock.expect(config.getInitParameterNames()).andReturn(Collections.emptyEnumeration()).anyTimes(); + } + + EasyMock.replay(config, context, gatewayConfig); + + HadoopGroupProviderFilter filter = new HadoopGroupProviderFilter() { + @Override + protected List<String> hadoopGroups(String mappedPrincipalName) throws Exception { + return ldapEnabled ? super.hadoopGroups(mappedPrincipalName) : expectedGroups; + } + }; + filter.init(config); + + Subject subject = new Subject(); + subject.getPrincipals().add(new PrimaryPrincipal(principalName)); + + String[] groups = filter.mapGroupPrincipals(principalName, subject); + + assertThat(Arrays.asList(groups), is(expectedGroups)); + if (ldapEnabled) { + EasyMock.verify(ldapService); + } + } } diff --git a/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/shirorealm/KnoxLdapContextFactory.java b/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/shirorealm/KnoxLdapContextFactory.java index 08773d246..57c6a0e62 100644 --- a/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/shirorealm/KnoxLdapContextFactory.java +++ b/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/shirorealm/KnoxLdapContextFactory.java @@ -70,15 +70,12 @@ public class KnoxLdapContextFactory extends JndiLdapContextFactory { } @Override - public void setSystemPassword(final String systemPass) { - if (StringUtils.isBlank(systemPass)) { - return; - } - + public String getSystemPassword() { + final String systemPass = super.getSystemPassword(); final AliasService aliasService = getAliasService(); - if (!aliasService.isAlias(systemPass)) { - super.setSystemPassword(systemPass); + if (StringUtils.isBlank(systemPass) || !aliasService.isAlias(systemPass)) { + return systemPass; } else { final String systemPasswordAlias = aliasService.extractAlias(systemPass); char[] systemPassword = null; @@ -93,10 +90,10 @@ public class KnoxLdapContextFactory extends JndiLdapContextFactory { LOG.unableToGetPassword(e); } if (systemPassword != null) { - super.setSystemPassword(new String(systemPassword)); + return new String(systemPassword); } else { - super.setSystemPassword(""); //needs to be set to blank LOG.aliasValueNotFound(clusterName, systemPasswordAlias); + return ""; //needs to be set to blank } } } diff --git a/gateway-provider-security-shiro/src/test/java/org/apache/knox/gateway/shirorealm/KnoxLdapContextFactoryTest.java b/gateway-provider-security-shiro/src/test/java/org/apache/knox/gateway/shirorealm/KnoxLdapContextFactoryTest.java index 0db8b8e91..dce734731 100644 --- a/gateway-provider-security-shiro/src/test/java/org/apache/knox/gateway/shirorealm/KnoxLdapContextFactoryTest.java +++ b/gateway-provider-security-shiro/src/test/java/org/apache/knox/gateway/shirorealm/KnoxLdapContextFactoryTest.java @@ -46,17 +46,21 @@ public class KnoxLdapContextFactoryTest { } @Test - public void testSetSystemPasswordBlank() { + public void testGetSystemPasswordBlank() { final KnoxLdapContextFactory factory = new TestKnoxLdapContextFactory(); factory.setSystemPassword(""); - assertNull(factory.getSystemPassword()); + assertEquals("", factory.getSystemPassword()); + } + @Test + public void testGetSystemPasswordNull() { + final KnoxLdapContextFactory factory = new TestKnoxLdapContextFactory(); factory.setSystemPassword(null); assertNull(factory.getSystemPassword()); } @Test - public void testSetSystemPasswordIsNotAlias() { + public void testGetSystemPasswordIsNotAlias() { final KnoxLdapContextFactory factory = new TestKnoxLdapContextFactory(); final String password = "password"; factory.setSystemPassword(password); @@ -64,17 +68,17 @@ public class KnoxLdapContextFactoryTest { } @Test - public void testSetSystemPasswordIsAliasFoundInClusterCredentialStore() throws Exception { + public void testGetSystemPasswordIsAliasFoundInClusterCredentialStore() throws Exception { testAliasFoundInCredentialStore(PasswordLocation.CLUSTER); } @Test - public void testSetSystemPasswordIsAliasFoundInGatewayCredentialStore() throws Exception { + public void testGetSystemPasswordIsAliasFoundInGatewayCredentialStore() throws Exception { testAliasFoundInCredentialStore(PasswordLocation.GATEWAY); } @Test - public void testSetSystemPasswordNotFound() throws Exception { + public void testGetSystemPasswordNotFound() throws Exception { testAliasFoundInCredentialStore(PasswordLocation.NOWHERE); } diff --git a/gateway-release/home/conf/users.ldif b/gateway-release/home/conf/users.ldif index c148b6865..999f82403 100644 --- a/gateway-release/home/conf/users.ldif +++ b/gateway-release/home/conf/users.ldif @@ -116,3 +116,35 @@ cn: admin description: admin group member: uid=admin,ou=people,dc=hadoop,dc=apache,dc=org +# entry for sample user recursiveUser +dn: uid=recursiveUser,ou=people,dc=hadoop,dc=apache,dc=org +objectclass:top +objectclass:person +objectclass:organizationalPerson +objectclass:inetOrgPerson +cn: Recursive +sn: User +uid: recursiveUser +userPassword:recursiveUser-password + +# Level 1 Group +dn: cn=level1,ou=groups,dc=hadoop,dc=apache,dc=org +objectclass:top +objectclass: groupofnames +cn: level1 +member: uid=recursiveUser,ou=people,dc=hadoop,dc=apache,dc=org + +# Level 2 Group (Member is Level 1) +dn: cn=level2,ou=groups,dc=hadoop,dc=apache,dc=org +objectclass:top +objectclass: groupofnames +cn: level2 +member: cn=level1,ou=groups,dc=hadoop,dc=apache,dc=org + +# Level 3 Group (Member is Level 2) +dn: cn=level3,ou=groups,dc=hadoop,dc=apache,dc=org +objectclass:top +objectclass: groupofnames +cn: level3 +member: cn=level2,ou=groups,dc=hadoop,dc=apache,dc=org + diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java b/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java index 68dfbd6b2..eb3f9f227 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java @@ -1798,6 +1798,16 @@ public class GatewayConfigImpl extends Configuration implements GatewayConfig { return config; } + @Override + public boolean isLDAPRecursiveGroupResolutionEnabled() { + return Boolean.parseBoolean(get(LDAP_RECURSIVE_GROUP_RESOLUTION, "false")); + } + + @Override + public int getLDAPRecursiveGroupResolutionMaxDepth() { + return getInt(LDAP_RECURSIVE_GROUP_RESOLUTION_MAX_DEPTH, 3); + } + @Override public boolean getGroupUIServicesOnHomepage() { return getBoolean(KNOX_HOMEPAGE_GROUP_UI_SERVICES, DEFAULT_GROUP_UI_SERVICES); diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/CLIGatewayServices.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/CLIGatewayServices.java index 872578716..9c2ea6e62 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/CLIGatewayServices.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/CLIGatewayServices.java @@ -25,6 +25,7 @@ import org.apache.knox.gateway.deploy.DeploymentContext; import org.apache.knox.gateway.descriptor.FilterParamDescriptor; import org.apache.knox.gateway.descriptor.ResourceDescriptor; import org.apache.knox.gateway.services.security.impl.CLIMasterService; +import org.apache.knox.gateway.services.ldap.KnoxLDAPService; import org.apache.knox.gateway.topology.Provider; public class CLIGatewayServices extends AbstractGatewayServices { @@ -55,6 +56,13 @@ public class CLIGatewayServices extends AbstractGatewayServices { addService(ServiceType.TOPOLOGY_SERVICE, gatewayServiceFactory.create(this, ServiceType.TOPOLOGY_SERVICE, config, options)); addService(ServiceType.TOKEN_STATE_SERVICE, gatewayServiceFactory.create(this, ServiceType.TOKEN_STATE_SERVICE, config, options)); + + // LDAP Service - infrastructure service for embedded LDAP server + if (config.isLDAPEnabled()) { + KnoxLDAPService ldapService = new KnoxLDAPService(); + ldapService.init(config, options); + addService(ServiceType.LDAP_SERVICE, ldapService); + } } @Override diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManager.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManager.java index cd7bb1a80..eebf9251e 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManager.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManager.java @@ -80,6 +80,9 @@ public class KnoxLDAPServerManager { backendConfig.put("dataFile", config.getLDAPBackendDataFile()); } + backendConfig.put("recursiveGroupResolution", String.valueOf(config.isLDAPRecursiveGroupResolutionEnabled())); + backendConfig.put("recursiveGroupResolutionMaxDepth", String.valueOf(config.getLDAPRecursiveGroupResolutionMaxDepth())); + // For proxy backends, extract remoteBaseDn if present this.remoteBaseDn = backendConfig.get("remoteBaseDn"); @@ -257,6 +260,15 @@ public class KnoxLDAPServerManager { return baseDn; } + /** + * Get groups for a user from the configured backend + * @param username The username + * @return List of group names + */ + public List<String> getUserGroups(String username) throws Exception { + return backend.getUserGroups(username); + } + /** * Check if the LDAP server is currently running. * diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/KnoxLDAPService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/KnoxLDAPService.java index a6f66a67e..66bcfdcb2 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/KnoxLDAPService.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/KnoxLDAPService.java @@ -23,6 +23,7 @@ import org.apache.knox.gateway.i18n.messages.MessagesFactory; import org.apache.knox.gateway.services.Service; import org.apache.knox.gateway.services.ServiceLifecycleException; +import java.util.List; import java.util.Map; /** @@ -47,7 +48,6 @@ public class KnoxLDAPService implements Service, GatewayConfigChangeListener { // Initialize the LDAP server manager with configuration ldapServerManager = new KnoxLDAPServerManager(); ldapServerManager.initialize(config); - } catch (Exception e) { throw new ServiceLifecycleException("Failed to initialize LDAP service", e); } @@ -122,4 +122,13 @@ public class KnoxLDAPService implements Service, GatewayConfigChangeListener { public boolean isEnabled() { return enabled; } + + /** + * Get groups for a user from the configured backend + * @param username The username + * @return List of group names + */ + public List<String> getUserGroups(String username) throws Exception { + return ldapServerManager == null ? List.of() : ldapServerManager.getUserGroups(username); + } } diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/LdapMessages.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/LdapMessages.java index cc92a099d..221e36e4d 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/LdapMessages.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/LdapMessages.java @@ -110,4 +110,28 @@ public interface LdapMessages { @Message(level = MessageLevel.ERROR, text = "Failed to reload LDAP service: {0}") void ldapServiceReloadFailed(@StackTrace(level = MessageLevel.DEBUG) Exception e); + + @Message(level = MessageLevel.DEBUG, text = "Recursive group search enabled: {0}, max depth: {1}") + void ldapRecursiveGroupSearchConfig(boolean enabled, int maxDepth); + + @Message(level = MessageLevel.DEBUG, text = "Recursive group search for user {0} found {1} group(s) ({2}) at depth {3}") + void ldapRecursiveGroupSearchProgress(String user, int count, String groups, int depth); + + @Message(level = MessageLevel.DEBUG, text = "Recursive group search for user {0} completed. Total groups found: {1}") + void ldapRecursiveGroupSearchFinished(String user, int count); + + @Message(level = MessageLevel.WARN, text = "Recursive group search for user {0} reached max depth {1}") + void ldapRecursiveGroupSearchMaxDepthReached(String user, int maxDepth); + + @Message(level = MessageLevel.DEBUG, text = "Cycle detected in recursive group search for user {0} at group {1}") + void ldapRecursiveGroupSearchCycleDetected(String user, String groupDn); + + @Message(level = MessageLevel.DEBUG, text = "Created skeleton group entry for {0} as actual group entry was not found in the backend") + void ldapSkeletonGroupEntryCreated(String groupDn); + + @Message(level = MessageLevel.DEBUG, text = "Found {1} parent(s) in cache for group {0}") + void ldapRecursiveGroupSearchCacheHit(String groupDn, int count); + + @Message(level = MessageLevel.DEBUG, text = "Added parent {1} to cache for group {0}") + void ldapRecursiveGroupSearchCacheAdd(String groupDn, String parentDn); } diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackend.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackend.java index 053a487bb..59355e4ad 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackend.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackend.java @@ -22,6 +22,7 @@ import org.apache.directory.api.ldap.model.cursor.EntryCursor; import org.apache.directory.api.ldap.model.entry.Attribute; import org.apache.directory.api.ldap.model.entry.DefaultEntry; import org.apache.directory.api.ldap.model.entry.Entry; +import org.apache.directory.api.ldap.model.entry.Value; import org.apache.directory.api.ldap.model.exception.LdapException; import org.apache.directory.api.ldap.model.message.SearchScope; import org.apache.directory.api.ldap.model.name.Dn; @@ -38,10 +39,13 @@ import org.apache.knox.gateway.services.ldap.LdapMessages; import java.io.IOException; import java.util.ArrayList; import java.util.Collection; -import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; /** * LDAP backend that proxies to an external LDAP server. @@ -65,6 +69,8 @@ public class LdapProxyBackend implements LdapBackend { private String userSearchFilter = "({userIdAttr}={username})"; // Will be populated with userIdentifierAttribute private String groupMemberAttribute = "memberUid"; // member for AD, memberUid for POSIX private boolean useMemberOf; // Use memberOf attribute for group lookup (efficient for AD) + private boolean recursiveGroupResolution; + private int recursiveGroupResolutionMaxDepth; private List<String> proxyEntryAttributeTypes = List.of( // "uid" will always be filled @@ -133,13 +139,16 @@ public class LdapProxyBackend implements LdapBackend { userIdentifierAttribute = config.getOrDefault("userIdentifierAttribute", "uid"); groupMemberAttribute = config.getOrDefault("groupMemberAttribute", "memberUid"); useMemberOf = Boolean.parseBoolean(config.getOrDefault("useMemberOf", "false")); + recursiveGroupResolution = Boolean.parseBoolean(config.getOrDefault("recursiveGroupResolution", "false")); + recursiveGroupResolutionMaxDepth = Integer.parseInt(config.getOrDefault("recursiveGroupResolutionMaxDepth", "3")); // Build search filter template userSearchFilter = "(" + userIdentifierAttribute + "={username})"; LOG.ldapBackendLoading(getName(), "Proxying " + proxyBaseDn + " to " + ldapUrl + " (" + remoteBaseDn + ") with " + userIdentifierAttribute + " attribute" + - (useMemberOf ? " using memberOf lookups" : " using group searches")); + (useMemberOf ? " using memberOf lookups" : " using group searches") + + (recursiveGroupResolution ? " with recursive group resolution (max depth: " + recursiveGroupResolutionMaxDepth + ")" : "")); // Initialize connection pool initializeConnectionPool(config); @@ -281,15 +290,12 @@ public class LdapProxyBackend implements LdapBackend { connection = getConnection(); // Search for user using configurable attribute String filter = userSearchFilter.replace("{username}", username); - EntryCursor cursor = connection.search(userSearchBase, filter, SearchScope.SUBTREE, "*"); - - if (cursor.next()) { - Entry sourceEntry = cursor.get(); - Entry entry = createProxyEntry(sourceEntry, username, connection, schemaManager); - cursor.close(); - return entry; + try (EntryCursor cursor = connection.search(userSearchBase, filter, SearchScope.SUBTREE, "*")) { + if (cursor.next()) { + Entry sourceEntry = cursor.get(); + return createProxyEntry(sourceEntry, username, connection, schemaManager, createResolvedParentsCache()); + } } - cursor.close(); return null; } finally { releaseConnection(connection); @@ -301,53 +307,214 @@ public class LdapProxyBackend implements LdapBackend { LdapConnection connection = null; try { connection = getConnection(); - if (useMemberOf) { - // Use memberOf attribute for efficient AD lookups - return getUserGroupsViaMemberOf(connection, username); - } else { - // Use traditional group search approach - String filter = userSearchFilter.replace("{username}", username); - EntryCursor cursor = connection.search(userSearchBase, filter, SearchScope.SUBTREE, "dn"); + List<Entry> groups = getUserGroupsEntries(connection, username, createResolvedParentsCache()); + return getCnsFromEntries(groups); + } finally { + releaseConnection(connection); + } + } + private List<Entry> getUserGroupsEntries(LdapConnection connection, String username, Map<String, Set<Entry>> resolvedParentsCache) throws Exception { + List<Entry> groups = new ArrayList<>(); + if (useMemberOf) { + // Use memberOf attribute for efficient AD lookups + List<String> groupDns = getUserGroupsViaMemberOfInternal(connection, username); + for (String groupDn : groupDns) { + if (recursiveGroupResolution) { + // We only need the Entry if we are doing recursive group resolution + try { + Entry groupEntry = connection.lookup(groupDn, "cn"); + if (groupEntry != null) { + groups.add(groupEntry); + } else { + groups.add(createSkeletonGroupEntry(groupDn)); + } + } catch (LdapException e) { + groups.add(createSkeletonGroupEntry(groupDn)); + } + } else { + // Optimized path: just use skeleton entry to extract CN from DN + groups.add(createSkeletonGroupEntry(groupDn)); + } + } + } else { + // Use traditional group search approach + String filter = userSearchFilter.replace("{username}", username); + try (EntryCursor cursor = connection.search(userSearchBase, filter, SearchScope.SUBTREE, "dn")) { if (cursor.next()) { String userDn = cursor.get().getDn().toString(); - cursor.close(); - return getCnsFromEntries(getUserGroupsInternal(connection, userDn, username)); + groups = getUserGroupsInternal(connection, userDn, username); + } + } + } + + if (recursiveGroupResolution && !groups.isEmpty()) { + groups = resolveGroupsRecursive(connection, groups, username, resolvedParentsCache); + } + return groups; + } + + private List<String> getUserGroupsViaMemberOfInternal(LdapConnection connection, String username) throws LdapException, CursorException, IOException { + final List<String> groupDns = new ArrayList<>(); + // Search for user and retrieve memberOf attribute + final String filter = userSearchFilter.replace("{username}", username); + try (EntryCursor cursor = connection.search(userSearchBase, filter, SearchScope.SUBTREE, "memberOf")) { + if (cursor.next()) { + final Attribute memberOfAttr = cursor.get().get("memberOf"); + if (memberOfAttr != null) { + for (Value value : memberOfAttr) { + groupDns.add(value.getString()); + } } + } + } + return groupDns; + } - cursor.close(); + /** + * Creates a skeleton group entry from a DN when the actual group entry cannot be + * found in the backend. This ensures backward compatibility with the original + * implementation which extracted group names directly from memberOf DNs even + * if the referenced group entries did not exist. + * + * @param groupDn The Distinguished Name of the group + * @return A skeleton Entry containing the DN and CN (if extractable), or null if creation fails + */ + private Entry createSkeletonGroupEntry(String groupDn) { + try { + Entry entry = new DefaultEntry(groupDn); + String groupName = extractGroupNameFromDn(groupDn); + if (groupName != null) { + entry.add("cn", groupName); } - return Collections.emptyList(); - } finally { - releaseConnection(connection); + LOG.ldapSkeletonGroupEntryCreated(groupDn); + return entry; + } catch (LdapException e) { + LOG.ldapSearch(groupDn, "failed to create skeleton entry"); + return null; } } - private List<String> getUserGroupsViaMemberOf(LdapConnection connection, String username) throws LdapException, CursorException, IOException { - List<String> groups = new ArrayList<>(); + private List<Entry> resolveGroupsRecursive(LdapConnection connection, List<Entry> initialGroups, String username, + Map<String, Set<Entry>> resolvedParentsCache) throws LdapException, CursorException, IOException { + LOG.ldapRecursiveGroupSearchConfig(recursiveGroupResolution, recursiveGroupResolutionMaxDepth); - // Search for user and retrieve memberOf attribute - String filter = userSearchFilter.replace("{username}", username); - EntryCursor cursor = connection.search(userSearchBase, filter, SearchScope.SUBTREE, "memberOf"); - - if (cursor.next()) { - Entry userEntry = cursor.get(); - Attribute memberOfAttr = userEntry.get("memberOf"); - - if (memberOfAttr != null) { - // Extract group names from DNs - for (org.apache.directory.api.ldap.model.entry.Value value : memberOfAttr) { - String groupDn = value.getString(); - String groupName = extractGroupNameFromDn(groupDn); - if (groupName != null) { - groups.add(groupName); + Set<String> allGroupDns = new HashSet<>(); + List<Entry> allGroups = new ArrayList<>(); + List<Entry> currentLevelGroups = new ArrayList<>(initialGroups); + + for (Entry group : initialGroups) { + allGroupDns.add(group.getDn().getNormName()); + allGroups.add(group); + } + + logRecursiveSearchProgress(username, initialGroups, 0); + + int depth = 1; + while (!currentLevelGroups.isEmpty() && depth < recursiveGroupResolutionMaxDepth) { + List<Entry> nextLevelGroups = new ArrayList<>(); + List<Entry> groupsToSearch = new ArrayList<>(); + + // Check cache first + populateFromCache(resolvedParentsCache, currentLevelGroups, allGroupDns, allGroups, nextLevelGroups, groupsToSearch); + + if (!groupsToSearch.isEmpty()) { + StringBuilder filterBuilder = new StringBuilder("(|"); + for (Entry group : groupsToSearch) { + String dn = group.getDn().getName(); + filterBuilder.append("(member=").append(dn).append(")(uniqueMember=").append(dn).append(")"); + } + filterBuilder.append(")"); + + try (EntryCursor cursor = connection.search(groupSearchBase, filterBuilder.toString(), SearchScope.SUBTREE, "cn", "member", "uniqueMember")) { + while (cursor.next()) { + Entry parentGroup = cursor.get(); + String parentDn = parentGroup.getDn().getNormName(); + + // Update cache for all groups found in this search + updateCache(resolvedParentsCache, groupsToSearch, parentGroup); + + if (!allGroupDns.contains(parentDn)) { + allGroupDns.add(parentDn); + allGroups.add(parentGroup); + nextLevelGroups.add(parentGroup); + } else { + LOG.ldapRecursiveGroupSearchCycleDetected(username, parentDn); + } } } + + // If some groups had no parents, we still need to mark them in cache to avoid re-searching + for (Entry child : groupsToSearch) { + resolvedParentsCache.putIfAbsent(child.getDn().getNormName(), new HashSet<>()); + } + } + + logRecursiveSearchProgress(username, nextLevelGroups, depth); + currentLevelGroups = nextLevelGroups; + depth++; + + if (depth == recursiveGroupResolutionMaxDepth && !currentLevelGroups.isEmpty()) { + LOG.ldapRecursiveGroupSearchMaxDepthReached(username, recursiveGroupResolutionMaxDepth); } } - cursor.close(); - return groups; + LOG.ldapRecursiveGroupSearchFinished(username, allGroups.size()); + return allGroups; + } + + private void populateFromCache(Map<String, Set<Entry>> resolvedParentsCache, List<Entry> currentLevelGroups, Set<String> allGroupDns, List<Entry> allGroups, List<Entry> nextLevelGroups, List<Entry> groupsToSearch) { + for (Entry group : currentLevelGroups) { + Set<Entry> parents = resolvedParentsCache.get(group.getDn().getNormName()); + if (parents != null) { + LOG.ldapRecursiveGroupSearchCacheHit(group.getDn().getName(), parents.size()); + parents.forEach(parent -> { + String parentDn = parent.getDn().getNormName(); + if (!allGroupDns.contains(parentDn)) { + allGroupDns.add(parentDn); + allGroups.add(parent); + nextLevelGroups.add(parent); + } + }); + } else { + groupsToSearch.add(group); + } + } + } + + private void updateCache(Map<String, Set<Entry>> resolvedParentsCache, List<Entry> groupsToSearch, Entry parentGroup) { + for (Entry child : groupsToSearch) { + if (isMember(parentGroup, child.getDn())) { + LOG.ldapRecursiveGroupSearchCacheAdd(child.getDn().getName(), parentGroup.getDn().getName()); + resolvedParentsCache.computeIfAbsent(child.getDn().getNormName(), k -> new HashSet<>()).add(parentGroup); + } + } + } + + private boolean isMember(Entry group, Dn memberDn) { + return checkMemberAttribute(group, "member", memberDn) || checkMemberAttribute(group, "uniqueMember", memberDn); + } + + private boolean checkMemberAttribute(Entry group, String attributeName, Dn memberDn) { + final Attribute attr = group.get(attributeName); + if (attr != null) { + for (Value value : attr) { + try { + if (memberDn.equals(new Dn(value.getString()))) { + return true; + } + } catch (LdapException e) { + // Ignore invalid DNs in member attribute + } + } + } + return false; + } + + private void logRecursiveSearchProgress(String username, List<Entry> groups, int depth) { + LOG.ldapRecursiveGroupSearchProgress(username, groups.size(), + String.join(",", groups.stream().map(e -> e.getDn().getRdn().getValue()).collect(Collectors.joining())), + depth); } private String extractGroupNameFromDn(String groupDn) { @@ -381,13 +548,12 @@ public class LdapProxyBackend implements LdapBackend { ")"; } - EntryCursor cursor = connection.search(groupSearchBase, filter, SearchScope.SUBTREE, "cn"); - - while (cursor.next()) { - groups.add(cursor.get()); + try (EntryCursor cursor = connection.search(groupSearchBase, filter, SearchScope.SUBTREE, "cn")) { + while (cursor.next()) { + groups.add(cursor.get()); + } } - cursor.close(); return groups; } @@ -406,29 +572,36 @@ public class LdapProxyBackend implements LdapBackend { public List<Entry> searchUsers(String filter, SchemaManager schemaManager) throws Exception { List<Entry> results = new ArrayList<>(); LdapConnection connection = null; + Map<String, Set<Entry>> resolvedParentsCache = createResolvedParentsCache(); try { connection = getConnection(); String ldapFilter = "(" + userIdentifierAttribute + "=" + filter.trim() + ")"; - EntryCursor cursor = connection.search(userSearchBase, ldapFilter, SearchScope.SUBTREE, "*"); - - while (cursor.next()) { - Entry sourceEntry = cursor.get(); - Attribute idAttr = sourceEntry.get(userIdentifierAttribute); - if (idAttr != null) { - String username = idAttr.getString(); - Entry entry = createProxyEntry(sourceEntry, username, connection, schemaManager); - results.add(entry); + try (EntryCursor cursor = connection.search(userSearchBase, ldapFilter, SearchScope.SUBTREE, "*")) { + while (cursor.next()) { + Entry sourceEntry = cursor.get(); + Attribute idAttr = sourceEntry.get(userIdentifierAttribute); + if (idAttr != null) { + String username = idAttr.getString(); + Entry entry = createProxyEntry(sourceEntry, username, connection, schemaManager, resolvedParentsCache); + results.add(entry); + } } } - - cursor.close(); return results; } finally { releaseConnection(connection); } } + /** + * Factory method for the resolved parents cache. + * Overridden in tests to verify caching behavior. + */ + protected Map<String, Set<Entry>> createResolvedParentsCache() { + return new HashMap<>(); + } + /** * Creates a proxy entry from a backend source entry with all required attributes. * This method standardizes the conversion of backend LDAP entries to proxy entries, @@ -438,10 +611,11 @@ public class LdapProxyBackend implements LdapBackend { * @param username The username for the entry * @param connection The LDAP connection for fetching group information * @param schemaManager The schema manager for creating entries + * @param resolvedParentsCache Cache for direct parent groups to avoid redundant searches * @return A new Entry with backend DN and all copied attributes * @throws Exception if entry creation or attribute copying fails */ - private Entry createProxyEntry(Entry sourceEntry, String username, LdapConnection connection, SchemaManager schemaManager) throws Exception { + private Entry createProxyEntry(Entry sourceEntry, String username, LdapConnection connection, SchemaManager schemaManager, Map<String, Set<Entry>> resolvedParentsCache) throws Exception { // Standard proxy approach: return entry with backend DN unchanged // This preserves DN integrity for bind operations and DN references Entry entry = new DefaultEntry(schemaManager); @@ -463,10 +637,10 @@ public class LdapProxyBackend implements LdapBackend { copyAttribute(sourceEntry, entry, attributeType); } - if (useMemberOf) { + if (useMemberOf && !recursiveGroupResolution) { copyAttribute(sourceEntry, entry, proxyEntryGroupMembershipAttributeType); } else { - List<Entry> groups = getUserGroupsInternal(connection, sourceEntry.getDn().toString(), username); + List<Entry> groups = getUserGroupsEntries(connection, username, resolvedParentsCache); for (Entry groupEntry : groups) { entry.add(proxyEntryGroupMembershipAttributeType, groupEntry.getDn().getName()); } @@ -476,10 +650,10 @@ public class LdapProxyBackend implements LdapBackend { } private void copyAttribute(Entry source, Entry target, String attributeName) throws LdapException { - Attribute attr = source.get(attributeName); - if (attr != null) { + final Attribute attribute = source.get(attributeName); + if (attribute != null) { // Copy all values of the attribute (important for multi-valued attributes like objectClass) - for (org.apache.directory.api.ldap.model.entry.Value value : attr) { + for (Value value : attribute) { try { target.add(attributeName, value.getString()); } catch (LdapException e) { diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/util/KnoxCLI.java b/gateway-server/src/main/java/org/apache/knox/gateway/util/KnoxCLI.java index 141f2e816..b1465a7af 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/util/KnoxCLI.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/util/KnoxCLI.java @@ -82,6 +82,7 @@ import org.apache.knox.gateway.services.security.MasterService; import org.apache.knox.gateway.services.security.token.TokenMigrationTarget; import org.apache.knox.gateway.services.security.token.TokenStateService; import org.apache.knox.gateway.services.topology.TopologyService; +import org.apache.knox.gateway.services.ldap.KnoxLDAPService; import org.apache.knox.gateway.topology.Provider; import org.apache.knox.gateway.topology.Topology; import org.apache.knox.gateway.topology.validation.TopologyValidator; @@ -302,6 +303,8 @@ public class KnoxCLI extends Configured implements Tool { } else { command = new LDAPAuthCommand(); } + } else if(args[i].equals("ldap-user-groups-test")) { + command = new LDAPGroupTestCommand(); } else if(args[i].equals("system-user-auth-test")) { if (i + 1 >= args.length){ printKnoxShellUsage(); @@ -714,6 +717,30 @@ public class KnoxCLI extends Configured implements Tool { return services.getService(ServiceType.REMOTE_REGISTRY_CLIENT_SERVICE); } + protected String ensureNotNullUserName() { + return ensureNotNullUserName(null); + } + + protected String ensureNotNullUserName(String userName) { + if (userName != null) { + return userName; + } + + final Console c = System.console(); + if (c != null) { + return c.readLine("Username: "); + } else { + try (InputStreamReader inputStreamReader = new InputStreamReader(System.in, StandardCharsets.UTF_8); + BufferedReader reader = new BufferedReader(inputStreamReader)) { + out.println("Username: "); + return reader.readLine(); + } catch (IOException e) { + out.println(e.toString()); + return ""; + } + } + } + } private class AliasListCommand extends Command { @@ -1763,37 +1790,26 @@ public class KnoxCLI extends Configured implements Tool { * populates the username and password members. */ protected void promptCredentials() { - if(this.username == null){ - Console c = System.console(); - if( c != null) { - this.username = c.readLine("Username: "); - } else { - try(InputStreamReader inputStreamReader = new InputStreamReader(System.in, StandardCharsets.UTF_8); - BufferedReader reader = new BufferedReader(inputStreamReader)) { - out.println("Username: "); - this.username = reader.readLine(); - } catch (IOException e){ - out.println(e.toString()); - this.username = ""; - } - } - } + this.username = ensureNotNullUserName(this.username); + populatePassword(); + } - if(this.password == null){ + private void populatePassword() { + if (this.password == null) { Console c = System.console(); - if( c != null) { + if (c != null) { this.password = c.readPassword("Password: "); - }else{ - try(InputStreamReader inputStreamReader = new InputStreamReader(System.in, StandardCharsets.UTF_8); - BufferedReader reader = new BufferedReader(inputStreamReader)) { + } else { + try (InputStreamReader inputStreamReader = new InputStreamReader(System.in, StandardCharsets.UTF_8); + BufferedReader reader = new BufferedReader(inputStreamReader)) { out.println("Password: "); String pw = reader.readLine(); - if(pw != null){ + if (pw != null) { this.password = pw.toCharArray(); } else { this.password = new char[0]; } - } catch (IOException e){ + } catch (IOException e) { out.println(e.toString()); this.password = new char[0]; } @@ -1868,6 +1884,49 @@ public class KnoxCLI extends Configured implements Tool { } } + private class LDAPGroupTestCommand extends Command { + public static final String USAGE = "ldap-user-groups-test [--u username] [--d]"; + public static final String DESC = """ + This command tests the KnoxLDAPService ability to retrieve groups for a user directly from the configured LDAP backend. + Optional: [--u username]: Provide a username argument to the command"""; + + private String username; + + @Override + public String getUsage() { + return USAGE + ":\n\n" + DESC; + } + + @Override + public void execute() throws Exception { + if (user != null) { + this.username = user; + } else { + this.username = ensureNotNullUserName(); + } + + GatewayConfig config = getGatewayConfig(); + if (!config.isLDAPEnabled()) { + out.println("KnoxLDAPService is not enabled in gateway-site.xml; cannot lookup LDAP groups"); + return; + } + + try { + final KnoxLDAPService ldapService = services.getService(ServiceType.LDAP_SERVICE); + out.println("Querying KnoxLDAPService for groups of user: " + username); + List<String> groups = ldapService.getUserGroups(username); + if (groups == null || groups.isEmpty()) { + out.println(username + " does not belong to any groups"); + } else { + out.println(username + " is a member of: " + String.join(", ", groups)); + } + } catch (Exception e) { + out.println("Error retrieving groups: " + e.getMessage()); + throw e; + } + } + } + private class LDAPAuthCommand extends LDAPCommand { public static final String USAGE = "user-auth-test [--cluster clustername] [--u username] [--p password] [--g]"; diff --git a/gateway-server/src/main/resources/conf/gateway-site.xml b/gateway-server/src/main/resources/conf/gateway-site.xml index 9669a1941..ce7525e2d 100644 --- a/gateway-server/src/main/resources/conf/gateway-site.xml +++ b/gateway-server/src/main/resources/conf/gateway-site.xml @@ -56,6 +56,12 @@ limitations under the License. <description>Base DN for LDAP entries in the proxy server. Default is dc=proxy,dc=com.</description> </property> + <property> + <name>gateway.ldap.recursive.group.resolution</name> + <value>false</value> + <desciption>Boolean value indicating whether recursive group resolution is enabled in KnoxLDAPService.</desciption> + </property> + <property> <name>gateway.ldap.backend.type</name> <value>ldap</value> diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServiceTest.java index fe045194b..40777b253 100644 --- a/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServiceTest.java +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServiceTest.java @@ -95,8 +95,7 @@ public class KnoxLDAPServiceTest { @Test public void testInitWithLdapEnabledFileBackend() throws Exception { - setupMockConfigForFileBackend(); - replay(mockConfig); + setupMockConfig("file"); ldapService.init(mockConfig, new HashMap<>()); @@ -108,8 +107,7 @@ public class KnoxLDAPServiceTest { @Test public void testInitWithLdapEnabledLdapBackend() throws Exception { - setupMockConfigForLdapBackend(); - replay(mockConfig); + setupMockConfig("ldap"); ldapService.init(mockConfig, new HashMap<>()); @@ -121,19 +119,13 @@ public class KnoxLDAPServiceTest { @Test(expected = ServiceLifecycleException.class) public void testInitWithInvalidBackendType() throws Exception { - expect(mockConfig.isLDAPEnabled()).andReturn(true); - expect(mockConfig.getGatewayDataDir()).andReturn(tempDataDir.getAbsolutePath()); - expect(mockConfig.getLDAPPort()).andReturn(3890); - expect(mockConfig.getLDAPBaseDN()).andReturn("dc=test,dc=com"); - expect(mockConfig.getLDAPBackendType()).andReturn("invalid"); - expect(mockConfig.getLDAPBackendConfig("invalid")).andReturn(new HashMap<>()); - replay(mockConfig); + setupMockConfig("invalid"); ldapService.init(mockConfig, new HashMap<>()); } @Test - public void testStartWhenDisabled() throws Exception { + public void testStartAndStopWhenDisabled() throws Exception { expect(mockConfig.isLDAPEnabled()).andReturn(false); replay(mockConfig); @@ -142,16 +134,6 @@ public class KnoxLDAPServiceTest { // Should not throw exception ldapService.start(); - verify(mockConfig); - } - - @Test - public void testStopWhenDisabled() throws Exception { - expect(mockConfig.isLDAPEnabled()).andReturn(false); - replay(mockConfig); - - ldapService.init(mockConfig, new HashMap<>()); - // Should not throw exception ldapService.stop(); @@ -167,17 +149,7 @@ public class KnoxLDAPServiceTest { @Test public void testOnGatewayConfigChanged() throws Exception { - expect(mockConfig.isLDAPEnabled()).andReturn(true).anyTimes(); - expect(mockConfig.getGatewayDataDir()).andReturn(tempDataDir.getAbsolutePath()).anyTimes(); - expect(mockConfig.getLDAPPort()).andReturn(3890).times(1).andReturn(3891).anyTimes(); - expect(mockConfig.getLDAPBaseDN()).andReturn("dc=test,dc=com").anyTimes(); - expect(mockConfig.getLDAPBackendType()).andReturn("file").anyTimes(); - - Map<String, String> fileBackendConfig = new HashMap<>(); - fileBackendConfig.put("dataFile", tempLdapFile.getAbsolutePath()); - expect(mockConfig.getLDAPBackendConfig("file")).andReturn(fileBackendConfig).anyTimes(); - - replay(mockConfig); + setupMockConfig("file"); ldapService.init(mockConfig, new HashMap<>()); assertEquals("Initial port should be 3890", 3890, ldapService.getLdapPort()); @@ -190,30 +162,28 @@ public class KnoxLDAPServiceTest { verify(mockConfig); } - private void setupMockConfigForFileBackend() { - expect(mockConfig.isLDAPEnabled()).andReturn(true); - expect(mockConfig.getGatewayDataDir()).andReturn(tempDataDir.getAbsolutePath()); - expect(mockConfig.getLDAPPort()).andReturn(3890); - expect(mockConfig.getLDAPBaseDN()).andReturn("dc=test,dc=com"); - expect(mockConfig.getLDAPBackendType()).andReturn("file"); - - Map<String, String> fileBackendConfig = new HashMap<>(); - fileBackendConfig.put("dataFile", tempLdapFile.getAbsolutePath()); - expect(mockConfig.getLDAPBackendConfig("file")).andReturn(fileBackendConfig); + private void setupMockConfig(String backendType) { + expect(mockConfig.isLDAPEnabled()).andReturn(true).atLeastOnce(); + expect(mockConfig.isLDAPRecursiveGroupResolutionEnabled()).andReturn(false).atLeastOnce(); + expect(mockConfig.getLDAPRecursiveGroupResolutionMaxDepth()).andReturn(0).atLeastOnce(); + expect(mockConfig.getGatewayDataDir()).andReturn(tempDataDir.getAbsolutePath()).atLeastOnce(); + expect(mockConfig.getLDAPPort()).andReturn(3890).times(1).andReturn(3891).anyTimes(); + expect(mockConfig.getLDAPBaseDN()).andReturn("file".equals(backendType) ? "dc=test,dc=com" : "dc=proxy,dc=com").atLeastOnce(); + expect(mockConfig.getLDAPBackendType()).andReturn(backendType).atLeastOnce(); + expect(mockConfig.getLDAPBackendConfig(backendType)).andReturn(buildBackendConfig(backendType)).atLeastOnce(); + replay(mockConfig); } - private void setupMockConfigForLdapBackend() { - expect(mockConfig.isLDAPEnabled()).andReturn(true); - expect(mockConfig.getGatewayDataDir()).andReturn(tempDataDir.getAbsolutePath()); - expect(mockConfig.getLDAPPort()).andReturn(3890); - expect(mockConfig.getLDAPBaseDN()).andReturn("dc=proxy,dc=com"); - expect(mockConfig.getLDAPBackendType()).andReturn("ldap"); - - Map<String, String> ldapBackendConfig = new HashMap<>(); - ldapBackendConfig.put("url", "ldap://localhost:33389"); - ldapBackendConfig.put("remoteBaseDn", "dc=hadoop,dc=apache,dc=org"); - ldapBackendConfig.put("systemUsername", "cn=admin,dc=hadoop,dc=apache,dc=org"); - ldapBackendConfig.put("systemPassword", "admin-password"); - expect(mockConfig.getLDAPBackendConfig("ldap")).andReturn(ldapBackendConfig); + private Map<String, String> buildBackendConfig(String backendType) { + final Map<String, String> backendConfig = new HashMap<>(); + if ("ldap".equals(backendType)) { + backendConfig.put("url", "ldap://localhost:33389"); + backendConfig.put("remoteBaseDn", "dc=hadoop,dc=apache,dc=org"); + backendConfig.put("systemUsername", "cn=admin,dc=hadoop,dc=apache,dc=org"); + backendConfig.put("systemPassword", "admin-password"); + } else if ("file".equals(backendType)) { + backendConfig.put("dataFile", tempLdapFile.getAbsolutePath()); + } + return backendConfig; } } diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackendTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackendTest.java index a6b132b35..0aba49487 100644 --- a/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackendTest.java +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackendTest.java @@ -46,11 +46,11 @@ import org.junit.Test; import java.io.File; import java.util.HashMap; import java.util.HashSet; -import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; public class LdapProxyBackendTest { private static Map<String, String> ldapBackendConfig; @@ -99,13 +99,10 @@ public class LdapProxyBackendTest { // Start the DirectoryService directoryService.startup(); - // load test data from ldif file - File ldifFile = new File( - LdapProxyBackendTest.class.getResource( "/ldap-proxy-backend-test.ldif" ) - .toURI()); + // load test data from ldif files CoreSession session = directoryService.getAdminSession(); - LdifFileLoader lfl = new LdifFileLoader(session, ldifFile, null); - lfl.execute(); + loadLdif(session, "/ldap-proxy-backend-test.ldif"); + loadLdif(session, "/ldap-recursive-test.ldif"); // Create and start the LDAP server ldapServer = new LdapServer(); @@ -124,6 +121,12 @@ public class LdapProxyBackendTest { "groupSearchBase", "ou=groups,dc=hadoop,dc=apache,dc=org"); } + private static void loadLdif(CoreSession session, String ldifResource) throws Exception { + File ldifFile = new File(LdapProxyBackendTest.class.getResource(ldifResource).toURI()); + LdifFileLoader lfl = new LdifFileLoader(session, ldifFile, null); + lfl.execute(); + } + @AfterClass public static void tearDownAfterClass() throws Exception { if(ldapServer != null) { @@ -150,21 +153,10 @@ public class LdapProxyBackendTest { ldapProxyBackend.initialize(ldapBackendConfig); Entry entry = ldapProxyBackend.getUser("ldaptest1", schemaManager); - assertEquals("ldaptest1", entry.get("uid").getString()); - assertEquals("TestCn1", entry.get("cn").getString()); - assertEquals("[email protected]", entry.get("mail").getString()); - assertEquals("Test user ldaptest1", entry.get("description").getString()); - assertNull(entry.get("sAMAccountName")); - assertEquals(2, entry.get("memberOf").size()); - Set<String> expectedMemberOf = Set.of( + validateUserEntry(entry, "ldaptest1", "TestCn1", "[email protected]", "Test user ldaptest1"); + validateMemberOf(entry, Set.of( "cn=group1,ou=groups,dc=hadoop,dc=apache,dc=org", - "cn=group2,ou=groups,dc=hadoop,dc=apache,dc=org"); - Set<String> foundMemberOf = new HashSet<>(2); - Iterator<Value> memberOfs = entry.get("memberOf").iterator(); - while (memberOfs.hasNext()) { - foundMemberOf.add(memberOfs.next().getString()); - } - assertEquals(expectedMemberOf, foundMemberOf); + "cn=group2,ou=groups,dc=hadoop,dc=apache,dc=org")); } @Test @@ -177,74 +169,39 @@ public class LdapProxyBackendTest { @Test public void testGetUserByUID() throws Exception { - Map<String, String> config = new HashMap<>(ldapBackendConfig); - config.put("userIdentifierAttribute", "uid"); + Map<String, String> config = createConfigWithUserAttr("uid"); ldapProxyBackend.initialize(config); Entry entry = ldapProxyBackend.getUser("ldaptest1", schemaManager); - assertEquals("ldaptest1", entry.get("uid").getString()); - assertEquals("TestCn1", entry.get("cn").getString()); - assertEquals("[email protected]", entry.get("mail").getString()); - assertEquals("Test user ldaptest1", entry.get("description").getString()); - assertNull(entry.get("sAMAccountName")); - assertEquals(2, entry.get("memberOf").size()); - Set<String> expectedMemberOf = Set.of( + validateUserEntry(entry, "ldaptest1", "TestCn1", "[email protected]", "Test user ldaptest1"); + validateMemberOf(entry, Set.of( "cn=group1,ou=groups,dc=hadoop,dc=apache,dc=org", - "cn=group2,ou=groups,dc=hadoop,dc=apache,dc=org"); - Set<String> foundMemberOf = new HashSet<>(2); - Iterator<Value> memberOfs = entry.get("memberOf").iterator(); - while (memberOfs.hasNext()) { - foundMemberOf.add(memberOfs.next().getString()); - } - assertEquals(expectedMemberOf, foundMemberOf); + "cn=group2,ou=groups,dc=hadoop,dc=apache,dc=org")); } @Test public void testGetUserByCN() throws Exception { - Map<String, String> config = new HashMap<>(ldapBackendConfig); - config.put("userIdentifierAttribute", "cn"); + Map<String, String> config = createConfigWithUserAttr("cn"); ldapProxyBackend.initialize(config); Entry entry = ldapProxyBackend.getUser("TestCn1", schemaManager); - assertEquals("TestCn1", entry.get("uid").getString()); - assertEquals("TestCn1", entry.get("cn").getString()); - assertEquals("[email protected]", entry.get("mail").getString()); - assertEquals("Test user ldaptest1", entry.get("description").getString()); - assertNull(entry.get("sAMAccountName")); - assertEquals(2, entry.get("memberOf").size()); - Set<String> expectedMemberOf = Set.of( + validateUserEntry(entry, "TestCn1", "TestCn1", "[email protected]", "Test user ldaptest1"); + validateMemberOf(entry, Set.of( "cn=group1,ou=groups,dc=hadoop,dc=apache,dc=org", - "cn=group2,ou=groups,dc=hadoop,dc=apache,dc=org"); - Set<String> foundMemberOf = new HashSet<>(2); - Iterator<Value> memberOfs = entry.get("memberOf").iterator(); - while (memberOfs.hasNext()) { - foundMemberOf.add(memberOfs.next().getString()); - } - assertEquals(expectedMemberOf, foundMemberOf); + "cn=group2,ou=groups,dc=hadoop,dc=apache,dc=org")); } @Test public void testGetUserBySAMAccountName() throws Exception { - Map<String, String> config = new HashMap<>(ldapBackendConfig); - config.put("userIdentifierAttribute", "sAMAccountName"); + Map<String, String> config = createConfigWithUserAttr("sAMAccountName"); ldapProxyBackend.initialize(config); Entry entry = ldapProxyBackend.getUser("TestSam1", schemaManager); - assertEquals("TestSam1", entry.get("uid").getString()); - assertEquals("TestCn1", entry.get("cn").getString()); - assertEquals("[email protected]", entry.get("mail").getString()); - assertEquals("Test user ldaptest1", entry.get("description").getString()); + validateUserEntry(entry, "TestSam1", "TestCn1", "[email protected]", "Test user ldaptest1"); assertEquals("TestSam1", entry.get("sAMAccountName").getString()); - assertEquals(2, entry.get("memberOf").size()); - Set<String> expectedMemberOf = Set.of( + validateMemberOf(entry, Set.of( "cn=group1,ou=groups,dc=hadoop,dc=apache,dc=org", - "cn=group2,ou=groups,dc=hadoop,dc=apache,dc=org"); - Set<String> foundMemberOf = new HashSet<>(2); - Iterator<Value> memberOfs = entry.get("memberOf").iterator(); - while (memberOfs.hasNext()) { - foundMemberOf.add(memberOfs.next().getString()); - } - assertEquals(expectedMemberOf, foundMemberOf); + "cn=group2,ou=groups,dc=hadoop,dc=apache,dc=org")); } @Test @@ -254,22 +211,10 @@ public class LdapProxyBackendTest { ldapProxyBackend.initialize(config); Entry entry = ldapProxyBackend.getUser("ldaptest2", schemaManager); - assertEquals("ldaptest2", entry.get("uid").getString()); - assertEquals("TestCn2", entry.get("cn").getString()); - assertEquals("[email protected]", entry.get("mail").getString()); - assertEquals("Test user ldaptest2", entry.get("description").getString()); - assertNull(entry.get("sAMAccountName")); - assertEquals(2, entry.get("memberOf").size()); - Set<String> expectedMemberOf = Set.of( + validateUserEntry(entry, "ldaptest2", "TestCn2", "[email protected]", "Test user ldaptest2"); + validateMemberOf(entry, Set.of( "cn=groupMemberOf1,ou=groups,dc=hadoop,dc=apache,dc=org", - "cn=groupMemberOf2,ou=groups,dc=hadoop,dc=apache,dc=org"); - Set<String> foundMemberOf = new HashSet<>(2); - Iterator<Value> memberOfs = entry.get("memberOf").iterator(); - while (memberOfs.hasNext()) { - foundMemberOf.add(memberOfs.next().getString()); - } - assertEquals(expectedMemberOf, foundMemberOf); - + "cn=groupMemberOf2,ou=groups,dc=hadoop,dc=apache,dc=org")); } @Test @@ -330,126 +275,205 @@ public class LdapProxyBackendTest { @Test public void testSearchUsers() throws Exception { - // searches by uid by default ldapProxyBackend.initialize(ldapBackendConfig); - - List<Entry> entries = ldapProxyBackend.searchUsers("*", schemaManager); - Set<String> foundUids = new HashSet<>(); - for (Entry entry : entries) { - foundUids.add(entry.get("uid").getString()); - } - assertEquals(3, foundUids.size()); - assertTrue(foundUids.contains("ldaptest1")); - assertTrue(foundUids.contains("ldaptest2")); - assertTrue(foundUids.contains("guest")); + validateUserSearch("*", 3, Set.of("ldaptest1", "ldaptest2", "guest")); } @Test public void testSearchUsersPartial() throws Exception { - // searches by uid by default ldapProxyBackend.initialize(ldapBackendConfig); - - List<Entry> entries = ldapProxyBackend.searchUsers("ldap*", schemaManager); - Set<String> foundUids = new HashSet<>(); - for (Entry entry : entries) { - foundUids.add(entry.get("uid").getString()); - } - assertEquals(2, foundUids.size()); - assertTrue(foundUids.contains("ldaptest1")); - assertTrue(foundUids.contains("ldaptest2")); + validateUserSearch("ldap*", 2, Set.of("ldaptest1", "ldaptest2")); } @Test public void testSearchUsersNoneFound() throws Exception { - // searches by uid by default ldapProxyBackend.initialize(ldapBackendConfig); - List<Entry> entries = ldapProxyBackend.searchUsers("nobody*", schemaManager); assertTrue(entries.isEmpty()); } @Test public void testSearchUsersByCn() throws Exception { - Map<String, String> config = new HashMap<>(ldapBackendConfig); - config.put("userIdentifierAttribute", "cn"); + Map<String, String> config = createConfigWithUserAttr("cn"); ldapProxyBackend.initialize(config); - - List<Entry> entries = ldapProxyBackend.searchUsers("*", schemaManager); - Set<String> foundUids = new HashSet<>(); - for (Entry entry : entries) { - foundUids.add(entry.get("uid").getString()); - } - assertEquals(3, foundUids.size()); - assertTrue(foundUids.contains("TestCn1")); - assertTrue(foundUids.contains("TestCn2")); - assertTrue(foundUids.contains("Guest")); + validateUserSearch("*", 3, Set.of("TestCn1", "TestCn2", "Guest")); } @Test public void testSearchUsersPartialByCn() throws Exception { - Map<String, String> config = new HashMap<>(ldapBackendConfig); - config.put("userIdentifierAttribute", "cn"); + Map<String, String> config = createConfigWithUserAttr("cn"); ldapProxyBackend.initialize(config); - - List<Entry> entries = ldapProxyBackend.searchUsers("TestCn*", schemaManager); - Set<String> foundUids = new HashSet<>(); - for (Entry entry : entries) { - foundUids.add(entry.get("uid").getString()); - } - assertEquals(2, foundUids.size()); - assertTrue(foundUids.contains("TestCn1")); - assertTrue(foundUids.contains("TestCn2")); + validateUserSearch("TestCn*", 2, Set.of("TestCn1", "TestCn2")); } @Test public void testSearchUsersNoneFoundByCn() throws Exception { - Map<String, String> config = new HashMap<>(ldapBackendConfig); - config.put("userIdentifierAttribute", "cn"); + Map<String, String> config = createConfigWithUserAttr("cn"); ldapProxyBackend.initialize(config); - List<Entry> entries = ldapProxyBackend.searchUsers("nobody*", schemaManager); assertTrue(entries.isEmpty()); } @Test public void testSearchUsersBySAMAccountName() throws Exception { - Map<String, String> config = new HashMap<>(ldapBackendConfig); - config.put("userIdentifierAttribute", "sAMAccountName"); + Map<String, String> config = createConfigWithUserAttr("sAMAccountName"); ldapProxyBackend.initialize(config); - - List<Entry> entries = ldapProxyBackend.searchUsers("*", schemaManager); - Set<String> foundUids = new HashSet<>(); - for (Entry entry : entries) { - foundUids.add(entry.get("uid").getString()); - } - assertEquals(2, foundUids.size()); - assertTrue(foundUids.contains("TestSam1")); - assertTrue(foundUids.contains("TestSam2")); + validateUserSearch("*", 2, Set.of("TestSam1", "TestSam2")); } @Test public void testSearchUsersPartialBySAMAccountName() throws Exception { - Map<String, String> config = new HashMap<>(ldapBackendConfig); - config.put("userIdentifierAttribute", "sAMAccountName"); + Map<String, String> config = createConfigWithUserAttr("sAMAccountName"); ldapProxyBackend.initialize(config); - - List<Entry> entries = ldapProxyBackend.searchUsers("TestSam*", schemaManager); - Set<String> foundUids = new HashSet<>(); - for (Entry entry : entries) { - foundUids.add(entry.get("uid").getString()); - } - assertEquals(2, foundUids.size()); - assertTrue(foundUids.contains("TestSam1")); - assertTrue(foundUids.contains("TestSam2")); + validateUserSearch("TestSam*", 2, Set.of("TestSam1", "TestSam2")); } @Test public void testSearchUsersNoneFoundBySAMAccountName() throws Exception { - Map<String, String> config = new HashMap<>(ldapBackendConfig); - config.put("userIdentifierAttribute", "sAMAccountName"); + Map<String, String> config = createConfigWithUserAttr("sAMAccountName"); ldapProxyBackend.initialize(config); - List<Entry> entries = ldapProxyBackend.searchUsers("nobody*", schemaManager); assertTrue(entries.isEmpty()); } -} \ No newline at end of file + + @Test + public void testGetRecursiveUserGroupsDepth2() throws Exception { + Map<String, String> config = createRecursiveConfig(2); + ldapProxyBackend.initialize(config); + + List<String> userGroups = ldapProxyBackend.getUserGroups("recursiveUser"); + assertEquals(4, userGroups.size()); + assertTrue(userGroups.contains("level1Group")); + assertTrue(userGroups.contains("level2Group")); + assertTrue(userGroups.contains("cycleGroupA")); + assertTrue(userGroups.contains("cycleGroupB")); + } + + @Test + public void testGetRecursiveUserGroupsDepth4() throws Exception { + Map<String, String> config = createRecursiveConfig(4); + ldapProxyBackend.initialize(config); + + List<String> userGroups = ldapProxyBackend.getUserGroups("recursiveUser"); + assertEquals(6, userGroups.size()); + assertTrue(userGroups.contains("level1Group")); + assertTrue(userGroups.contains("level2Group")); + assertTrue(userGroups.contains("level3Group")); + assertTrue(userGroups.contains("level4Group")); + assertTrue(userGroups.contains("cycleGroupA")); + assertTrue(userGroups.contains("cycleGroupB")); + } + + @Test + public void testGetRecursiveUserGroupsWithCycle() throws Exception { + Map<String, String> config = createRecursiveConfig(10); + ldapProxyBackend.initialize(config); + + List<String> userGroups = ldapProxyBackend.getUserGroups("recursiveUser"); + assertTrue(userGroups.contains("cycleGroupA")); + assertTrue(userGroups.contains("cycleGroupB")); + } + + @Test + public void testGetUserRecursiveGroups() throws Exception { + Map<String, String> config = createRecursiveConfig(5); + ldapProxyBackend.initialize(config); + + Entry entry = ldapProxyBackend.getUser("recursiveUser", schemaManager); + validateMemberOf(entry, Set.of( + "cn=level1Group,ou=recursiveGroups,dc=hadoop,dc=apache,dc=org", + "cn=level2Group,ou=recursiveGroups,dc=hadoop,dc=apache,dc=org", + "cn=level3Group,ou=recursiveGroups,dc=hadoop,dc=apache,dc=org", + "cn=level4Group,ou=recursiveGroups,dc=hadoop,dc=apache,dc=org", + "cn=cycleGroupA,ou=recursiveGroups,dc=hadoop,dc=apache,dc=org", + "cn=cycleGroupB,ou=recursiveGroups,dc=hadoop,dc=apache,dc=org")); + } + + @Test + public void testSearchUsersRecursiveWithSharedGroups() throws Exception { + Map<String, String> config = createRecursiveConfig(5); + + final AtomicInteger cacheHits = new AtomicInteger(0); + ldapProxyBackend = new LdapProxyBackend() { + @Override + protected Map<String, Set<Entry>> createResolvedParentsCache() { + return new HashMap<>() { + @Override + public Set< org.apache.directory.api.ldap.model.entry.Entry> get(Object key) { + if (super.get(key) != null) { + cacheHits.incrementAndGet(); + } + return super.get(key); + } + }; + } + }; + ldapProxyBackend.initialize(config); + + // Search for all recursive users (recursiveUser and recursiveUser2) + // They share level1Group, cycleGroupA, and all their ancestors. + List<Entry> entries = ldapProxyBackend.searchUsers("recursiveUser*", schemaManager); + assertEquals(2, entries.size()); + + Set<String> expectedGroups = Set.of( + "cn=level1Group,ou=recursiveGroups,dc=hadoop,dc=apache,dc=org", + "cn=level2Group,ou=recursiveGroups,dc=hadoop,dc=apache,dc=org", + "cn=level3Group,ou=recursiveGroups,dc=hadoop,dc=apache,dc=org", + "cn=level4Group,ou=recursiveGroups,dc=hadoop,dc=apache,dc=org", + "cn=cycleGroupA,ou=recursiveGroups,dc=hadoop,dc=apache,dc=org", + "cn=cycleGroupB,ou=recursiveGroups,dc=hadoop,dc=apache,dc=org"); + + for (Entry entry : entries) { + validateMemberOf(entry, expectedGroups); + } + + // Verify that caching actually happened. + // For the second user, many groups should have been found in the cache. + assertEquals("Expected 6 cache hits for shared groups, but got " + cacheHits.get(), 6, cacheHits.get()); + } + + // Helper methods for refactoring + + private Map<String, String> createConfigWithUserAttr(String attr) { + Map<String, String> config = new HashMap<>(ldapBackendConfig); + config.put("userIdentifierAttribute", attr); + return config; + } + + private Map<String, String> createRecursiveConfig(int depth) { + Map<String, String> config = new HashMap<>(ldapBackendConfig); + config.put("recursiveGroupResolution", "true"); + config.put("recursiveGroupResolutionMaxDepth", String.valueOf(depth)); + config.put("userSearchBase", "ou=recursivePeople,dc=hadoop,dc=apache,dc=org"); + config.put("groupSearchBase", "ou=recursiveGroups,dc=hadoop,dc=apache,dc=org"); + return config; + } + + private void validateUserEntry(Entry entry, String expectedUid, String expectedCn, String expectedMail, String expectedDesc) throws Exception { + assertEquals(expectedUid, entry.get("uid").getString()); + assertEquals(expectedCn, entry.get("cn").getString()); + assertEquals(expectedMail, entry.get("mail").getString()); + assertEquals(expectedDesc, entry.get("description").getString()); + } + + private void validateMemberOf(Entry entry, Set<String> expectedGroups) throws Exception { + assertEquals(expectedGroups.size(), entry.get("memberOf").size()); + Set<String> foundGroups = new HashSet<>(); + for (Value value : entry.get("memberOf")) { + foundGroups.add(value.getString()); + } + assertEquals(expectedGroups, foundGroups); + } + + private void validateUserSearch(String filter, int expectedSize, Set<String> expectedUids) throws Exception { + List<Entry> entries = ldapProxyBackend.searchUsers(filter, schemaManager); + assertEquals(expectedSize, entries.size()); + Set<String> foundUids = new HashSet<>(); + for (Entry entry : entries) { + foundUids.add(entry.get("uid").getString()); + } + for (String uid : expectedUids) { + assertTrue("Expected UID " + uid + " not found", foundUids.contains(uid)); + } + } +} diff --git a/gateway-server/src/test/resources/ldap-recursive-test.ldif b/gateway-server/src/test/resources/ldap-recursive-test.ldif new file mode 100644 index 000000000..e6182e0dd --- /dev/null +++ b/gateway-server/src/test/resources/ldap-recursive-test.ldif @@ -0,0 +1,95 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +version: 1 + +# entry for recursive people container +dn: ou=recursivePeople,dc=hadoop,dc=apache,dc=org +objectclass:top +objectclass:organizationalUnit +ou: recursivePeople + +# entry for recursive group container +dn: ou=recursiveGroups,dc=hadoop,dc=apache,dc=org +objectClass: top +objectClass: organizationalUnit +ou: recursiveGroups + +# entry for the end user +dn: uid=recursiveUser,ou=recursivePeople,dc=hadoop,dc=apache,dc=org +objectclass:top +objectclass:person +objectclass:organizationalPerson +objectclass:inetOrgPerson +cn: RecursiveUser +sn: User +uid: recursiveUser +userPassword: password + +# entry for the second end user (for cache testing) +dn: uid=recursiveUser2,ou=recursivePeople,dc=hadoop,dc=apache,dc=org +objectclass:top +objectclass:person +objectclass:organizationalPerson +objectclass:inetOrgPerson +cn: RecursiveUser2 +sn: User2 +uid: recursiveUser2 +userPassword: password + +# Level 1 Group +dn: cn=level1Group,ou=recursiveGroups,dc=hadoop,dc=apache,dc=org +objectclass:top +objectclass:groupOfNames +cn: level1Group +member: uid=recursiveUser,ou=recursivePeople,dc=hadoop,dc=apache,dc=org +member: uid=recursiveUser2,ou=recursivePeople,dc=hadoop,dc=apache,dc=org + +# Level 2 Group +dn: cn=level2Group,ou=recursiveGroups,dc=hadoop,dc=apache,dc=org +objectclass:top +objectclass:groupOfNames +cn: level2Group +member: cn=level1Group,ou=recursiveGroups,dc=hadoop,dc=apache,dc=org + +# Level 3 Group +dn: cn=level3Group,ou=recursiveGroups,dc=hadoop,dc=apache,dc=org +objectclass:top +objectclass:groupOfNames +cn: level3Group +member: cn=level2Group,ou=recursiveGroups,dc=hadoop,dc=apache,dc=org + +# Level 4 Group +dn: cn=level4Group,ou=recursiveGroups,dc=hadoop,dc=apache,dc=org +objectclass:top +objectclass:groupOfNames +cn: level4Group +member: cn=level3Group,ou=recursiveGroups,dc=hadoop,dc=apache,dc=org + +# Cycle: Group A -> Group B -> Group A +dn: cn=cycleGroupA,ou=recursiveGroups,dc=hadoop,dc=apache,dc=org +objectclass:top +objectclass:groupOfNames +cn: cycleGroupA +member: uid=recursiveUser,ou=recursivePeople,dc=hadoop,dc=apache,dc=org +member: uid=recursiveUser2,ou=recursivePeople,dc=hadoop,dc=apache,dc=org +member: cn=cycleGroupB,ou=recursiveGroups,dc=hadoop,dc=apache,dc=org + +dn: cn=cycleGroupB,ou=recursiveGroups,dc=hadoop,dc=apache,dc=org +objectclass:top +objectclass:groupOfNames +cn: cycleGroupB +member: cn=cycleGroupA,ou=recursiveGroups,dc=hadoop,dc=apache,dc=org diff --git a/gateway-spi-common/src/main/java/org/apache/knox/gateway/GatewayTestConfig.java b/gateway-spi-common/src/main/java/org/apache/knox/gateway/GatewayTestConfig.java index ec22c1dc9..80ab4e0c8 100644 --- a/gateway-spi-common/src/main/java/org/apache/knox/gateway/GatewayTestConfig.java +++ b/gateway-spi-common/src/main/java/org/apache/knox/gateway/GatewayTestConfig.java @@ -1265,6 +1265,16 @@ public class GatewayTestConfig extends Configuration implements GatewayConfig { return Collections.emptyMap(); } + @Override + public boolean isLDAPRecursiveGroupResolutionEnabled() { + return false; + } + + @Override + public int getLDAPRecursiveGroupResolutionMaxDepth() { + return 0; + } + @Override public boolean getGroupUIServicesOnHomepage() { return false; diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java b/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java index cc174f62a..6116a3d4c 100644 --- a/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java @@ -130,6 +130,8 @@ public interface GatewayConfig { String LDAP_BASE_DN = "gateway.ldap.base.dn"; String LDAP_BACKEND_TYPE = "gateway.ldap.backend.type"; String LDAP_BACKEND_DATA_FILE = "gateway.ldap.backend.data.file"; + String LDAP_RECURSIVE_GROUP_RESOLUTION = "gateway.ldap.recursive.group.resolution"; + String LDAP_RECURSIVE_GROUP_RESOLUTION_MAX_DEPTH = "gateway.ldap.recursive.group.resolution.max.depth"; /** * The location of the gateway configuration. @@ -1087,6 +1089,16 @@ public interface GatewayConfig { */ Map<String, String> getLDAPBackendConfig(String backendType); + /** + * @return true if recursive group resolution is enabled for LDAP service + */ + boolean isLDAPRecursiveGroupResolutionEnabled(); + + /** + * @return the maximum depth for recursive group search + */ + int getLDAPRecursiveGroupResolutionMaxDepth(); + /** * @return set of all property names in the configuration */ diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/security/principal/SimplePrincipalMapper.java b/gateway-spi/src/main/java/org/apache/knox/gateway/security/principal/SimplePrincipalMapper.java index 12b97c906..090cb7b9e 100644 --- a/gateway-spi/src/main/java/org/apache/knox/gateway/security/principal/SimplePrincipalMapper.java +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/security/principal/SimplePrincipalMapper.java @@ -31,10 +31,8 @@ public class SimplePrincipalMapper implements PrincipalMapper { @Override public void loadMappingTable(String principalMapping, String groupMapping) throws PrincipalMappingException { - if (principalMapping != null) { principalMappings = parseMapping(principalMapping); groupMappings = parseMapping(groupMapping); - } } private Map<String, String[]> parseMapping(String mappings)
