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 extends Entry> 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