From 19936e904562212af9dda01d77cdd0db134b9d0f Mon Sep 17 00:00:00 2001 From: David Han Date: Mon, 21 Sep 2026 15:35:15 -0500 Subject: [PATCH] KNOX-3485: Roles lookup replaces group entries with role entries Adds an explicit flag to enabled DN mapping in the LDAP Proxy. This fixes a consistency issue where attributes are mapped to the Proxy base DN by the entry DNs were not. The code was fixed so that either the entry DN and attribute DNs are all mapped or none mapped. The LDAPRolesLookupInterceptor is modified to differentiate between group and user entries. If the entry is a group entry, then the entry dn is replaced with the role dn. Adds new workflow test with knox configured to use role lookup. A new docker-compose.role-lookup.yml file is added to run the tests under the new configuration. The roles.json file is used for file-based role lookup so no external server is needed. The workflow test copies the test_knox_ldap_proxy_search.py tests and replaces the expected values from role lookup. The tests are also run with the bypass control. docker compose -f ./.github/workflows/compose/docker-compose.yml -f ./.github/workflows/compose/docker-compose.roles-lookup.yml up --exit-code-from tests tests --- .../compose/docker-compose.role-lookup.yml | 38 +++ .github/workflows/compose/docker-compose.yml | 2 +- .../compose/role-lookup/gateway-site.xml | 248 ++++++++++++++++++ .../workflows/compose/role-lookup/roles.json | 72 +++++ .../tests/test_knox_auth_service_and_ldap.py | 3 + .../tests/test_knox_ldap_proxy_search.py | 37 ++- ...knox_ldap_proxy_search_with_role_lookup.py | 102 +++++++ .../tests/test_knoxauth_preauth_and_paths.py | 1 + .../config/impl/GatewayConfigImpl.java | 6 + .../services/ldap/KnoxLDAPServerManager.java | 3 + .../knox/gateway/services/ldap/LdapUtils.java | 31 +++ .../ldap/backend/FilterMappingVisitor.java | 19 +- .../ldap/backend/LdapProxyBackend.java | 6 +- .../ldap/backend/RemoteSchemaConverter.java | 39 ++- .../LDAPRolesLookupInterceptor.java | 99 +++++-- .../services/ldap/KnoxLDAPServiceTest.java | 1 + .../gateway/services/ldap/LdapUtilsTest.java | 160 +++++++++++ .../ldap/backend/LdapProxyBackendTest.java | 3 +- .../LDAPRolesLookupInterceptorTest.java | 179 +++++++++++++ .../knox/gateway/GatewayTestConfig.java | 5 + .../knox/gateway/config/GatewayConfig.java | 6 + knox-site/docs/service_ldap_server.md | 3 + 22 files changed, 1016 insertions(+), 47 deletions(-) create mode 100644 .github/workflows/compose/docker-compose.role-lookup.yml create mode 100644 .github/workflows/compose/role-lookup/gateway-site.xml create mode 100644 .github/workflows/compose/role-lookup/roles.json create mode 100644 .github/workflows/tests/test_knox_ldap_proxy_search_with_role_lookup.py create mode 100644 gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/LdapUtilsTest.java 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