diff --git a/.github/workflows/build/Dockerfile.local b/.github/workflows/build/Dockerfile.local
index 73e1c9bfe8..f53295de21 100644
--- a/.github/workflows/build/Dockerfile.local
+++ b/.github/workflows/build/Dockerfile.local
@@ -60,6 +60,7 @@ ADD conf/topologies/knoxtoken.xml /knox-runtime/conf/topologies/knoxtoken.xml
ADD conf/topologies/health.xml /knox-runtime/conf/topologies/health.xml
ADD conf/topologies/knoxldap.xml /knox-runtime/conf/topologies/knoxldap.xml
ADD conf/topologies/remoteauth.xml /knox-runtime/conf/topologies/remoteauth.xml
+ADD conf/users.ldif /knox-runtime/conf/users.ldif
RUN chown -R gateway /knox-runtime/
diff --git a/.github/workflows/build/conf/topologies/knoxldap.xml b/.github/workflows/build/conf/topologies/knoxldap.xml
index 102797634f..3ff631098d 100644
--- a/.github/workflows/build/conf/topologies/knoxldap.xml
+++ b/.github/workflows/build/conf/topologies/knoxldap.xml
@@ -35,7 +35,7 @@ limitations under the License.
main.ldapRealm.contextFactory.url
- ldap://ldap:33389
+ ldap://localhost:33389
main.ldapRealm.contextFactory.authenticationMechanism
diff --git a/.github/workflows/build/conf/users.ldif b/.github/workflows/build/conf/users.ldif
new file mode 100644
index 0000000000..8e19cc3625
--- /dev/null
+++ b/.github/workflows/build/conf/users.ldif
@@ -0,0 +1,86 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+version: 1
+
+dn: dc=hadoop,dc=apache,dc=org
+objectclass: organization
+objectclass: dcObject
+o: Hadoop at Apache.org
+dc: hadoop
+description: Makers of Hadoop
+
+# entry for the people container
+dn: ou=people,dc=hadoop,dc=apache,dc=org
+objectclass:top
+objectclass:organizationalUnit
+ou: people
+
+# entry for group container
+dn: ou=groups,dc=hadoop,dc=apache,dc=org
+objectClass: top
+objectClass: organizationalUnit
+ou: groups
+
+dn: cn=group1,ou=groups,dc=hadoop,dc=apache,dc=org
+objectclass:top
+objectclass:groupOfNames
+cn: group1
+member: uid=guest,ou=people,dc=hadoop,dc=apache,dc=org
+
+dn: cn=group2,ou=groups,dc=hadoop,dc=apache,dc=org
+objectclass:top
+objectclass:groupOfNames
+cn: group2
+member: uid=guest,ou=people,dc=hadoop,dc=apache,dc=org
+
+dn: cn=recursive-leaf,ou=groups,dc=hadoop,dc=apache,dc=org
+objectclass:top
+objectclass:groupOfNames
+cn: recursive-leaf
+member: uid=guest,ou=people,dc=hadoop,dc=apache,dc=org
+
+dn: cn=recursive-mid,ou=groups,dc=hadoop,dc=apache,dc=org
+objectclass:top
+objectclass:groupOfNames
+cn: recursive-mid
+member: cn=recursive-leaf,ou=groups,dc=hadoop,dc=apache,dc=org
+
+dn: cn=recursive-top,ou=groups,dc=hadoop,dc=apache,dc=org
+objectclass:top
+objectclass:groupOfNames
+cn: recursive-top
+member: cn=recursive-mid,ou=groups,dc=hadoop,dc=apache,dc=org
+
+dn: uid=guest,ou=people,dc=hadoop,dc=apache,dc=org
+objectclass:top
+objectclass:person
+objectclass:organizationalPerson
+objectclass:inetOrgPerson
+cn: Guest
+sn: User
+uid: guest
+userPassword:guest-password
+
+dn: uid=admin,ou=people,dc=hadoop,dc=apache,dc=org
+objectclass:top
+objectclass:person
+objectclass:organizationalPerson
+objectclass:inetOrgPerson
+cn: Admin
+sn: User
+uid: admin
+userPassword:admin-password
diff --git a/.github/workflows/build/gateway-site.xml b/.github/workflows/build/gateway-site.xml
index 5f333f0639..7a865f8b84 100644
--- a/.github/workflows/build/gateway-site.xml
+++ b/.github/workflows/build/gateway-site.xml
@@ -160,4 +160,60 @@ limitations under the License.
max-age=300; includeSubDomains
+
+
+ gateway.ldap.enabled
+ true
+
+
+ gateway.ldap.port
+ 33389
+
+
+ gateway.ldap.base.dn
+ dc=hadoop,dc=apache,dc=org
+
+
+ gateway.ldap.backend.type
+ ldap
+
+
+ gateway.ldap.recursive.group.resolution
+ true
+
+
+ gateway.ldap.group.max.depth
+ 10
+
+
+
+
+ gateway.ldap.backend.ldap.url
+ ldap://ldap:33389
+
+
+ gateway.ldap.backend.ldap.remoteBaseDn
+ dc=hadoop,dc=apache,dc=org
+
+
+ gateway.ldap.backend.ldap.systemUsername
+ uid=guest,ou=people,dc=hadoop,dc=apache,dc=org
+
+
+ gateway.ldap.backend.ldap.systemPassword
+ guest-password
+
+
+ gateway.ldap.backend.ldap.userSearchBase
+ ou=people,dc=hadoop,dc=apache,dc=org
+
+
+ gateway.ldap.backend.ldap.groupSearchBase
+ ou=groups,dc=hadoop,dc=apache,dc=org
+
+
+ gateway.ldap.backend.ldap.groupMemberAttribute
+ member
+
+
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 51a42d84e4..cba88c2532 100644
--- a/.github/workflows/tests/test_knox_auth_service_and_LDAP.py
+++ b/.github/workflows/tests/test_knox_auth_service_and_LDAP.py
@@ -53,6 +53,17 @@ def test_auth_service_guest(self):
self.assertEqual(response.headers[actor_id_header], 'guest')
print(f"Verified {actor_id_header}: {response.headers[actor_id_header]}")
+ # Check for groups (recursive)
+ prefix = 'x-knox-actor-groups'
+ all_groups = collect_actor_group_values(response, prefix=prefix)
+ self.assertTrue(len(all_groups) > 0, f"No headers found starting with {prefix}")
+
+ expected_groups = ['group1', 'group2', 'recursive-leaf', 'recursive-mid', 'recursive-top']
+ print(f"Found groups: {all_groups}")
+ for group in expected_groups:
+ self.assertIn(group, all_groups)
+ print(f"Verified all expected recursive groups for guest")
+
def test_auth_service_admin_groups(self):
"""
Verify that admin user gets actor ID and group headers.
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 79c67f5a2f..abf738a6c4 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
@@ -1764,6 +1764,16 @@ public String getLDAPBackendDataFile() {
return getGatewayDataDir() + File.separator + "ldap-users.json";
}
+ @Override
+ public boolean isLDAPRecursiveGroupResolutionEnabled() {
+ return Boolean.parseBoolean(get(LDAP_RECURSIVE_GROUP_RESOLUTION, "false"));
+ }
+
+ @Override
+ public int getLDAPGroupMaxDepth() {
+ return Integer.parseInt(get(LDAP_GROUP_MAX_DEPTH, "10"));
+ }
+
@Override
public Set getPropertyNames() {
Set names = new HashSet<>();
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 f0a87dc5b8..8d34137c45 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
@@ -153,6 +153,10 @@ public void start() throws Exception {
public void stop() throws Exception {
LOG.ldapServiceStopping(port);
+ if (backend != null) {
+ backend.close();
+ }
+
if (ldapServer != null) {
try {
ldapServer.stop();
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/KnoxLDAPService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/KnoxLDAPService.java
index 1ec748da9d..d3c65830a6 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/KnoxLDAPService.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/KnoxLDAPService.java
@@ -61,6 +61,8 @@ public void init(GatewayConfig config, Map options) throws Servi
// Add common configuration
backendConfig.put("baseDn", baseDn);
+ backendConfig.put("recursiveGroupResolution", String.valueOf(config.isLDAPRecursiveGroupResolutionEnabled()));
+ backendConfig.put("groupMaxDepth", String.valueOf(config.getLDAPGroupMaxDepth()));
// Add legacy dataFile property for backwards compatibility with file backend
if ("file".equalsIgnoreCase(backendType) && !backendConfig.containsKey("dataFile")) {
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/FileBackend.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/FileBackend.java
index b0cdcd8607..03cf59f53b 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/FileBackend.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/FileBackend.java
@@ -141,4 +141,9 @@ public List searchUsers(String filter, SchemaManager schemaManager) throw
return results;
}
+
+ @Override
+ public void close() {
+ //NOP
+ }
}
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapBackend.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapBackend.java
index 6530c13b37..ae70e3457c 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapBackend.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapBackend.java
@@ -65,4 +65,9 @@ public interface LdapBackend {
* @return List of matching entries
*/
List searchUsers(String filter, SchemaManager schemaManager) throws Exception;
+
+ /**
+ * Closing underlying resources on the backend, if any
+ */
+ void close();
}
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 38328b5713..25599cf1c8 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
@@ -35,11 +35,11 @@
import java.io.IOException;
import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
+import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
+import java.util.Set;
/**
* LDAP backend that proxies to an external LDAP server.
@@ -47,30 +47,30 @@
*/
public class LdapProxyBackend implements LdapBackend {
private static final LdapMessages LOG = MessagesFactory.get(LdapMessages.class);
+ private static final String MEMBER_OF = "memberOf";
- private String ldapUrl;
private String bindDn;
private String bindPassword;
private String userSearchBase;
private String groupSearchBase;
- private String proxyBaseDn; // Base DN for proxy entries (e.g., dc=proxy,dc=com)
- private String remoteBaseDn; // Base DN for remote server searches (e.g., dc=hadoop,dc=apache,dc=org)
private int port;
private String host;
// Configurable attributes for AD/LDAP compatibility
private String userIdentifierAttribute = "uid"; // uid for LDAP, sAMAccountName for AD
+ private String groupIdentifierAttribute = "cn"; // cn is standard for groups
private String userSearchFilter = "({userIdAttr}={username})"; // Will be populated with userIdentifierAttribute
private String groupMemberAttribute = "memberUid"; // member for AD, memberUid for POSIX
private boolean useMemberOf; // Use memberOf attribute for group lookup (efficient for AD)
+ private boolean recursiveGroupResolution;
+ private int groupMaxDepth;
- private List proxyEntryAttributeTypes = List.of(
+ private final List proxyEntryAttributeTypes = List.of(
// "uid" will always be filled
"cn",
"dn",
"mail",
"description");
- private final String proxyEntryGroupMembershipAttributeType = "memberOf";
// Connection pool for efficient connection reuse
private LdapConnectionPool connectionPool;
@@ -83,19 +83,20 @@ public String getName() {
@Override
public void initialize(Map config) throws Exception {
// Proxy base DN is for entries created in the proxy LDAP server
- proxyBaseDn = config.get("baseDn");
+ // Base DN for proxy entries (e.g., dc=proxy,dc=com)
+ String proxyBaseDn = config.get("baseDn");
if (proxyBaseDn == null || proxyBaseDn.isEmpty()) {
throw new IllegalArgumentException("baseDn is required for LDAP proxy backend");
}
- // Remote base DN is for searching the remote LDAP server
- remoteBaseDn = config.get("remoteBaseDn");
+ // Base DN for remote server searches (e.g., dc=hadoop,dc=apache,dc=org)
+ String remoteBaseDn = config.get("remoteBaseDn");
if (remoteBaseDn == null || remoteBaseDn.isEmpty()) {
throw new IllegalArgumentException("remoteBaseDn is required for LDAP proxy backend - this is the base DN of the remote LDAP server");
}
// Support both url and host/port configuration
- ldapUrl = config.get("url");
+ String ldapUrl = config.get("url");
if (ldapUrl != null && !ldapUrl.isEmpty()) {
// Parse URL to extract host and port
parseLdapUrl(ldapUrl);
@@ -129,15 +130,19 @@ public void initialize(Map config) throws Exception {
// Configure attribute mappings for AD/LDAP compatibility
userIdentifierAttribute = config.getOrDefault("userIdentifierAttribute", "uid");
+ groupIdentifierAttribute = config.getOrDefault("groupIdentifierAttribute", "cn");
groupMemberAttribute = config.getOrDefault("groupMemberAttribute", "memberUid");
useMemberOf = Boolean.parseBoolean(config.getOrDefault("useMemberOf", "false"));
+ recursiveGroupResolution = Boolean.parseBoolean(config.getOrDefault("recursiveGroupResolution", "false"));
+ groupMaxDepth = Integer.parseInt(config.getOrDefault("groupMaxDepth", "10"));
// Build search filter template
userSearchFilter = "(" + userIdentifierAttribute + "={username})";
LOG.ldapBackendLoading(getName(), "Proxying " + proxyBaseDn + " to " + ldapUrl + " (" + remoteBaseDn + ") with " +
userIdentifierAttribute + " attribute" +
- (useMemberOf ? " using memberOf lookups" : " using group searches"));
+ (useMemberOf ? (recursiveGroupResolution ? " using recursive memberOf lookups" : " using memberOf lookups") :
+ (recursiveGroupResolution ? " using recursive group searches" : " using group searches")));
// Initialize connection pool
initializeConnectionPool(config);
@@ -243,6 +248,7 @@ private void releaseConnection(LdapConnection connection) {
* Closes the connection pool and releases all resources.
* Should be called when the backend is being shut down.
*/
+ @Override
public void close() {
if (connectionPool != null) {
try {
@@ -258,213 +264,251 @@ public Entry getUser(String username, SchemaManager schemaManager) throws Except
LdapConnection connection = null;
try {
connection = getConnection();
- // Search for user using configurable attribute
- String filter = userSearchFilter.replace("{username}", username);
- EntryCursor cursor = connection.search(userSearchBase, filter, SearchScope.SUBTREE, "*");
+ // 1. Try search in user base
+ final String filter = userSearchFilter.replace("{username}", username);
+ final Entry userBaseEntry = fetchEntry(username, schemaManager, connection, userSearchBase, filter);
- if (cursor.next()) {
- Entry sourceEntry = cursor.get();
- Entry entry = createProxyEntry(sourceEntry, username, connection, schemaManager);
- cursor.close();
- return entry;
+ if (userBaseEntry != null) {
+ return userBaseEntry;
+ } else {
+ // 2. Try search in group base if not found in user base.
+ // This allows the proxy to enrich group entries with recursive 'memberOf' attributes
+ // when a client searches for a group name.
+ String groupFilter = "(" + groupIdentifierAttribute + "=" + username + ")";
+ return fetchEntry(username, schemaManager, connection, groupSearchBase, groupFilter);
}
- cursor.close();
- return null;
} finally {
releaseConnection(connection);
}
}
+ private Entry fetchEntry(String username, SchemaManager schemaManager, LdapConnection connection, String searchBase, String searchFilter) throws Exception {
+ try (EntryCursor cursor = connection.search(searchBase, searchFilter, SearchScope.SUBTREE, "*", "+")) {
+ final Entry sourceEntry = cursor.next() ? cursor.get() : null;
+ return sourceEntry == null ? null : createProxyEntry(sourceEntry, username, connection, schemaManager);
+ }
+ }
+
@Override
public List getUserGroups(String username) throws Exception {
LdapConnection connection = null;
try {
connection = getConnection();
+ Set allGroupDns = new HashSet<>();
if (useMemberOf) {
- // Use memberOf attribute for efficient AD lookups
- return getUserGroupsViaMemberOf(connection, username);
+ allGroupDns.addAll(getUserGroupDnsViaMemberOf(connection, username));
+ if (recursiveGroupResolution && !allGroupDns.isEmpty()) {
+ resolveRecursiveGroupDnsViaMemberOf(connection, allGroupDns, 0);
+ }
} else {
- // Use traditional group search approach
String filter = userSearchFilter.replace("{username}", username);
- EntryCursor cursor = connection.search(userSearchBase, filter, SearchScope.SUBTREE, "dn");
-
- if (cursor.next()) {
- String userDn = cursor.get().getDn().toString();
- cursor.close();
- return getCnsFromEntries(getUserGroupsInternal(connection, userDn, username));
+ try (EntryCursor cursor = connection.search(userSearchBase, filter, SearchScope.SUBTREE, "dn")) {
+ if (cursor.next()) {
+ String userDn = cursor.get().getDn().toString();
+ Set visited = new HashSet<>();
+ visited.add(userDn);
+ List initialGroups = getUserGroupsInternal(connection, userDn, username);
+ for (Entry group : initialGroups) {
+ allGroupDns.add(group.getDn().toString());
+ }
+ if (recursiveGroupResolution && !allGroupDns.isEmpty()) {
+ resolveRecursiveGroupDnsInternal(connection, allGroupDns, visited, 0);
+ }
+ }
}
+ }
- cursor.close();
+ List groupNames = new ArrayList<>();
+ for (String dn : allGroupDns) {
+ String name = extractGroupNameFromDn(dn);
+ if (name != null) {
+ groupNames.add(name);
+ }
}
- return Collections.emptyList();
+ return groupNames;
} finally {
releaseConnection(connection);
}
}
- private List getUserGroupsViaMemberOf(LdapConnection connection, String username) throws LdapException, CursorException, IOException {
- List groups = new ArrayList<>();
+ private List getUserGroupDnsViaMemberOf(LdapConnection connection, String username) throws LdapException, CursorException, IOException {
+ final String filter = userSearchFilter.replace("{username}", username);
+ try (EntryCursor cursor = connection.search(userSearchBase, filter, SearchScope.SUBTREE, MEMBER_OF, "+")) {
+ List dns = new ArrayList<>();
+ if (cursor.next()) {
+ Entry userEntry = cursor.get();
+ Attribute memberOfAttr = userEntry.get(MEMBER_OF);
+ if (memberOfAttr != null) {
+ for (org.apache.directory.api.ldap.model.entry.Value value : memberOfAttr) {
+ dns.add(value.getString());
+ }
+ }
+ }
+ return dns;
+ }
+ }
+
+ private void resolveRecursiveGroupDnsViaMemberOf(LdapConnection connection, Set allGroupDns, int depth) throws LdapException, CursorException, IOException {
+ if (depth >= groupMaxDepth) {
+ return;
+ }
- // Search for user and retrieve memberOf attribute
- String filter = userSearchFilter.replace("{username}", username);
- EntryCursor cursor = connection.search(userSearchBase, filter, SearchScope.SUBTREE, "memberOf");
+ final Set nextLevelDns = fetchNextLevelDNs(connection, allGroupDns);
- if (cursor.next()) {
- Entry userEntry = cursor.get();
- Attribute memberOfAttr = userEntry.get("memberOf");
+ if (!nextLevelDns.isEmpty()) {
+ allGroupDns.addAll(nextLevelDns);
+ resolveRecursiveGroupDnsViaMemberOf(connection, allGroupDns, depth + 1);
+ }
+ }
- if (memberOfAttr != null) {
- // Extract group names from DNs
- for (org.apache.directory.api.ldap.model.entry.Value value : memberOfAttr) {
- String groupDn = value.getString();
- String groupName = extractGroupNameFromDn(groupDn);
- if (groupName != null) {
- groups.add(groupName);
+ private Set fetchNextLevelDNs(LdapConnection connection, Set allGroupDns) throws IOException, LdapException, CursorException {
+ final Set nextLevelDns = new HashSet<>();
+ for (String dn : new HashSet<>(allGroupDns)) {
+ try (EntryCursor cursor = connection.search(dn, "(objectClass=*)", SearchScope.OBJECT, MEMBER_OF, "+")) {
+ if (cursor.next()) {
+ Attribute memberOfAttr = cursor.get().get(MEMBER_OF);
+ if (memberOfAttr != null) {
+ for (org.apache.directory.api.ldap.model.entry.Value value : memberOfAttr) {
+ String parentDn = value.getString();
+ if (!allGroupDns.contains(parentDn)) {
+ nextLevelDns.add(parentDn);
+ }
+ }
}
}
}
}
+ return nextLevelDns;
+ }
+
+ private void resolveRecursiveGroupDnsInternal(LdapConnection connection, Set allGroupDns, Set visited, int depth) throws LdapException, CursorException, IOException {
+ if (depth >= groupMaxDepth) {
+ return;
+ }
- cursor.close();
- return groups;
+ Set nextLevelDns = new HashSet<>();
+ for (String dn : new HashSet<>(allGroupDns)) {
+ if (visited.contains(dn)) {
+ continue;
+ }
+ visited.add(dn);
+
+ List parents = getUserGroupsInternal(connection, dn, null);
+ for (Entry parent : parents) {
+ String parentDn = parent.getDn().toString();
+ if (!allGroupDns.contains(parentDn)) {
+ nextLevelDns.add(parentDn);
+ }
+ }
+ }
+
+ if (!nextLevelDns.isEmpty()) {
+ allGroupDns.addAll(nextLevelDns);
+ resolveRecursiveGroupDnsInternal(connection, allGroupDns, visited, depth + 1);
+ }
}
private String extractGroupNameFromDn(String groupDn) {
- // Extract CN from DN like "CN=Domain Admins,CN=Users,DC=company,DC=com"
if (groupDn.toLowerCase(Locale.ROOT).startsWith("cn=")) {
int commaIdx = groupDn.indexOf(',');
if (commaIdx > 0) {
return groupDn.substring(3, commaIdx);
}
+ return groupDn.substring(3);
}
return null;
}
private List getUserGroupsInternal(LdapConnection connection, String userDn, String username) throws LdapException, CursorException, IOException {
- List groups = new ArrayList<>();
-
- // Search for groups where user is a member - build filter based on configuration
- String filter;
+ final String filter;
if ("member".equals(groupMemberAttribute)) {
- // AD style - uses full DN
- filter = "(|" +
- "(member=" + userDn + ")" +
- "(uniqueMember=" + userDn + ")" +
- ")";
+ filter = "(|(member=" + userDn + ")(uniqueMember=" + userDn + "))";
} else {
- // POSIX style - uses username
- filter = "(|" +
- "(memberUid=" + username + ")" +
- "(member=" + userDn + ")" +
- "(uniqueMember=" + userDn + ")" +
- ")";
+ filter = "(|(memberUid=" + (username != null ? username : "") + ")(member=" + userDn + ")(uniqueMember=" + userDn + "))";
}
- EntryCursor cursor = connection.search(groupSearchBase, filter, SearchScope.SUBTREE, "cn");
-
- while (cursor.next()) {
- groups.add(cursor.get());
- }
-
- cursor.close();
- return groups;
- }
-
- private List getCnsFromEntries(Collection entries) throws LdapException {
- List cns = new ArrayList<>();
- for (Entry entry : entries) {
- Attribute cnAttr = entry.get("cn");
- if (cnAttr != null) {
- cns.add(cnAttr.getString());
+ try (EntryCursor cursor = connection.search(groupSearchBase, filter, SearchScope.SUBTREE, "cn") ) {
+ List groups = new ArrayList<>();
+ while (cursor.next()) {
+ groups.add(cursor.get());
}
+ return groups;
}
- return cns;
}
@Override
public List searchUsers(String filter, SchemaManager schemaManager) throws Exception {
- List results = new ArrayList<>();
LdapConnection connection = null;
-
try {
connection = getConnection();
- String ldapFilter = "(" + userIdentifierAttribute + "=" + filter.trim() + ")";
- EntryCursor cursor = connection.search(userSearchBase, ldapFilter, SearchScope.SUBTREE, "*");
-
- while (cursor.next()) {
- Entry sourceEntry = cursor.get();
- Attribute idAttr = sourceEntry.get(userIdentifierAttribute);
- if (idAttr != null) {
- String username = idAttr.getString();
- Entry entry = createProxyEntry(sourceEntry, username, connection, schemaManager);
- results.add(entry);
+ final String userSearchFilter = "(" + userIdentifierAttribute + "=" + filter.trim() + ")";
+ try (EntryCursor cursor = connection.search(userSearchBase, userSearchFilter, SearchScope.SUBTREE, "*", "+")) {
+ final List users = new ArrayList<>();
+ while (cursor.next()) {
+ Entry sourceEntry = cursor.get();
+ Attribute idAttr = sourceEntry.get(userIdentifierAttribute);
+ if (idAttr != null) {
+ Entry entry = createProxyEntry(sourceEntry, idAttr.getString(), connection, schemaManager);
+ users.add(entry);
+ }
}
+ return users;
}
-
- cursor.close();
- return results;
} finally {
releaseConnection(connection);
}
}
- /**
- * Creates a proxy entry from a backend source entry with all required attributes.
- * This method standardizes the conversion of backend LDAP entries to proxy entries,
- * preserving the backend DN and copying all standard user attributes.
- *
- * @param sourceEntry The entry from the backend LDAP server
- * @param username The username for the entry
- * @param connection The LDAP connection for fetching group information
- * @param schemaManager The schema manager for creating entries
- * @return A new Entry with backend DN and all copied attributes
- * @throws Exception if entry creation or attribute copying fails
- */
private Entry createProxyEntry(Entry sourceEntry, String username, LdapConnection connection, SchemaManager schemaManager) throws Exception {
- // 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());
-
- // Copy all attributes as-is from backend
copyAttribute(sourceEntry, entry, "objectClass");
copyAttribute(sourceEntry, entry, userIdentifierAttribute);
-
- // Map identifier attribute to uid for consistency if needed
if (!"uid".equals(userIdentifierAttribute)) {
Attribute idAttr = sourceEntry.get(userIdentifierAttribute);
if (idAttr != null) {
entry.add("uid", idAttr.getString());
}
}
-
for (String attributeType : proxyEntryAttributeTypes) {
copyAttribute(sourceEntry, entry, attributeType);
}
+ Set allGroupDns = new HashSet<>();
if (useMemberOf) {
- copyAttribute(sourceEntry, entry, proxyEntryGroupMembershipAttributeType);
+ Attribute memberOfAttr = sourceEntry.get(MEMBER_OF);
+ if (memberOfAttr != null) {
+ for (org.apache.directory.api.ldap.model.entry.Value value : memberOfAttr) {
+ allGroupDns.add(value.getString());
+ }
+ }
+ if (recursiveGroupResolution && !allGroupDns.isEmpty()) {
+ resolveRecursiveGroupDnsViaMemberOf(connection, allGroupDns, 0);
+ }
} else {
- List groups = getUserGroupsInternal(connection, sourceEntry.getDn().toString(), username);
- for (Entry groupEntry : groups) {
- entry.add(proxyEntryGroupMembershipAttributeType, groupEntry.getDn().getName());
+ List initialGroups = getUserGroupsInternal(connection, sourceEntry.getDn().toString(), username);
+ for (Entry group : initialGroups) {
+ allGroupDns.add(group.getDn().toString());
+ }
+ if (recursiveGroupResolution && !allGroupDns.isEmpty()) {
+ Set visited = new HashSet<>();
+ visited.add(sourceEntry.getDn().toString());
+ resolveRecursiveGroupDnsInternal(connection, allGroupDns, visited, 0);
}
}
+ for (String dn : allGroupDns) {
+ entry.add(MEMBER_OF, dn);
+ }
+
return entry;
}
private void copyAttribute(Entry source, Entry target, String attributeName) throws LdapException {
Attribute attr = source.get(attributeName);
if (attr != null) {
- // Copy all values of the attribute (important for multi-valued attributes like objectClass)
for (org.apache.directory.api.ldap.model.entry.Value value : attr) {
- try {
- target.add(attributeName, value.getString());
- } catch (LdapException e) {
- LOG.ldapAttributeCopyError(e);
- throw e;
- }
+ target.add(attributeName, value.getString());
}
}
}
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 dbdb081dac..942a51faac 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
@@ -126,6 +126,8 @@ public void testInitWithInvalidBackendType() throws Exception {
expect(mockConfig.getLDAPPort()).andReturn(3890);
expect(mockConfig.getLDAPBaseDN()).andReturn("dc=test,dc=com");
expect(mockConfig.getLDAPBackendType()).andReturn("invalid");
+ expect(mockConfig.isLDAPRecursiveGroupResolutionEnabled()).andReturn(false).anyTimes();
+ expect(mockConfig.getLDAPGroupMaxDepth()).andReturn(10).anyTimes();
expect(mockConfig.getLDAPBackendConfig("invalid")).andReturn(new HashMap<>());
replay(mockConfig);
@@ -171,6 +173,8 @@ private void setupMockConfigForFileBackend() {
expect(mockConfig.getLDAPPort()).andReturn(3890);
expect(mockConfig.getLDAPBaseDN()).andReturn("dc=test,dc=com");
expect(mockConfig.getLDAPBackendType()).andReturn("file");
+ expect(mockConfig.isLDAPRecursiveGroupResolutionEnabled()).andReturn(false).anyTimes();
+ expect(mockConfig.getLDAPGroupMaxDepth()).andReturn(10).anyTimes();
Map fileBackendConfig = new HashMap<>();
fileBackendConfig.put("dataFile", tempLdapFile.getAbsolutePath());
@@ -183,6 +187,8 @@ private void setupMockConfigForLdapBackend() {
expect(mockConfig.getLDAPPort()).andReturn(3890);
expect(mockConfig.getLDAPBaseDN()).andReturn("dc=proxy,dc=com");
expect(mockConfig.getLDAPBackendType()).andReturn("ldap");
+ expect(mockConfig.isLDAPRecursiveGroupResolutionEnabled()).andReturn(false).anyTimes();
+ expect(mockConfig.getLDAPGroupMaxDepth()).andReturn(10).anyTimes();
Map ldapBackendConfig = new HashMap<>();
ldapBackendConfig.put("url", "ldap://localhost:33389");
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 a6b132b356..86faf21a61 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
@@ -155,10 +155,12 @@ public void testGetUserByDefaultUserSearchFilter() throws Exception {
assertEquals("ldaptest1@example.com", entry.get("mail").getString());
assertEquals("Test user ldaptest1", entry.get("description").getString());
assertNull(entry.get("sAMAccountName"));
- assertEquals(2, entry.get("memberOf").size());
+ assertEquals(4, entry.get("memberOf").size());
Set expectedMemberOf = Set.of(
"cn=group1,ou=groups,dc=hadoop,dc=apache,dc=org",
- "cn=group2,ou=groups,dc=hadoop,dc=apache,dc=org");
+ "cn=group2,ou=groups,dc=hadoop,dc=apache,dc=org",
+ "cn=recursive-leaf,ou=groups,dc=hadoop,dc=apache,dc=org",
+ "cn=circular-a,ou=groups,dc=hadoop,dc=apache,dc=org");
Set foundMemberOf = new HashSet<>(2);
Iterator memberOfs = entry.get("memberOf").iterator();
while (memberOfs.hasNext()) {
@@ -187,10 +189,12 @@ public void testGetUserByUID() throws Exception {
assertEquals("ldaptest1@example.com", entry.get("mail").getString());
assertEquals("Test user ldaptest1", entry.get("description").getString());
assertNull(entry.get("sAMAccountName"));
- assertEquals(2, entry.get("memberOf").size());
+ assertEquals(4, entry.get("memberOf").size());
Set expectedMemberOf = Set.of(
"cn=group1,ou=groups,dc=hadoop,dc=apache,dc=org",
- "cn=group2,ou=groups,dc=hadoop,dc=apache,dc=org");
+ "cn=group2,ou=groups,dc=hadoop,dc=apache,dc=org",
+ "cn=recursive-leaf,ou=groups,dc=hadoop,dc=apache,dc=org",
+ "cn=circular-a,ou=groups,dc=hadoop,dc=apache,dc=org");
Set foundMemberOf = new HashSet<>(2);
Iterator memberOfs = entry.get("memberOf").iterator();
while (memberOfs.hasNext()) {
@@ -211,10 +215,12 @@ public void testGetUserByCN() throws Exception {
assertEquals("ldaptest1@example.com", entry.get("mail").getString());
assertEquals("Test user ldaptest1", entry.get("description").getString());
assertNull(entry.get("sAMAccountName"));
- assertEquals(2, entry.get("memberOf").size());
+ assertEquals(4, entry.get("memberOf").size());
Set expectedMemberOf = Set.of(
"cn=group1,ou=groups,dc=hadoop,dc=apache,dc=org",
- "cn=group2,ou=groups,dc=hadoop,dc=apache,dc=org");
+ "cn=group2,ou=groups,dc=hadoop,dc=apache,dc=org",
+ "cn=recursive-leaf,ou=groups,dc=hadoop,dc=apache,dc=org",
+ "cn=circular-a,ou=groups,dc=hadoop,dc=apache,dc=org");
Set foundMemberOf = new HashSet<>(2);
Iterator memberOfs = entry.get("memberOf").iterator();
while (memberOfs.hasNext()) {
@@ -235,10 +241,12 @@ public void testGetUserBySAMAccountName() throws Exception {
assertEquals("ldaptest1@example.com", entry.get("mail").getString());
assertEquals("Test user ldaptest1", entry.get("description").getString());
assertEquals("TestSam1", entry.get("sAMAccountName").getString());
- assertEquals(2, entry.get("memberOf").size());
+ assertEquals(4, entry.get("memberOf").size());
Set expectedMemberOf = Set.of(
"cn=group1,ou=groups,dc=hadoop,dc=apache,dc=org",
- "cn=group2,ou=groups,dc=hadoop,dc=apache,dc=org");
+ "cn=group2,ou=groups,dc=hadoop,dc=apache,dc=org",
+ "cn=recursive-leaf,ou=groups,dc=hadoop,dc=apache,dc=org",
+ "cn=circular-a,ou=groups,dc=hadoop,dc=apache,dc=org");
Set foundMemberOf = new HashSet<>(2);
Iterator memberOfs = entry.get("memberOf").iterator();
while (memberOfs.hasNext()) {
@@ -452,4 +460,57 @@ public void testSearchUsersNoneFoundBySAMAccountName() throws Exception {
List entries = ldapProxyBackend.searchUsers("nobody*", schemaManager);
assertTrue(entries.isEmpty());
}
-}
\ No newline at end of file
+
+ @Test
+ public void testGetUserGroupsRecursive() throws Exception {
+ Map config = new HashMap<>(ldapBackendConfig);
+ config.put("recursiveGroupResolution", "true");
+ ldapProxyBackend.initialize(config);
+
+ List userGroups = ldapProxyBackend.getUserGroups("ldaptest1");
+ assertTrue(userGroups.contains("group1"));
+ assertTrue(userGroups.contains("group2"));
+ assertTrue(userGroups.contains("recursive-leaf"));
+ assertTrue(userGroups.contains("recursive-mid"));
+ assertTrue(userGroups.contains("recursive-top"));
+ }
+
+ @Test
+ public void testGetUserGroupsRecursiveCircular() throws Exception {
+ Map config = new HashMap<>(ldapBackendConfig);
+ config.put("recursiveGroupResolution", "true");
+ ldapProxyBackend.initialize(config);
+
+ List userGroups = ldapProxyBackend.getUserGroups("ldaptest1");
+ assertTrue(userGroups.contains("circular-a"));
+ assertTrue(userGroups.contains("circular-b"));
+ }
+
+ @Test
+ public void testGetUserGroupsRecursiveMaxDepth() throws Exception {
+ Map config = new HashMap<>(ldapBackendConfig);
+ config.put("recursiveGroupResolution", "true");
+ config.put("groupMaxDepth", "1"); // Only leaf level
+ ldapProxyBackend.initialize(config);
+
+ List userGroups = ldapProxyBackend.getUserGroups("ldaptest1");
+ assertTrue(userGroups.contains("recursive-leaf"));
+ assertTrue(userGroups.contains("recursive-mid"));
+ // recursive-top is depth 2 from leaf (which is depth 0 relative to user groups search)
+ assertTrue(!userGroups.contains("recursive-top"));
+ }
+
+ @Test
+ public void testGetUserGroupsRecursiveUseMemberOf() throws Exception {
+ Map config = new HashMap<>(ldapBackendConfig);
+ config.put("useMemberOf", "true");
+ config.put("recursiveGroupResolution", "true");
+ ldapProxyBackend.initialize(config);
+
+ List userGroups = ldapProxyBackend.getUserGroups("ldaptest2");
+ assertTrue(userGroups.contains("groupMemberOf1"));
+ assertTrue(userGroups.contains("groupMemberOf2"));
+ assertTrue(userGroups.contains("recursive-mid-mo"));
+ assertTrue(userGroups.contains("recursive-top-mo"));
+ }
+ }
\ No newline at end of file
diff --git a/gateway-server/src/test/resources/ldap-proxy-backend-test.ldif b/gateway-server/src/test/resources/ldap-proxy-backend-test.ldif
index 1863427806..c37033a192 100644
--- a/gateway-server/src/test/resources/ldap-proxy-backend-test.ldif
+++ b/gateway-server/src/test/resources/ldap-proxy-backend-test.ldif
@@ -35,17 +35,6 @@ objectClass: top
objectClass: organizationalUnit
ou: groups
-# entry for the end user
-dn: uid=guest,ou=people,dc=hadoop,dc=apache,dc=org
-objectclass:top
-objectclass:person
-objectclass:organizationalPerson
-objectclass:inetOrgPerson
-cn: Guest
-sn: User
-uid: guest
-userPassword:guest-password
-
dn: cn=group1,ou=groups,dc=hadoop,dc=apache,dc=org
objectclass:top
objectclass:groupOfNames
@@ -58,6 +47,82 @@ objectclass:groupOfNames
cn: group2
member: uid=ldaptest1,ou=people,dc=hadoop,dc=apache,dc=org
+dn: cn=recursive-leaf,ou=groups,dc=hadoop,dc=apache,dc=org
+objectclass:top
+objectclass:groupOfNames
+cn: recursive-leaf
+member: uid=ldaptest1,ou=people,dc=hadoop,dc=apache,dc=org
+
+dn: cn=recursive-mid,ou=groups,dc=hadoop,dc=apache,dc=org
+objectclass:top
+objectclass:groupOfNames
+cn: recursive-mid
+member: cn=recursive-leaf,ou=groups,dc=hadoop,dc=apache,dc=org
+
+dn: cn=recursive-top,ou=groups,dc=hadoop,dc=apache,dc=org
+objectclass:top
+objectclass:groupOfNames
+cn: recursive-top
+member: cn=recursive-mid,ou=groups,dc=hadoop,dc=apache,dc=org
+
+dn: cn=circular-a,ou=groups,dc=hadoop,dc=apache,dc=org
+objectclass:top
+objectclass:groupOfNames
+cn: circular-a
+member: uid=ldaptest1,ou=people,dc=hadoop,dc=apache,dc=org
+member: cn=circular-b,ou=groups,dc=hadoop,dc=apache,dc=org
+
+dn: cn=circular-b,ou=groups,dc=hadoop,dc=apache,dc=org
+objectclass:top
+objectclass:groupOfNames
+cn: circular-b
+member: cn=circular-a,ou=groups,dc=hadoop,dc=apache,dc=org
+
+dn: cn=groupMemberOf1,ou=groups,dc=hadoop,dc=apache,dc=org
+objectclass:top
+objectclass:person
+objectclass:organizationalPerson
+objectclass:inetOrgPerson
+cn: groupMemberOf1
+sn: groupMemberOf1
+memberOf: cn=recursive-mid-mo,ou=groups,dc=hadoop,dc=apache,dc=org
+
+dn: cn=groupMemberOf2,ou=groups,dc=hadoop,dc=apache,dc=org
+objectclass:top
+objectclass:person
+objectclass:organizationalPerson
+objectclass:inetOrgPerson
+cn: groupMemberOf2
+sn: groupMemberOf2
+
+dn: cn=recursive-mid-mo,ou=groups,dc=hadoop,dc=apache,dc=org
+objectclass:top
+objectclass:person
+objectclass:organizationalPerson
+objectclass:inetOrgPerson
+cn: recursive-mid-mo
+sn: recursive-mid-mo
+memberOf: cn=recursive-top-mo,ou=groups,dc=hadoop,dc=apache,dc=org
+
+dn: cn=recursive-top-mo,ou=groups,dc=hadoop,dc=apache,dc=org
+objectclass:top
+objectclass:person
+objectclass:organizationalPerson
+objectclass:inetOrgPerson
+cn: recursive-top-mo
+sn: recursive-top-mo
+
+# entry for the end users
+dn: uid=guest,ou=people,dc=hadoop,dc=apache,dc=org
+objectclass:top
+objectclass:person
+objectclass:organizationalPerson
+objectclass:inetOrgPerson
+cn: Guest
+sn: User
+uid: guest
+userPassword:guest-password
+
dn: uid=ldaptest1,ou=people,dc=hadoop,dc=apache,dc=org
objectclass:top
objectclass:person
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 8a55d37368..5d1f9f200b 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
@@ -1244,6 +1244,16 @@ public String getLDAPBackendDataFile() {
return getGatewayDataPath().resolve("ldap-users.json").toString();
}
+ @Override
+ public boolean isLDAPRecursiveGroupResolutionEnabled() {
+ return false;
+ }
+
+ @Override
+ public int getLDAPGroupMaxDepth() {
+ return 10;
+ }
+
@Override
public Set getPropertyNames() {
return Collections.emptySet();
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 a3c62e011b..863ade0aed 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java
@@ -130,6 +130,8 @@ public interface GatewayConfig {
String LDAP_BASE_DN = "gateway.ldap.base.dn";
String LDAP_BACKEND_TYPE = "gateway.ldap.backend.type";
String LDAP_BACKEND_DATA_FILE = "gateway.ldap.backend.data.file";
+ String LDAP_RECURSIVE_GROUP_RESOLUTION = "gateway.ldap.recursive.group.resolution";
+ String LDAP_GROUP_MAX_DEPTH = "gateway.ldap.group.max.depth";
/**
* The location of the gateway configuration.
@@ -1075,6 +1077,16 @@ public interface GatewayConfig {
*/
String getLDAPBackendDataFile();
+ /**
+ * @return true if recursive group resolution is enabled for LDAP; otherwise false
+ */
+ boolean isLDAPRecursiveGroupResolutionEnabled();
+
+ /**
+ * @return the maximum depth for recursive group resolution
+ */
+ int getLDAPGroupMaxDepth();
+
/**
* Get backend-specific configuration properties.
* Returns all properties with prefix "gateway.ldap.backend.{backendType}."