diff --git a/.github/workflows/compose/docker-compose.role-lookup.yml b/.github/workflows/compose/docker-compose.role-lookup.yml new file mode 100644 index 0000000000..7782343de4 --- /dev/null +++ b/.github/workflows/compose/docker-compose.role-lookup.yml @@ -0,0 +1,38 @@ +# 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. + +# Opt-in override that reconfigures the base compose stack to use role-lookup +# and runs ONLY the role-lookup E2E tests. It is intentionally not part +# of the default test run (which ignores test*_with_role_lookup.py). +# +# Usage: +# cd .github/workflows/compose +# IMAGE_TAG=knoxidf docker compose \ +# -f docker-compose.yml -f docker-compose.role-lookup.yml \ +# up --build --abort-on-container-exit --exit-code-from tests +services: + knox: + volumes: + - ./role-lookup/gateway-site.xml:/knox-runtime/conf/gateway-site.xml:ro + - ./role-lookup/roles.json:/knox-runtime/data/roles.json:ro + + tests: + command: > + bash -c "pip install -r requirements.txt + && pylint test*_with_role_lookup.py + && echo 'Waiting for knox...' + && sleep 30 + && pytest test*_with_role_lookup.py --junitxml=test-results.role-lookup.xml" + diff --git a/.github/workflows/compose/docker-compose.yml b/.github/workflows/compose/docker-compose.yml index 26b9a5ccd7..88331a6dff 100644 --- a/.github/workflows/compose/docker-compose.yml +++ b/.github/workflows/compose/docker-compose.yml @@ -156,7 +156,7 @@ services: && pylint *.py && echo 'Waiting for knox...' && sleep 30 - && pytest --ignore=test_single_eku_mtls.py --ignore=test_single_eku_no_mtls.py --ignore=test_knoxidf_federation.py --ignore=test_knox_hashicorp_vault_alias.py --junitxml=test-results.xml" + && pytest --ignore=test_single_eku_mtls.py --ignore=test_single_eku_no_mtls.py --ignore=test_knoxidf_federation.py --ignore=test_knox_hashicorp_vault_alias.py --ignore-glob=test*_with_role_lookup.py --junitxml=test-results.xml" depends_on: - knox diff --git a/.github/workflows/compose/role-lookup/gateway-site.xml b/.github/workflows/compose/role-lookup/gateway-site.xml new file mode 100644 index 0000000000..ed1aad1e9d --- /dev/null +++ b/.github/workflows/compose/role-lookup/gateway-site.xml @@ -0,0 +1,248 @@ + + + + + + gateway.service.alias.impl + org.apache.knox.gateway.services.security.impl.DefaultAliasService + + + gateway.port + 8443 + The HTTP port for the Gateway. + + + + gateway.path + gateway + The default context path for the gateway. + + + + gateway.gateway.conf.dir + deployments + The directory within GATEWAY_HOME that contains gateway topology files and deployments. + + + + + gateway.websocket.feature.enabled + true + Enable/Disable websocket feature. + + + + gateway.scope.cookies.feature.enabled + false + Enable/Disable cookie scoping feature. + + + + + + gateway.webshell.feature.enabled + true + Enable/Disable webshell feature. + + + gateway.webshell.max.concurrent.sessions + 20 + Maximum number of total concurrent webshell sessions + + + gateway.webshell.read.buffer.size + 1024 + Web Shell buffer size for reading + + + + + gateway.websocket.JWT.validation.feature.enabled + true + Enable/Disable websocket JWT validation at websocket layer. + + + + + knox.homepage.logout.enabled + true + Enable/disable logout from the Knox Homepage. + + + + + gateway.knox.token.eviction.grace.period + 0 + A duration (in seconds) beyond a token’s expiration to wait before evicting its state. This configuration only applies when server-managed token state is enabled either in gateway-site or at the topology level. + + + + + gateway.knox.admin.groups + admin + + + + + gateway.group.config.hadoop.security.group.mapping + org.apache.hadoop.security.LdapGroupsMapping + + + gateway.group.config.use.ldap.service + true + + + gateway.dispatch.whitelist.services + DATANODE,HBASEUI,HDFSUI,JOBHISTORYUI,NODEUI,YARNUI,knoxauth + The comma-delimited list of service roles for which the gateway.dispatch.whitelist should be applied. + + + gateway.dispatch.whitelist + ^https?:\/\/(www\.local\.com|localhost|127\.0\.0\.1|0:0:0:0:0:0:0:1|::1):[0-9].*$ + The whitelist to be applied for dispatches associated with the service roles specified by gateway.dispatch.whitelist.services. + If the value is DEFAULT, a domain-based whitelist will be derived from the Knox host. + + + gateway.xforwarded.header.context.append.servicename + LIVYSERVER + Add service name to x-forward-context header for the list of services defined above. + + + gateway.strict.transport.enabled + true + + + gateway.strict.transport.option + max-age=300; includeSubDomains + + + + + gateway.ldap.enabled + true + + + gateway.ldap.port + 33390 + + + gateway.ldap.base.dn + dc=proxy,dc=org + + + gateway.ldap.bind.user + uid=bind,ou=people,dc=proxy,dc=org + + + gateway.ldap.max.size.limit + 1000 + + + gateway.ldap.max.time.limit + 60000 + + + gateway.ldap.recursive.group.resolution + true + + + gateway.ldap.interceptor.names + demoldap,roleslookup + + + + gateway.ldap.ssl.enabled + true + + + gateway.ldap.ssl.keystore.path + /knox-runtime/conf/ldaps-keystore.p12 + + + gateway.ldap.ssl.keystore.password.alias + gateway_ldap_ssl_keystore_password + + + + + gateway.ldap.interceptor.demoldap.interceptorType + backend + + + gateway.ldap.interceptor.demoldap.backendType + ldap + + + gateway.ldap.interceptor.demoldap.url + ldaps://ldap:33389 + + + + gateway.ldap.interceptor.demoldap.trustAllCertificates + true + + + gateway.ldap.interceptor.demoldap.remoteBaseDn + dc=hadoop,dc=apache,dc=org + + + gateway.ldap.interceptor.demoldap.systemUsername + uid=guest,ou=people,dc=hadoop,dc=apache,dc=org + + + gateway.ldap.interceptor.demoldap.systemPassword + guest-password + + + gateway.ldap.interceptor.demoldap.userSearchBase + ou=people,dc=hadoop,dc=apache,dc=org + + + gateway.ldap.interceptor.demoldap.groupSearchBase + ou=groups,dc=hadoop,dc=apache,dc=org + + + gateway.ldap.interceptor.demoldap.groupMemberAttribute + member + + + + gateway.ldap.interceptor.demoldap.pageSize + 3 + + + + + gateway.ldap.interceptor.roleslookup.interceptorType + rolesLookup + + + + + gateway.ldap.roles.lookup.strategy + file + + + gateway.ldap.roles.lookup.file.path + /knox-runtime/data/roles.json + + + diff --git a/.github/workflows/compose/role-lookup/roles.json b/.github/workflows/compose/role-lookup/roles.json new file mode 100644 index 0000000000..b3a4743f70 --- /dev/null +++ b/.github/workflows/compose/role-lookup/roles.json @@ -0,0 +1,72 @@ +[ + { + "id": "admin", + "type": "user", + "roles": [ + { + "scope": "platform", + "name": "admin" + } + ] + }, + { + "id": "admin", + "type": "group", + "roles": [ + { + "scope": "platform", + "name": "admin" + } + ] + }, + { + "id": "analyst", + "type": "group", + "roles": [ + { + "scope": "platform", + "name": "analyst" + } + ] + }, + { + "id": "scientist", + "type": "group", + "roles": [ + { + "scope": "platform", + "name": "scientist" + } + ] + }, + { + "id": "level1", + "type": "group", + "roles": [ + { + "scope": "platform", + "name": "level1" + } + ] + }, + { + "id": "level2", + "type": "group", + "roles": [ + { + "scope": "platform", + "name": "level2" + } + ] + }, + { + "id": "level3", + "type": "group", + "roles": [ + { + "scope": "platform", + "name": "level3" + } + ] + } +] \ No newline at end of file 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 3f3ccea6e8..a11418e613 100644 --- a/.github/workflows/tests/test_knox_auth_service_and_ldap.py +++ b/.github/workflows/tests/test_knox_auth_service_and_ldap.py @@ -54,6 +54,7 @@ def test_auth_service_guest(self): # Check for Actor Group header - should be empty for guest prefix = 'x-knox-actor-groups' all_groups = collect_actor_group_values(response, prefix=prefix) + print(f"Found groups: {all_groups}") self.assertEqual( len(all_groups), 0, @@ -83,6 +84,7 @@ def test_auth_service_admin_groups(self): # We mapped admin to 'longGroupName1,longGroupName2,longGroupName3,longGroupName4' prefix = 'x-knox-actor-groups' all_groups = collect_actor_group_values(response, prefix=prefix) + print(f"Found groups: {all_groups}") self.assertTrue(len(all_groups) > 0, f"No headers found starting with {prefix}") for header_name in response.headers: if header_name.lower().startswith(prefix.lower()): @@ -120,6 +122,7 @@ def test_auth_service_recursive_user_groups(self): # Check for Actor Group headers prefix = 'x-knox-actor-groups' all_groups = collect_actor_group_values(response, prefix=prefix) + print(f"Found groups: {all_groups}") expected_groups = ['level1', 'level2', 'level3'] self.assertEqual(len(all_groups), len(expected_groups)) diff --git a/.github/workflows/tests/test_knox_ldap_proxy_search.py b/.github/workflows/tests/test_knox_ldap_proxy_search.py index 8f05705b94..beaca91b31 100644 --- a/.github/workflows/tests/test_knox_ldap_proxy_search.py +++ b/.github/workflows/tests/test_knox_ldap_proxy_search.py @@ -74,11 +74,24 @@ def setUp(self) -> None: def tearDown(self) -> None: self.connection.unbind() - def rdn_values(self, base: str, ldap_filter: str) -> list[str]: - """Run a subtree search and return the leading RDN value of each entry.""" - self.connection.search(base, ldap_filter, search_scope=ldap3.SUBTREE) + def search( + self, base: str, ldap_filter: str, controls: list[tuple] = None + ) -> list[ldap3.Entry]: + """Run a subtree search and return the entries. + Re-running search will invalidate the entries.""" + + self.connection.search( + base, + ldap_filter, + search_scope=ldap3.SUBTREE, + attributes=ldap3.ALL_ATTRIBUTES, + controls=controls) + return self.connection.entries + + def rdn_values(self, entries: list[ldap3.Entry]) -> list[str]: + """Extract the RDN value of each entry.""" values = [] - for entry in self.connection.entries: + for entry in entries: # entry_dn looks like "uid=guest,ou=people,..." or "cn=level1,ou=groups,..." first_rdn = entry.entry_dn.split(",", 1)[0] values.append(first_rdn.split("=", 1)[1]) @@ -103,46 +116,46 @@ def test_anonymous_search_rejected(self) -> None: def test_search_all_users_by_objectclass(self) -> None: """All inetOrgPerson entries under ou=people are returned.""" - users = self.rdn_values(PEOPLE_BASE, "(objectClass=inetOrgPerson)") + users = self.rdn_values(self.search(PEOPLE_BASE, "(objectClass=inetOrgPerson)")) for expected in ("guest", "admin", "sam", "tom", "recursiveUser"): self.assertIn(expected, users) def test_search_all_groups_by_objectclass(self) -> None: """All groupOfNames entries under ou=groups are returned.""" - groups = self.rdn_values(GROUPS_BASE, "(objectClass=groupOfNames)") + groups = self.rdn_values(self.search(GROUPS_BASE, "(objectClass=groupOfNames)")) for expected in ("analyst", "scientist", "admin", "level1", "level2", "level3"): self.assertIn(expected, groups) def test_search_groups_by_cn_wildcard(self) -> None: """A cn wildcard filter returns only the matching groups.""" - groups = self.rdn_values(GROUPS_BASE, "(cn=level*)") + groups = self.rdn_values(self.search(GROUPS_BASE, "(cn=level*)")) self.assertEqual({"level1", "level2", "level3"}, set(groups)) def test_search_user_by_uid(self) -> None: """A single user can still be looked up by uid.""" - users = self.rdn_values(PEOPLE_BASE, "(uid=sam)") + users = self.rdn_values(self.search(PEOPLE_BASE, "(uid=sam)")) self.assertIn("sam", users) def test_search_all_users_by_objectclass_proxy_dn(self) -> None: """All inetOrgPerson entries under ou=people are returned.""" - users = self.rdn_values(PROXY_PEOPLE_BASE, "(objectClass=inetOrgPerson)") + users = self.rdn_values(self.search(PROXY_PEOPLE_BASE, "(objectClass=inetOrgPerson)")) for expected in ("guest", "admin", "sam", "tom", "recursiveUser"): self.assertIn(expected, users) def test_search_all_groups_by_objectclass_proxy_dn(self) -> None: """All groupOfNames entries under ou=groups are returned.""" - groups = self.rdn_values(PROXY_GROUPS_BASE, "(objectClass=groupOfNames)") + groups = self.rdn_values(self.search(PROXY_GROUPS_BASE, "(objectClass=groupOfNames)")) for expected in ("analyst", "scientist", "admin", "level1", "level2", "level3"): self.assertIn(expected, groups) def test_search_groups_by_cn_wildcard_proxy_dn(self) -> None: """A cn wildcard filter returns only the matching groups.""" - groups = self.rdn_values(PROXY_GROUPS_BASE, "(cn=level*)") + groups = self.rdn_values(self.search(PROXY_GROUPS_BASE, "(cn=level*)")) self.assertEqual({"level1", "level2", "level3"}, set(groups)) def test_search_user_by_uid_proxy_dn(self) -> None: """A single user can still be looked up by uid.""" - users = self.rdn_values(PROXY_PEOPLE_BASE, "(uid=sam)") + users = self.rdn_values(self.search(PROXY_PEOPLE_BASE, "(uid=sam)")) self.assertIn("sam", users) diff --git a/.github/workflows/tests/test_knox_ldap_proxy_search_with_role_lookup.py b/.github/workflows/tests/test_knox_ldap_proxy_search_with_role_lookup.py new file mode 100644 index 0000000000..35acac0ac3 --- /dev/null +++ b/.github/workflows/tests/test_knox_ldap_proxy_search_with_role_lookup.py @@ -0,0 +1,102 @@ +# 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. + +"""Integration tests for general LDAP search through the embedded Knox LDAP proxy, +with role lookup configured. + +These reuse TestKnoxLdapProxySearch's connection setup and the tests that are +unaffected by role lookup (they run again here against the role-lookup-configured +gateway), and override the group-lookup tests: group RDNs are expected to come back +mapped to platform:-prefixed roles, unless the search bypasses role lookup via +ROLE_LOOKUP_BYPASS_CONTROL. +""" + +from __future__ import annotations + +import unittest + +from test_knox_ldap_proxy_search import ( + GROUPS_BASE, + PROXY_GROUPS_BASE, + TestKnoxLdapProxySearch, +) + +ROLE_LOOKUP_BYPASS_CONTROL = ("1.3.6.1.4.1.18060.18.0.1", False, bytearray([0x01, 0x01, 0xff])) + + +class TestKnoxLdapProxySearchWithRoleLookup(TestKnoxLdapProxySearch): + """Verify general search requests are proxied to the demo LDAP backend + with role lookup configured.""" + + def test_search_all_groups_by_objectclass(self) -> None: + """All groupOfNames entries under ou=groups come back mapped to roles.""" + groups = self.rdn_values(self.search(GROUPS_BASE, "(objectClass=groupOfNames)")) + for expected in ("platform:analyst", "platform:scientist", + "platform:admin", "platform:level1", + "platform:level2", "platform:level3"): + self.assertIn(expected, groups) + + def test_search_all_groups_by_objectclass_bypass_role_lookup(self) -> None: + """Role lookup can be bypassed, returning the unmapped group names.""" + groups = self.rdn_values( + self.search(GROUPS_BASE, "(objectClass=groupOfNames)", [ROLE_LOOKUP_BYPASS_CONTROL])) + for expected in ("analyst", "scientist", + "admin", "level1", + "level2", "level3"): + self.assertIn(expected, groups) + + def test_search_groups_by_cn_wildcard(self) -> None: + """A cn wildcard filter returns only the matching groups, mapped to roles.""" + groups = self.rdn_values(self.search(GROUPS_BASE, "(cn=level*)")) + self.assertEqual({"platform:level1", "platform:level2", "platform:level3"}, set(groups)) + + def test_search_all_groups_by_objectclass_proxy_dn(self) -> None: + """All groupOfNames entries under ou=groups come back mapped to roles.""" + groups = self.rdn_values(self.search(PROXY_GROUPS_BASE, "(objectClass=groupOfNames)")) + for expected in ("platform:analyst", "platform:scientist", + "platform:admin", "platform:level1", + "platform:level2", "platform:level3"): + self.assertIn(expected, groups) + + def test_search_all_groups_by_objectclass_proxy_dn_bypass_role_lookup(self) -> None: + """Role lookup can be bypassed, returning the unmapped group names.""" + groups = self.rdn_values( + self.search(PROXY_GROUPS_BASE, + "(objectClass=groupOfNames)", + [ROLE_LOOKUP_BYPASS_CONTROL])) + for expected in ("analyst", "scientist", + "admin", "level1", + "level2", "level3"): + self.assertIn(expected, groups) + + def test_search_groups_by_cn_wildcard_proxy_dn(self) -> None: + """A cn wildcard filter returns only the matching groups, mapped to roles.""" + groups = self.rdn_values(self.search(PROXY_GROUPS_BASE, "(cn=level*)")) + self.assertEqual({"platform:level1", "platform:level2", "platform:level3"}, set(groups)) + + def test_search_groups_by_cn_wildcard_proxy_dn_bypass_role_lookup(self) -> None: + """Role lookup can be bypassed, returning the unmapped group names.""" + groups = self.rdn_values( + self.search(PROXY_GROUPS_BASE, "(cn=level*)", [ROLE_LOOKUP_BYPASS_CONTROL])) + self.assertEqual({"level1", "level2", "level3"}, set(groups)) + + +# Drop the bare base-class reference so pytest's unittest collection doesn't also run +# it here as its own top-level test case; the subclass above still inherits its tests. +del TestKnoxLdapProxySearch + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/tests/test_knoxauth_preauth_and_paths.py b/.github/workflows/tests/test_knoxauth_preauth_and_paths.py index 28a3f0940e..9ece9bff6f 100644 --- a/.github/workflows/tests/test_knoxauth_preauth_and_paths.py +++ b/.github/workflows/tests/test_knoxauth_preauth_and_paths.py @@ -73,6 +73,7 @@ def test_preauth_get_with_admin_includes_mapped_groups(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.headers.get("x-knox-actor-username"), "admin") groups = collect_actor_group_values(response, prefix="x-knox-actor-groups") + print(f"Found groups: {groups}") for name in ("longGroupName1", "longGroupName2"): self.assertIn(name, groups) 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 58bc2eaa1f..deb5213b99 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 @@ -401,6 +401,7 @@ public class GatewayConfigImpl extends Configuration implements GatewayConfig { public static final int DEFAULT_LDAP_MAX_SIZE_LIMIT = 1000; /* The default max time for LDAP search in milliseconds */ public static final int DEFAULT_LDAP_MAX_TIME_LIMIT = 60 * 1000; + public static final boolean DEFAULT_LDAP_DN_MAPPING_ENABLED = true; public GatewayConfigImpl() { init(); @@ -1933,6 +1934,11 @@ public int getLDAPMaxTimeLimit() { return getInt(LDAP_MAX_TIME_LIMIT, DEFAULT_LDAP_MAX_TIME_LIMIT); } + @Override + public boolean getLDAPDnMappingEnabled() { + return getBoolean(LDAP_DN_MAPPING_ENABLED, DEFAULT_LDAP_DN_MAPPING_ENABLED); + } + @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/ldap/KnoxLDAPServerManager.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManager.java index a37be23ce7..615678cf41 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 @@ -86,6 +86,7 @@ public class KnoxLDAPServerManager { private int port; private String baseDn; private String bindUser; + private boolean dnMappingEnabled; // Secure (LDAPS) transport configuration private boolean sslEnabled; private String sslKeystorePath; @@ -122,6 +123,7 @@ public void initialize(GatewayConfig config) throws Exception { this.baseDn = config.getLDAPBaseDN(); this.bindUser = config.getLDAPBindUser(); validateBindUser(); + this.dnMappingEnabled = config.getLDAPDnMappingEnabled(); maxSizeLimit = config.getLDAPMaxSizeLimit(); maxTimeLimit = config.getLDAPMaxTimeLimit(); @@ -173,6 +175,7 @@ private void createInterceptors(GatewayConfig config) throws Exception { // Add common configuration interceptorConfig.put("baseDn", baseDn); + interceptorConfig.put("dnMappingEnabled", String.valueOf(dnMappingEnabled)); if (!interceptorConfig.containsKey("maxResultSetSize")) { // Set the backend to return more results than the proxy's size limit. // This will ensure that the proxy will return "Size limit exceeded" diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/LdapUtils.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/LdapUtils.java index 3b2ad14b60..90ba772433 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/LdapUtils.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/LdapUtils.java @@ -18,12 +18,43 @@ import org.apache.directory.api.ldap.model.entry.Attribute; 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.name.Dn; import org.apache.directory.api.ldap.model.name.Rdn; public class LdapUtils { + public static boolean isGroupEntry(Entry entry) throws LdapException { + final Attribute objectClass = entry.get("objectClass"); + if (objectClass == null) { + return false; + } + for (Value value : objectClass) { + final String objectClassName = value.getString(); + if ("groupOfNames".equalsIgnoreCase(objectClassName) || "groupOfUniqueNames".equalsIgnoreCase(objectClassName)) { + return true; + } + } + return false; + } + + public static boolean isUserEntry(Entry entry) throws LdapException { + final Attribute objectClass = entry.get("objectClass"); + if (objectClass == null) { + return false; + } + for (Value value : objectClass) { + final String objectClassName = value.getString(); + if ("inetOrgPerson".equalsIgnoreCase(objectClassName) + || "person".equalsIgnoreCase(objectClassName) + || "organizationalPerson".equalsIgnoreCase(objectClassName)) { + return true; + } + } + return false; + } + public static String extractUsernameFromDn(Dn dn) { if (dn == null || dn.isEmpty()) { return null; diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/FilterMappingVisitor.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/FilterMappingVisitor.java index 69d89c355c..ea8e5f9d55 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/FilterMappingVisitor.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/FilterMappingVisitor.java @@ -27,6 +27,10 @@ import java.util.ArrayList; import java.util.List; +import java.util.Locale; +import java.util.function.Function; + +import static org.apache.knox.gateway.services.ldap.backend.RemoteSchemaConverter.DN_VALUED_ATTRIBUTES; /** * FilterVisitor that maps LDAP search filters from the proxy attributes to @@ -38,12 +42,14 @@ public class FilterMappingVisitor implements FilterVisitor { private final String userObjectClass; private final String groupObjectClass; private final SchemaManager schemaManager; + private final Function dnConverter; - public FilterMappingVisitor(String userIdentifierAttribute, String userObjectClass, String groupObjectClass, SchemaManager schemaManager) { + public FilterMappingVisitor(String userIdentifierAttribute, String userObjectClass, String groupObjectClass, SchemaManager schemaManager, Function dnConverter) { this.userIdentifierAttribute = userIdentifierAttribute; this.userObjectClass = userObjectClass; this.groupObjectClass = groupObjectClass; this.schemaManager = schemaManager; + this.dnConverter = dnConverter; } @Override @@ -68,6 +74,17 @@ private Object handleLeafNode(LeafNode leafNode) { leafNode.setAttributeType(schemaManager.getAttributeType(userIdentifierAttribute)); } + // Map the dn-valued attributes from the proxy base dn to remote base dn + if (DN_VALUED_ATTRIBUTES.contains(currentAttribute.toLowerCase(Locale.ROOT))) { + if (leafNode instanceof SimpleNode) { + SimpleNode valueNode = (SimpleNode) leafNode; + Value currentValue = valueNode.getValue(); + if (currentValue != null) { + valueNode.setValue(new Value(dnConverter.apply(currentValue.toString()))); + } + } + } + // Map group or user object class values to the configured values if ("objectClass".equalsIgnoreCase(currentAttribute)) { if (leafNode instanceof SimpleNode) { 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 323011017a..becab28bcd 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 @@ -92,6 +92,7 @@ public class LdapProxyBackend implements LdapBackend { private String proxyBaseDn; // Base DN for proxy entries (e.g., dc=proxy,dc=com) private String proxyUserSearchBase; private String proxyGroupSearchBase; + private boolean dnMappingEnabled; // Backend configuration private String remoteBaseDn; // Base DN for remote server searches (e.g., dc=hadoop,dc=apache,dc=org) @@ -168,6 +169,8 @@ public LdapProxyBackend(String name, Map config) { proxyUserSearchBase = "ou=people," + proxyBaseDn; proxyGroupSearchBase = "ou=groups," + proxyBaseDn; + dnMappingEnabled = Boolean.parseBoolean(config.get("dnMappingEnabled")); + // Remote base DN is for searching the remote LDAP server remoteBaseDn = config.get("remoteBaseDn"); if (remoteBaseDn == null || remoteBaseDn.isEmpty()) { @@ -190,7 +193,8 @@ public LdapProxyBackend(String name, Map config) { remoteGroupSearchBase, remoteUserIdentifierAttribute, remoteUserObjectClass, - remoteGroupObjectClass); + remoteGroupObjectClass, + dnMappingEnabled); // Configure group lookup useMemberOf = Boolean.parseBoolean(config.getOrDefault("useMemberOf", "false")); diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/RemoteSchemaConverter.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/RemoteSchemaConverter.java index a4a5f07f6e..23542a8a2b 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/RemoteSchemaConverter.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/RemoteSchemaConverter.java @@ -27,6 +27,7 @@ import org.apache.directory.api.ldap.model.schema.SchemaManager; import org.apache.knox.gateway.i18n.messages.MessagesFactory; import org.apache.knox.gateway.services.ldap.LdapMessages; +import org.apache.knox.gateway.services.ldap.LdapUtils; import java.text.ParseException; import java.util.Locale; @@ -38,12 +39,12 @@ public class RemoteSchemaConverter { private static final LdapMessages LOG = MessagesFactory.get(LdapMessages.class); // Credential-bearing attributes that must never be surfaced through the proxy entry. - private static final Set SENSITIVE_ATTRIBUTES = Set.of( + public static final Set SENSITIVE_ATTRIBUTES = Set.of( "userpassword", "unicodepwd", "userpkcs12"); // Attributes whose values are distinguished names and therefore need remote->proxy // DN rewriting. Other attribute values (mail, description, ...) are copied verbatim. - private static final Set DN_VALUED_ATTRIBUTES = Set.of( + public static final Set DN_VALUED_ATTRIBUTES = Set.of( "member", "uniquemember", "memberof", "manager", "owner", "seealso"); // Proxy configuration @@ -58,6 +59,7 @@ public class RemoteSchemaConverter { private final String remoteUserIdentifierAttribute; private final String remoteUserObjectClass; private final String remoteGroupObjectClass; + private final boolean dnMappingEnabled; public RemoteSchemaConverter(String proxyBaseDn, String proxyUserSearchBase, @@ -67,7 +69,8 @@ public RemoteSchemaConverter(String proxyBaseDn, String remoteGroupSearchBase, String remoteUserIdentifierAttribute, String remoteUserObjectClass, - String remoteGroupObjectClass) { + String remoteGroupObjectClass, + boolean dnMappingEnabled) { this.proxyBaseDn = proxyBaseDn; this.proxyUserSearchBase = proxyUserSearchBase; this.proxyGroupSearchBase = proxyGroupSearchBase; @@ -77,6 +80,7 @@ public RemoteSchemaConverter(String proxyBaseDn, this.remoteUserIdentifierAttribute = remoteUserIdentifierAttribute; this.remoteUserObjectClass = remoteUserObjectClass; this.remoteGroupObjectClass = remoteGroupObjectClass; + this.dnMappingEnabled = dnMappingEnabled; } /** @@ -90,10 +94,15 @@ public RemoteSchemaConverter(String proxyBaseDn, * @throws LdapException if entry creation or attribute copying fails */ public Entry convertRemoteEntryToProxyEntry(Entry sourceEntry, SchemaManager schemaManager) throws LdapException { - // Standard proxy approach: return entry with backend DN unchanged - // This preserves DN integrity for bind operations and DN references Entry entry = new DefaultEntry(schemaManager); - entry.setDn(sourceEntry.getDn()); + + if (dnMappingEnabled) { + entry.setDn(convertRemoteDnToProxyDn(sourceEntry.getDn().getName())); + } else { + // Standard proxy approach: return entry with backend DN unchanged + // This preserves DN integrity for bind operations and DN references + entry.setDn(sourceEntry.getDn()); + } // Copy attributes from the backend response, skipping credential-bearing ones so // they are never exposed through the proxy. @@ -104,11 +113,13 @@ public Entry convertRemoteEntryToProxyEntry(Entry sourceEntry, SchemaManager sch copyAttribute(sourceEntry, entry, attribute.getId()); } - // Map identifier attribute to uid for consistency if needed - if (!"uid".equals(remoteUserIdentifierAttribute)) { - Attribute idAttr = sourceEntry.get(remoteUserIdentifierAttribute); - if (idAttr != null) { - entry.add("uid", idAttr.getString()); + // Map user entry identifier attribute to uid for consistency if needed + if (LdapUtils.isUserEntry(entry)) { + if (!"uid".equals(remoteUserIdentifierAttribute)) { + Attribute idAttr = sourceEntry.get(remoteUserIdentifierAttribute); + if (idAttr != null) { + entry.add("uid", idAttr.getString()); + } } } @@ -136,7 +147,7 @@ public Entry convertRemoteEntryToProxyEntry(Entry sourceEntry, SchemaManager sch * @throws ParseException if the filter cannot be parsed */ public String convertProxyFilterToRemoteFilter(String filter, SchemaManager schemaManager) throws ParseException { - FilterMappingVisitor filterMappingVisitor = new FilterMappingVisitor(remoteUserIdentifierAttribute, remoteUserObjectClass, remoteGroupObjectClass, schemaManager); + FilterMappingVisitor filterMappingVisitor = new FilterMappingVisitor(remoteUserIdentifierAttribute, remoteUserObjectClass, remoteGroupObjectClass, schemaManager, this::convertProxyDnToRemoteDn); // Filter likely has already been annotated by other interceptors. // Clean the filter by removing any modifications or annotations @@ -158,10 +169,10 @@ public void copyAttribute(Entry source, Entry target, String attributeName) { if (attribute != null) { // Only rewrite DNs for DN-valued attributes; other values (e.g. mail, description) // are copied verbatim so they are not corrupted if they happen to contain a base DN. - final boolean dnValued = DN_VALUED_ATTRIBUTES.contains(attributeName.toLowerCase(Locale.ROOT)); + final boolean convertDn = dnMappingEnabled && DN_VALUED_ATTRIBUTES.contains(attributeName.toLowerCase(Locale.ROOT)); // Copy all values of the attribute (important for multi-valued attributes like objectClass) for (Value value : attribute) { - String valueString = dnValued ? convertRemoteDnToProxyDn(value.toString()) : value.toString(); + String valueString = convertDn ? convertRemoteDnToProxyDn(value.toString()) : value.toString(); if (!target.contains(attributeName, valueString)) { try { target.add(attributeName, valueString); diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/interceptor/LDAPRolesLookupInterceptor.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/interceptor/LDAPRolesLookupInterceptor.java index c5a5a6abe8..7e7e07c1d2 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/interceptor/LDAPRolesLookupInterceptor.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/interceptor/LDAPRolesLookupInterceptor.java @@ -39,8 +39,10 @@ import java.util.ArrayList; import java.util.Collection; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; /** @@ -78,21 +80,80 @@ public EntryFilteringCursor search(SearchOperationContext ctx) throws LdapExcept throw new LdapException(e); } - if (!entries.isEmpty()) { - for (Entry entry : entries) { - try { + final List roleEntries = new ArrayList<>(); + final List resultEntries = new ArrayList<>(entries.size()); + for (Entry entry : entries) { + try { + if (LdapUtils.isGroupEntry(entry)) { + roleEntries.addAll(translateGroupEntry(entry)); + } else { final String username = LdapUtils.extractUsernameFromEntry(entry, "uid", "cn"); final Set groups = fetchGroups(entry); final Collection roles = rolesLookupService.lookupRoles(username, groups); modifyEntry(entry, roles); - } catch (Exception e) { - LOG.ldapRolesLookupFailed("Error while updating entry with roles lookup results", e); - throw new LdapException(e); + resultEntries.add(entry); + } + } catch (Exception e) { + LOG.ldapRolesLookupFailed(entry.getDn().getName(), e); + throw new LdapException(e); + } + } + resultEntries.addAll(deduplicate(roleEntries)); + + return new EntryFilteringCursorImpl(new ListCursor<>(resultEntries), ctx, ctx.getSession().getDirectoryService().getSchemaManager()); + } + + private Collection deduplicate(List roleEntries) throws LdapException { + Map dedup = new HashMap<>(); + for (Entry entry : roleEntries) { + Dn dn = entry.getDn(); + if (!dedup.containsKey(dn)) { + dedup.put(dn, entry); + } else { + combineGroupEntry(dedup.get(dn), entry); + } + } + return dedup.values(); + } + + private void combineGroupEntry(Entry entry1, Entry entry2) throws LdapException { + // combine member attribute from both entries + Attribute entry1Member = entry1.get("member"); + Attribute entry2Member = entry2.get("member"); + if (entry1Member == null && entry2Member != null) { + entry1.add(entry2Member); + } else if (entry1Member != null && entry2Member != null) { + for (Value value : entry2Member) { + if (!entry1Member.contains(value)) { + entry1Member.add(value); } } } + } - return new EntryFilteringCursorImpl(new ListCursor<>(entries), ctx, ctx.getSession().getDirectoryService().getSchemaManager()); + /** + * Translates a group entry into zero or more entries representing the roles the group + * maps to, renaming each entry's DN (and cn) to the role name. Groups with no role mapping + * are dropped from the result set. + */ + List translateGroupEntry(Entry entry) throws Exception { + final String groupName = LdapUtils.extractGroupName(entry.getDn()); + if (groupName == null) { + return List.of(); + } + final Collection roles = rolesLookupService.lookupRoles(null, Set.of(groupName)); + final List translatedEntries = new ArrayList<>(); + for (String role : roles) { + final Dn roleDn = renameCnRdn(entry.getDn(), role); + if (roleDn != null) { + final Entry roleEntry = entry.clone(); + roleEntry.setDn(roleDn); + roleEntry.removeAttributes("cn", "memberOf"); + roleEntry.add("cn", role); + translatedEntries.add(roleEntry); + } + } + return translatedEntries; } private Set fetchGroups(final Entry entry) { @@ -144,17 +205,21 @@ private void updateMemberOfAttributes(Entry entry, Collection roles, Lis } private void addRoleAttribute(Entry entry, String role, Dn templateDn) throws LdapException { - if (templateDn != null) { - // Create a new DN by replacing the CN of the template DN - List rdns = new ArrayList<>(templateDn.getRdns()); - if (!rdns.isEmpty() && rdns.get(0).getType().equalsIgnoreCase("cn")) { - rdns.set(0, new Rdn("cn", role)); - Dn roleDn = new Dn(rdns.toArray(new Rdn[0])); - entry.add("memberOf", roleDn.getName()); - return; - } + final Dn roleDn = templateDn == null ? null : renameCnRdn(templateDn, role); + entry.add("memberOf", roleDn != null ? roleDn.getName() : "cn=" + role); + } + + /** + * Builds a new DN by replacing the leading "cn" RDN of the given DN with the given value. + * Returns null if the DN's leading RDN is not "cn", since there is no safe way to rename it. + */ + private static Dn renameCnRdn(Dn dn, String newCnValue) throws LdapException { + final List rdns = new ArrayList<>(dn.getRdns()); + if (rdns.isEmpty() || !rdns.get(0).getType().equalsIgnoreCase("cn")) { + return null; } - entry.add("memberOf", "cn=" + role); + rdns.set(0, new Rdn("cn", newCnValue)); + return new Dn(rdns.toArray(new Rdn[0])); } } 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 f32c036f84..91783c1d0d 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 @@ -182,6 +182,7 @@ private void setupMockConfig(String backendType) throws Exception { expect(mockConfig.getLDAPInterceptorConfig("testbackend")).andReturn(buildBackendConfig(backendType)).atLeastOnce(); expect(mockConfig.getLDAPMaxSizeLimit()).andReturn(1000).atLeastOnce(); expect(mockConfig.getLDAPMaxTimeLimit()).andReturn(60000).atLeastOnce(); + expect(mockConfig.getLDAPDnMappingEnabled()).andReturn(true).atLeastOnce(); replay(mockConfig); } diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/LdapUtilsTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/LdapUtilsTest.java new file mode 100644 index 0000000000..58c70bf417 --- /dev/null +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/LdapUtilsTest.java @@ -0,0 +1,160 @@ +/* + * 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. + */ +package org.apache.knox.gateway.services.ldap; + +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.name.Dn; +import org.apache.directory.api.ldap.model.schema.SchemaManager; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class LdapUtilsTest { + private SchemaManager schemaManager; + + @Before + public void setUp() throws Exception { + schemaManager = SchemaManagerFactory.createSchemaManager(); + } + + @Test + public void testIsGroupEntryWithGroupOfNames() throws Exception { + final Entry entry = createEntry("cn=engineering,ou=groups,dc=hadoop,dc=apache,dc=org", "groupOfNames"); + assertTrue(LdapUtils.isGroupEntry(entry)); + } + + @Test + public void testIsGroupEntryWithGroupOfUniqueNames() throws Exception { + final Entry entry = createEntry("cn=engineering,ou=groups,dc=hadoop,dc=apache,dc=org", "groupOfUniqueNames"); + assertTrue(LdapUtils.isGroupEntry(entry)); + } + + @Test + public void testIsGroupEntryWithUserObjectClass() throws Exception { + final Entry entry = createEntry("uid=alice,ou=people,dc=hadoop,dc=apache,dc=org", "inetOrgPerson"); + assertFalse(LdapUtils.isGroupEntry(entry)); + } + + @Test + public void testIsGroupEntryWithNoObjectClass() throws Exception { + final Entry entry = new DefaultEntry(schemaManager, "cn=engineering,ou=groups,dc=hadoop,dc=apache,dc=org"); + assertFalse(LdapUtils.isGroupEntry(entry)); + } + + @Test + public void testIsUserEntryWithInetOrgPerson() throws Exception { + final Entry entry = createEntry("uid=alice,ou=people,dc=hadoop,dc=apache,dc=org", "inetOrgPerson"); + assertTrue(LdapUtils.isUserEntry(entry)); + } + + @Test + public void testIsUserEntryWithPerson() throws Exception { + final Entry entry = createEntry("uid=alice,ou=people,dc=hadoop,dc=apache,dc=org", "person"); + assertTrue(LdapUtils.isUserEntry(entry)); + } + + @Test + public void testIsUserEntryWithOrganizationalPerson() throws Exception { + final Entry entry = createEntry("uid=alice,ou=people,dc=hadoop,dc=apache,dc=org", "organizationalPerson"); + assertTrue(LdapUtils.isUserEntry(entry)); + } + + @Test + public void testIsUserEntryWithGroupObjectClass() throws Exception { + final Entry entry = createEntry("cn=engineering,ou=groups,dc=hadoop,dc=apache,dc=org", "groupOfNames"); + assertFalse(LdapUtils.isUserEntry(entry)); + } + + @Test + public void testIsUserEntryWithNoObjectClass() throws Exception { + final Entry entry = new DefaultEntry(schemaManager, "uid=alice,ou=people,dc=hadoop,dc=apache,dc=org"); + assertFalse(LdapUtils.isUserEntry(entry)); + } + + @Test + public void testExtractUsernameFromDnWithUidRdn() throws Exception { + final Dn dn = new Dn("uid=alice,ou=people,dc=hadoop,dc=apache,dc=org"); + assertEquals("alice", LdapUtils.extractUsernameFromDn(dn)); + } + + @Test + public void testExtractUsernameFromDnWithNonUidRdn() throws Exception { + final Dn dn = new Dn("cn=alice,ou=people,dc=hadoop,dc=apache,dc=org"); + assertNull(LdapUtils.extractUsernameFromDn(dn)); + } + + @Test + public void testExtractUsernameFromDnWithNullDn() { + assertNull(LdapUtils.extractUsernameFromDn(null)); + } + + @Test + public void testExtractUsernameFromDnWithEmptyDn() { + assertNull(LdapUtils.extractUsernameFromDn(new Dn())); + } + + @Test + public void testExtractUsernameFromEntryUsesFirstMatchingAttribute() throws Exception { + final Entry entry = new DefaultEntry(schemaManager); + entry.add("uid", "alice"); + entry.add("cn", "Alice Smith"); + assertEquals("alice", LdapUtils.extractUsernameFromEntry(entry, "uid", "cn")); + } + + @Test + public void testExtractUsernameFromEntryFallsBackToLaterAttribute() throws Exception { + final Entry entry = new DefaultEntry(schemaManager); + entry.add("cn", "alice"); + assertEquals("alice", LdapUtils.extractUsernameFromEntry(entry, "uid", "cn")); + } + + @Test + public void testExtractUsernameFromEntryWithNoMatchingAttribute() throws Exception { + final Entry entry = new DefaultEntry(schemaManager); + entry.add("sn", "Smith"); + assertNull(LdapUtils.extractUsernameFromEntry(entry, "uid", "cn")); + } + + @Test + public void testExtractGroupNameWithCnRdn() throws Exception { + final Dn dn = new Dn("cn=engineering,ou=groups,dc=hadoop,dc=apache,dc=org"); + assertEquals("engineering", LdapUtils.extractGroupName(dn)); + } + + @Test + public void testExtractGroupNameWithNonCnRdn() throws Exception { + final Dn dn = new Dn("ou=groups,dc=hadoop,dc=apache,dc=org"); + assertNull(LdapUtils.extractGroupName(dn)); + } + + @Test + public void testExtractGroupNameWithEmptyDn() { + assertNull(LdapUtils.extractGroupName(new Dn())); + } + + private Entry createEntry(final String dn, final String objectClass) throws Exception { + final Entry entry = new DefaultEntry(schemaManager, dn); + entry.add("objectClass", objectClass); + return entry; + } +} 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 c38c8e770b..d4bbf1a62a 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 @@ -159,7 +159,8 @@ public static void setupBeforeClass() throws Exception { "systemUsername", "uid=guest,ou=people,dc=hadoop,dc=apache,dc=org", "systemPassword", "guest-password", "userSearchBase", "ou=people,dc=hadoop,dc=apache,dc=org", - "groupSearchBase", "ou=groups,dc=hadoop,dc=apache,dc=org"); + "groupSearchBase", "ou=groups,dc=hadoop,dc=apache,dc=org", + "dnMappingEnabled", "true"); } private static void loadLdif(CoreSession session, String ldifResource) throws Exception { diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/interceptor/LDAPRolesLookupInterceptorTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/interceptor/LDAPRolesLookupInterceptorTest.java index eb396cd4b1..7022619686 100644 --- a/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/interceptor/LDAPRolesLookupInterceptorTest.java +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/interceptor/LDAPRolesLookupInterceptorTest.java @@ -33,10 +33,13 @@ import org.junit.Before; import org.junit.Test; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Set; import static org.easymock.EasyMock.anyObject; import static org.easymock.EasyMock.anyString; @@ -132,6 +135,172 @@ public void testRolesLookupWithBypass() throws Exception { assertFalse(entries.next()); } + @Test + public void testTranslateGroupEntryWithSingleRole() throws Exception { + final Entry groupEntry = createGroupEntry("cn=engineering,ou=groups,dc=hadoop,dc=apache,dc=org"); + + final LDAPRolesLookupService mockRolesService = EasyMock.createMock(LDAPRolesLookupService.class); + expect(mockRolesService.lookupRoles(null, Set.of("engineering"))) + .andReturn(List.of("viewer")); + replay(mockRolesService); + + final List translated = + new LDAPRolesLookupInterceptor(mockRolesService).translateGroupEntry(groupEntry); + + assertEquals(1, translated.size()); + final Entry roleEntry = translated.get(0); + assertEquals("cn=viewer,ou=groups,dc=hadoop,dc=apache,dc=org", roleEntry.getDn().getName()); + assertEquals("viewer", roleEntry.get("cn").getString()); + } + + @Test + public void testTranslateGroupEntryWithMultipleRoles() throws Exception { + final Entry groupEntry = createGroupEntry("cn=engineering,ou=groups,dc=hadoop,dc=apache,dc=org"); + + final LDAPRolesLookupService mockRolesService = EasyMock.createMock(LDAPRolesLookupService.class); + expect(mockRolesService.lookupRoles(null, Set.of("engineering"))) + .andReturn(Arrays.asList("viewer", "editor")); + replay(mockRolesService); + + final List translated = + new LDAPRolesLookupInterceptor(mockRolesService).translateGroupEntry(groupEntry); + + final Set resultDns = new HashSet<>(); + for (final Entry roleEntry : translated) { + resultDns.add(roleEntry.getDn().getName()); + } + assertEquals(2, translated.size()); + assertTrue(resultDns.contains("cn=viewer,ou=groups,dc=hadoop,dc=apache,dc=org")); + assertTrue(resultDns.contains("cn=editor,ou=groups,dc=hadoop,dc=apache,dc=org")); + } + + @Test + public void testTranslateGroupEntryWithNoRoleMapping() throws Exception { + final Entry groupEntry = createGroupEntry("cn=unmapped,ou=groups,dc=hadoop,dc=apache,dc=org"); + + final LDAPRolesLookupService mockRolesService = EasyMock.createMock(LDAPRolesLookupService.class); + expect(mockRolesService.lookupRoles(null, Set.of("unmapped"))) + .andReturn(Collections.emptyList()); + replay(mockRolesService); + + final List translated = + new LDAPRolesLookupInterceptor(mockRolesService).translateGroupEntry(groupEntry); + + assertTrue("Group with no role mapping should be dropped", translated.isEmpty()); + } + + @Test + public void testSearchTranslatesUserAndGroupEntries() throws Exception { + final LDAPRolesLookupService mockRolesService = EasyMock.createMock(LDAPRolesLookupService.class); + expect(mockRolesService.lookupRoles("alice", Set.of("group1"))) + .andReturn(List.of("roleA")); + expect(mockRolesService.lookupRoles(null, Set.of("engineering"))) + .andReturn(List.of("viewer")); + expect(mockRolesService.lookupRoles(null, Set.of("unmapped"))) + .andReturn(Collections.emptyList()); + replay(mockRolesService); + + final TestContext testContext = createTestContext(false, mockRolesService); + + final Entry userEntry = createUserEntry("alice", "cn=group1,ou=groups,dc=hadoop,dc=apache,dc=org"); + final Entry mappedGroupEntry = createGroupEntry("cn=engineering,ou=groups,dc=hadoop,dc=apache,dc=org"); + final Entry unmappedGroupEntry = createGroupEntry("cn=unmapped,ou=groups,dc=hadoop,dc=apache,dc=org"); + testContext.nextInterceptor.setEntries(List.of(userEntry, mappedGroupEntry, unmappedGroupEntry)); + + final EntryFilteringCursor entries = testContext.interceptor.search(testContext.ctx); + + final List results = new ArrayList<>(); + while (entries.next()) { + results.add(entries.get()); + } + + assertEquals("Unmapped group should be dropped, leaving the user entry and one role entry", + 2, results.size()); + + Entry resultUserEntry = null; + Entry resultRoleEntry = null; + for (final Entry entry : results) { + if (entry.get("uid") != null) { + resultUserEntry = entry; + } else { + resultRoleEntry = entry; + } + } + + assertMemberOf(resultUserEntry, "cn=roleA,ou=groups,dc=hadoop,dc=apache,dc=org"); + assertEquals("cn=viewer,ou=groups,dc=hadoop,dc=apache,dc=org", resultRoleEntry.getDn().getName()); + assertEquals("viewer", resultRoleEntry.get("cn").getString()); + } + + @Test + public void testSearchDeduplicatesGroupsMappingToSameRole() throws Exception { + final LDAPRolesLookupService mockRolesService = EasyMock.createMock(LDAPRolesLookupService.class); + expect(mockRolesService.lookupRoles(null, Set.of("engineering"))) + .andReturn(List.of("viewer")); + expect(mockRolesService.lookupRoles(null, Set.of("support"))) + .andReturn(List.of("viewer")); + replay(mockRolesService); + + final TestContext testContext = createTestContext(false, mockRolesService); + + final Entry engineeringGroup = createGroupEntry("cn=engineering,ou=groups,dc=hadoop,dc=apache,dc=org", + "uid=alice,ou=people,dc=hadoop,dc=apache,dc=org", + "uid=carol,ou=people,dc=hadoop,dc=apache,dc=org"); + final Entry supportGroup = createGroupEntry("cn=support,ou=groups,dc=hadoop,dc=apache,dc=org", + "uid=alice,ou=people,dc=hadoop,dc=apache,dc=org", + "uid=bob,ou=people,dc=hadoop,dc=apache,dc=org"); + testContext.nextInterceptor.setEntries(List.of(engineeringGroup, supportGroup)); + + final EntryFilteringCursor entries = testContext.interceptor.search(testContext.ctx); + + final List results = new ArrayList<>(); + while (entries.next()) { + results.add(entries.get()); + } + + assertEquals("Groups mapping to the same role should be combined into a single entry", + 1, results.size()); + + final Entry roleEntry = results.get(0); + assertEquals("cn=viewer,ou=groups,dc=hadoop,dc=apache,dc=org", roleEntry.getDn().getName()); + final Attribute member = roleEntry.get("member"); + assertEquals("Duplicate member (alice) should only appear once", 3, member.size()); + assertTrue(member.contains("uid=alice,ou=people,dc=hadoop,dc=apache,dc=org")); + assertTrue(member.contains("uid=bob,ou=people,dc=hadoop,dc=apache,dc=org")); + assertTrue(member.contains("uid=carol,ou=people,dc=hadoop,dc=apache,dc=org")); + } + + @Test + public void testSearchDeduplicatesGroupsWhenOnlyOneHasMembers() throws Exception { + final LDAPRolesLookupService mockRolesService = EasyMock.createMock(LDAPRolesLookupService.class); + expect(mockRolesService.lookupRoles(null, Set.of("engineering"))) + .andReturn(List.of("viewer")); + expect(mockRolesService.lookupRoles(null, Set.of("support"))) + .andReturn(List.of("viewer")); + replay(mockRolesService); + + final TestContext testContext = createTestContext(false, mockRolesService); + + // No members on the first group, so the merged entry should end up with only the + // second group's members rather than failing on a null "member" attribute. + final Entry engineeringGroup = createGroupEntry("cn=engineering,ou=groups,dc=hadoop,dc=apache,dc=org"); + final Entry supportGroup = createGroupEntry("cn=support,ou=groups,dc=hadoop,dc=apache,dc=org", + "uid=bob,ou=people,dc=hadoop,dc=apache,dc=org"); + testContext.nextInterceptor.setEntries(List.of(engineeringGroup, supportGroup)); + + final EntryFilteringCursor entries = testContext.interceptor.search(testContext.ctx); + + final List results = new ArrayList<>(); + while (entries.next()) { + results.add(entries.get()); + } + + assertEquals(1, results.size()); + final Attribute member = results.get(0).get("member"); + assertEquals(1, member.size()); + assertTrue(member.contains("uid=bob,ou=people,dc=hadoop,dc=apache,dc=org")); + } + private TestContext createTestContext(boolean bypass, LDAPRolesLookupService rolesService) throws Exception { DirectoryService directoryService = new SimpleDirectoryService(); directoryService.setShutdownHookEnabled(false); @@ -178,6 +347,16 @@ private Entry createUserEntry(final String username, final String... memberOfDns return entry; } + private Entry createGroupEntry(final String dn, final String... members) throws Exception { + final Entry entry = new DefaultEntry(schemaManager, dn); + entry.add("objectClass", "groupOfNames"); + entry.add("cn", entry.getDn().getRdn().getValue()); + for (final String member : members) { + entry.add("member", member); + } + return entry; + } + private void assertMemberOf(final Entry entry, final String... expectedDns) { final Attribute memberOf = entry.get("memberOf"); assertEquals("Unexpected number of memberOf attributes", expectedDns.length, memberOf.size()); 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 e74df8b9dc..a523f319f0 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 @@ -1375,6 +1375,11 @@ public int getLDAPMaxTimeLimit() { return 0; } + @Override + public boolean getLDAPDnMappingEnabled() { + return true; + } + @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 b2af39e6c9..c5987bc544 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 @@ -162,6 +162,7 @@ public interface GatewayConfig { String LDAP_SSL_ENABLED_CIPHER_SUITES = "gateway.ldap.ssl.enabled.cipher.suites"; String LDAP_MAX_SIZE_LIMIT = "gateway.ldap.max.size.limit"; String LDAP_MAX_TIME_LIMIT = "gateway.ldap.max.time.limit"; + String LDAP_DN_MAPPING_ENABLED = "gateway.ldap.dn.mapping.enabled"; // TrustedOidcIssuerService gateway-level params and their default values String TRUSTED_OIDC_ISSUER_PREFIX = "gateway.trusted.oidc.issuer."; @@ -1286,6 +1287,11 @@ default List getSigningKeyAliases() { */ int getLDAPMaxTimeLimit(); + /** + * @return true if DN mapping in the LDAP service is enabled + */ + boolean getLDAPDnMappingEnabled(); + /** * @return set of all property names in the configuration */ diff --git a/knox-site/docs/service_ldap_server.md b/knox-site/docs/service_ldap_server.md index 27f983ac34..c26d71625f 100644 --- a/knox-site/docs/service_ldap_server.md +++ b/knox-site/docs/service_ldap_server.md @@ -46,6 +46,9 @@ The service is configured in `gateway-site.xml`. | `gateway.ldap.roles.lookup.file.path` | N/A | The LDAP roles lookup file path. | | `gateway.ldap.max.size.limit` | 1000 | The maximum size limit of the result set returned by search requests. | | `gateway.ldap.max.time.limit` | 60000 | The maximum time limit for search requests in milliseconds. | +| `gateway.ldap.dn.mapping.enabled` | True | Enables or disables mapping remote DNs to the LDAP server base DN. | +| `gateway.ldap.recursive.group.resolution` | False | Enables or disables retrieval of transitive group membership through recursive search queries against remote LDAP backends. | +| `gateway.ldap.recursive.group.resolution.max.depth` | 3 | Maximum depth of recursion for transitive group membership retrieval. | ### Bind Credentials