This is an automated email from the ASF dual-hosted git repository. smolnar82 pushed a commit to branch knox_idf in repository https://gitbox.apache.org/repos/asf/knox.git
commit 27c279a360d180d98a9ee2e7fb8e5e35b7ddb00b Author: David Han <[email protected]> AuthorDate: Mon Jun 8 05:30:24 2026 -0500 KNOX-3330: Refactor LDAP Proxy configuration to support multiple backends (#1240) --- .github/workflows/build/gateway-site.xml | 30 ++-- .../gateway/config/impl/GatewayConfigImpl.java | 8 +- .../services/ldap/KnoxLDAPServerManager.java | 185 +++++++++++++------ .../knox/gateway/services/ldap/LdapMessages.java | 26 ++- .../services/ldap/backend/BackendFactory.java | 23 ++- .../gateway/services/ldap/backend/FileBackend.java | 27 ++- .../services/ldap/backend/FileBackendFactory.java | 32 ++++ .../gateway/services/ldap/backend/LdapBackend.java | 14 +- .../services/ldap/backend/LdapBackendFactory.java | 25 +++ .../services/ldap/backend/LdapProxyBackend.java | 32 ++-- .../ldap/backend/LdapProxyBackendFactory.java | 32 ++++ .../DuplicateUserFilteringInterceptor.java | 87 +++++++++ .../DuplicateUserFilteringInterceptorFactory.java | 36 ++++ .../ldap/interceptor/InterceptorFactory.java | 59 ++++++ .../interceptor/KnoxLdapInterceptorFactory.java | 41 +++++ .../UserSearchInterceptor.java} | 79 ++++---- .../interceptor/UserSearchInterceptorFactory.java | 36 ++++ .../java/org/apache/knox/gateway/util/KnoxCLI.java | 1 + ...teway.services.ldap.backend.LdapBackendFactory} | 6 +- ...es.ldap.interceptor.KnoxLdapInterceptorFactory} | 6 +- .../src/main/resources/conf/gateway-site.xml | 113 ++++++++---- .../services/ldap/KnoxLDAPServerManagerTest.java | 198 ++++++++++++++++++--- .../gateway/services/ldap/KnoxLDAPServiceTest.java | 7 +- .../services/ldap/backend/BackendFactoryTest.java | 47 +++-- .../ldap/backend/LdapProxyBackendTest.java | 63 +++---- .../DuplicateUserFilteringInterceptorTest.java | 166 +++++++++++++++++ .../org/apache/knox/gateway/GatewayTestConfig.java | 6 +- .../apache/knox/gateway/config/GatewayConfig.java | 18 +- knox-site/docs/service_ldap_server.md | 125 +++++++++---- 29 files changed, 1223 insertions(+), 305 deletions(-) diff --git a/.github/workflows/build/gateway-site.xml b/.github/workflows/build/gateway-site.xml index d8c1bbbd1..8f35a939f 100644 --- a/.github/workflows/build/gateway-site.xml +++ b/.github/workflows/build/gateway-site.xml @@ -145,42 +145,50 @@ limitations under the License. <name>gateway.ldap.base.dn</name> <value>dc=hadoop,dc=apache,dc=org</value> </property> - <property> - <name>gateway.ldap.backend.type</name> - <value>ldap</value> - </property> <property> <name>gateway.ldap.recursive.group.resolution</name> <value>true</value> </property> + <property> + <name>gateway.ldap.interceptor.names</name> + <value>demoldap</value> + </property> <!-- LDAP Backend specific configuration (proxying to demo ldap) --> <property> - <name>gateway.ldap.backend.ldap.url</name> + <name>gateway.ldap.interceptor.demoldap.interceptorType</name> + <value>backend</value> + </property> + <property> + <name>gateway.ldap.interceptor.demoldap.backendType</name> + <value>ldap</value> + </property> + <property> + <name>gateway.ldap.interceptor.demoldap.url</name> <value>ldap://ldap:33389</value> </property> <property> - <name>gateway.ldap.backend.ldap.remoteBaseDn</name> + <name>gateway.ldap.interceptor.demoldap.remoteBaseDn</name> <value>dc=hadoop,dc=apache,dc=org</value> </property> <property> - <name>gateway.ldap.backend.ldap.systemUsername</name> + <name>gateway.ldap.interceptor.demoldap.systemUsername</name> <value>uid=guest,ou=people,dc=hadoop,dc=apache,dc=org</value> </property> <property> - <name>gateway.ldap.backend.ldap.systemPassword</name> + <name>gateway.ldap.interceptor.demoldap.systemPassword</name> <value>guest-password</value> </property> <property> - <name>gateway.ldap.backend.ldap.userSearchBase</name> + <name>gateway.ldap.interceptor.demoldap.userSearchBase</name> <value>ou=people,dc=hadoop,dc=apache,dc=org</value> </property> <property> - <name>gateway.ldap.backend.ldap.groupSearchBase</name> + <name>gateway.ldap.interceptor.demoldap.groupSearchBase</name> <value>ou=groups,dc=hadoop,dc=apache,dc=org</value> </property> <property> - <name>gateway.ldap.backend.ldap.groupMemberAttribute</name> + <name>gateway.ldap.interceptor.demoldap.groupMemberAttribute</name> <value>member</value> </property> 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 eb3f9f227..995fa81be 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 @@ -1754,8 +1754,8 @@ public class GatewayConfigImpl extends Configuration implements GatewayConfig { } @Override - public String getLDAPBackendType() { - return get(LDAP_BACKEND_TYPE, "file"); + public List<String> getLDAPInterceptorNames() { + return splitConfigValueToList(LDAP_INTERCEPTOR_NAMES); } @Override @@ -1782,9 +1782,9 @@ public class GatewayConfigImpl extends Configuration implements GatewayConfig { } @Override - public Map<String, String> getLDAPBackendConfig(String backendType) { + public Map<String, String> getLDAPInterceptorConfig(String interceptorName) { Map<String, String> config = new HashMap<>(); - String prefix = "gateway.ldap.backend." + backendType + "."; + String prefix = "gateway.ldap.interceptor." + interceptorName + "."; for (String key : getPropertyNames()) { if (key != null && key.startsWith(prefix)) { 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 eebf9251e..ff4edb20e 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 @@ -17,12 +17,22 @@ */ package org.apache.knox.gateway.services.ldap; +import com.google.common.annotations.VisibleForTesting; +import org.apache.commons.lang3.StringUtils; +import org.apache.directory.api.ldap.model.cursor.Cursor; +import org.apache.directory.api.ldap.model.entry.Attribute; import org.apache.directory.api.ldap.model.entry.DefaultEntry; import org.apache.directory.api.ldap.model.entry.Entry; +import org.apache.directory.api.ldap.model.entry.Value; +import org.apache.directory.api.ldap.model.exception.LdapException; +import org.apache.directory.api.ldap.model.message.SearchRequest; +import org.apache.directory.api.ldap.model.message.SearchRequestImpl; +import org.apache.directory.api.ldap.model.message.SearchScope; import org.apache.directory.api.ldap.model.name.Dn; import org.apache.directory.api.ldap.model.schema.SchemaManager; import org.apache.directory.server.core.DefaultDirectoryService; import org.apache.directory.server.core.api.DirectoryService; +import org.apache.directory.server.core.api.DnFactory; import org.apache.directory.server.core.api.InstanceLayout; import org.apache.directory.server.core.api.interceptor.Interceptor; import org.apache.directory.server.core.api.schema.SchemaPartition; @@ -32,13 +42,16 @@ import org.apache.directory.server.ldap.LdapServer; import org.apache.directory.server.protocol.shared.transport.TcpTransport; import org.apache.knox.gateway.config.GatewayConfig; import org.apache.knox.gateway.i18n.messages.MessagesFactory; -import org.apache.knox.gateway.services.ldap.backend.BackendFactory; -import org.apache.knox.gateway.services.ldap.backend.LdapBackend; +import org.apache.knox.gateway.services.ldap.interceptor.InterceptorFactory; import java.io.File; import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Set; /** * Manages the ApacheDS LDAP server instance with pluggable backends @@ -46,13 +59,16 @@ import java.util.Map; public class KnoxLDAPServerManager { private static final LdapMessages LOG = MessagesFactory.get(LdapMessages.class); - private DirectoryService directoryService; + @VisibleForTesting + DirectoryService directoryService; private LdapServer ldapServer; - private LdapBackend backend; + private GatewayConfig gatewayConfig; + private List<Interceptor> interceptors; private File workDir; private int port; private String baseDn; - private String remoteBaseDn; + // Collection of DNs for the proxied backend LDAP servers + private Set<String> baseDns; /** * Initialize the LDAP server with the given configuration @@ -60,6 +76,8 @@ public class KnoxLDAPServerManager { * @param config Gateway configuration */ public void initialize(GatewayConfig config) throws Exception { + this.gatewayConfig = config; + // Prepare work directory for LDAP data File gatewayDataDir = new File(config.getGatewayDataDir()); this.workDir = new File(gatewayDataDir, "ldap-server"); @@ -67,27 +85,8 @@ public class KnoxLDAPServerManager { // Get configuration this.port = config.getLDAPPort(); this.baseDn = config.getLDAPBaseDN(); - String backendType = config.getLDAPBackendType(); - - // Get backend-specific configuration using prefixed properties - Map<String, String> backendConfig = config.getLDAPBackendConfig(backendType); - - // Add common configuration - backendConfig.put("baseDn", baseDn); - - // Add legacy dataFile property for backwards compatibility with file backend - if ("file".equalsIgnoreCase(backendType) && !backendConfig.containsKey("dataFile")) { - backendConfig.put("dataFile", config.getLDAPBackendDataFile()); - } - - backendConfig.put("recursiveGroupResolution", String.valueOf(config.isLDAPRecursiveGroupResolutionEnabled())); - backendConfig.put("recursiveGroupResolutionMaxDepth", String.valueOf(config.getLDAPRecursiveGroupResolutionMaxDepth())); - // For proxy backends, extract remoteBaseDn if present - this.remoteBaseDn = backendConfig.get("remoteBaseDn"); - - // Initialize backend - backend = BackendFactory.createBackend(backendType, backendConfig); + this.interceptors = createInterceptors(config); // Clean up previous run if it didn't shut down cleanly File lockFile = new File(workDir, "run/instance.lock"); @@ -99,6 +98,34 @@ public class KnoxLDAPServerManager { workDir.mkdirs(); } + private List<Interceptor> createInterceptors(GatewayConfig config) throws Exception { + List<String> interceptorNames = config.getLDAPInterceptorNames(); + List<Interceptor> interceptors = new ArrayList<>(interceptorNames.size()); + for (String interceptorName : interceptorNames) { + // Get backend-specific configuration using prefixed properties + Map<String, String> interceptorConfig = config.getLDAPInterceptorConfig(interceptorName); + + // Add common configuration + interceptorConfig.put("baseDn", baseDn); + + // Add common LDAP Proxy configurations to backends + String interceptorType = interceptorConfig.get("interceptorType"); + String backendType = interceptorConfig.get("backendType"); + if ("backend".equalsIgnoreCase(interceptorType)) { + interceptorConfig.put("recursiveGroupResolution", String.valueOf(config.isLDAPRecursiveGroupResolutionEnabled())); + interceptorConfig.put("recursiveGroupResolutionMaxDepth", String.valueOf(config.getLDAPRecursiveGroupResolutionMaxDepth())); + if ("file".equalsIgnoreCase(backendType) && + !interceptorConfig.containsKey("dataFile")) { + // Add legacy dataFile property for backwards compatibility with file backend + interceptorConfig.put("dataFile", config.getLDAPBackendDataFile()); + } + } + + interceptors.add(InterceptorFactory.createInterceptor(interceptorName, interceptorConfig)); + } + return interceptors; + } + /** * Start the LDAP server */ @@ -134,17 +161,11 @@ public class KnoxLDAPServerManager { partition.setPartitionPath(new File(workDir, "proxy").toURI()); directoryService.addPartition(partition); - // Create partition for remote base DN if different from proxy base DN - // This allows backend entries with remote DNs to be returned in search results - if (remoteBaseDn != null && !remoteBaseDn.equals(baseDn)) { - JdbmPartition remotePartition = new JdbmPartition(schemaManager, directoryService.getDnFactory()); - remotePartition.setId("remote"); - remotePartition.setSuffixDn(new Dn(schemaManager, remoteBaseDn)); - remotePartition.setPartitionPath(new File(workDir, "remote").toURI()); - directoryService.addPartition(remotePartition); - } + baseDns = new HashSet<>(); + baseDns.add(baseDn); + addRemotePartitions(); - addGroupLookupInterceptor(); + addInterceptors(); // Allow anonymous access directoryService.setAllowAnonymousAccess(true); @@ -152,8 +173,8 @@ public class KnoxLDAPServerManager { // Start the service directoryService.startup(); - // Add base entries to the partition - createBaseEntries(schemaManager); + // Add base entries to the partitions + createBaseEntries(baseDns, schemaManager); // Create LDAP server on configured port ldapServer = new LdapServer(); @@ -165,25 +186,51 @@ public class KnoxLDAPServerManager { LOG.ldapServiceStarted(port); } - private void addGroupLookupInterceptor() { - // Add our interceptor for group lookups and bind proxying - // We need to insert it before AuthenticationInterceptor to intercept bind requests - final List<Interceptor> interceptors = new ArrayList<>(directoryService.getInterceptors()); + private void addRemotePartitions() throws LdapException { + SchemaManager schemaManager = directoryService.getSchemaManager(); + DnFactory dnFactory = directoryService.getDnFactory(); + List<String> interceptorNames = gatewayConfig.getLDAPInterceptorNames(); + for (String interceptorName : interceptorNames) { + // Get backend-specific configuration using prefixed properties + Map<String, String> interceptorConfig = gatewayConfig.getLDAPInterceptorConfig(interceptorName); + + String remoteBaseDn = interceptorConfig.get("remoteBaseDn"); + if (StringUtils.isNotBlank(remoteBaseDn)) { + if (!baseDns.contains(remoteBaseDn)) { + //create partition + String id = interceptorName.replaceAll("\\s+", ""); + JdbmPartition remotePartition = new JdbmPartition(schemaManager, dnFactory); + remotePartition.setId(id); + remotePartition.setSuffixDn(new Dn(schemaManager, remoteBaseDn)); + remotePartition.setPartitionPath(new File(workDir, id).toURI()); + directoryService.addPartition(remotePartition); + baseDns.add(remoteBaseDn); + } + } + } + } + + private void addInterceptors() throws LdapException { + // Find location of AuthenticationInterceptor. + // We need to insert interceptors before AuthenticationInterceptor to intercept bind requests + final List<Interceptor> dsInterceptors = new ArrayList<>(directoryService.getInterceptors()); int authIdx = -1; - for (int i = 0; i < interceptors.size(); i++) { - if (interceptors.get(i).getName().equalsIgnoreCase("authenticationInterceptor")) { + for (int i = 0; i < dsInterceptors.size(); i++) { + if (dsInterceptors.get(i).getName().equalsIgnoreCase("authenticationInterceptor")) { authIdx = i; break; } } - final GroupLookupInterceptor interceptor = new GroupLookupInterceptor(directoryService, backend); - if (authIdx != -1) { - interceptors.add(authIdx, interceptor); - } else { - interceptors.add(interceptor); + // Add our configured interceptors for group lookups and bind proxying + for (Interceptor interceptor : interceptors) { + if (authIdx != -1) { + dsInterceptors.add(authIdx, interceptor); + } else { + dsInterceptors.add(interceptor); + } } - directoryService.setInterceptors(interceptors); + directoryService.setInterceptors(dsInterceptors); } /** @@ -195,6 +242,7 @@ public class KnoxLDAPServerManager { if (ldapServer != null) { try { ldapServer.stop(); + ldapServer = null; } catch (Exception e) { LOG.ldapServiceStopFailed(e); } @@ -203,6 +251,7 @@ public class KnoxLDAPServerManager { if (directoryService != null) { try { directoryService.shutdown(); + directoryService = null; } catch (Exception e) { LOG.ldapServiceStopFailed(e); } @@ -211,13 +260,10 @@ public class KnoxLDAPServerManager { LOG.ldapServiceStopped(); } - private void createBaseEntries(SchemaManager schemaManager) throws Exception { - // Create base entries for proxy base DN - createBaseEntriesForDn(schemaManager, baseDn); - - // Create base entries for remote base DN if different - if (remoteBaseDn != null && !remoteBaseDn.equals(baseDn)) { - createBaseEntriesForDn(schemaManager, remoteBaseDn); + private void createBaseEntries(Collection<String> baseDns, SchemaManager schemaManager) throws Exception { + // Create base entries for proxy base DN and remote base DNs + for (String baseDn : baseDns) { + createBaseEntriesForDn(schemaManager, baseDn); } } @@ -266,7 +312,32 @@ public class KnoxLDAPServerManager { * @return List of group names */ public List<String> getUserGroups(String username) throws Exception { - return backend.getUserGroups(username); + SearchRequest searchRequest = new SearchRequestImpl(); + searchRequest.setBase(new Dn(directoryService.getSchemaManager(), baseDn)); + searchRequest.setScope(SearchScope.SUBTREE); + searchRequest.setFilter("(uid=" + username + ")"); + searchRequest.addAttributes("*"); + + List<String> groups = new ArrayList<>(); + try (Cursor<Entry> cursor = directoryService.getAdminSession().search(searchRequest)) { + if (cursor.next()) { + Entry entry = cursor.get(); + Attribute memberOf = entry.get("memberOf"); + if (memberOf != null) { + for (Value value : memberOf) { + String groupDn = value.getString(); + if (groupDn.toLowerCase(Locale.ROOT).startsWith("cn=")) { + int commaIdx = groupDn.indexOf(','); + if (commaIdx > 0) { + groups.add(groupDn.substring(3, commaIdx)); + } + } + + } + } + } + } + return groups; } /** diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/LdapMessages.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/LdapMessages.java index 221e36e4d..947581247 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/LdapMessages.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/LdapMessages.java @@ -49,13 +49,29 @@ public interface LdapMessages { text = "Failed to stop LDAP service: {0}") void ldapServiceStopFailed(@StackTrace(level = MessageLevel.DEBUG) Exception e); + @Message(level = MessageLevel.ERROR, + text = "Interceptor type ''{0}'' for interceptor ''{1}'' not found") + void ldapInterceptorNotFound(String interceptorType, String interceptorName); + + @Message(level = MessageLevel.ERROR, + text = "Interceptor type not found for interceptor ''{0}''") + void ldapInterceptorTypeNotFound(String interceptorName); + + @Message(level = MessageLevel.INFO, + text = "Creating interceptor: {0} (via {1})") + void ldapInterceptorCreating(String interceptorName, String source); + @Message(level = MessageLevel.INFO, text = "Loading backend: {0} (via {1})") void ldapBackendLoading(String backendName, String source); - @Message(level = MessageLevel.WARN, - text = "Backend ''{0}'' not found, using FileBackend") - void ldapBackendNotFound(String backendName); + @Message(level = MessageLevel.ERROR, + text = "Backend type ''{0}'' for backend ''{1}'' not found") + void ldapBackendNotFound(String backendType, String backendName); + + @Message(level = MessageLevel.ERROR, + text = "Backend type not found for backend ''{0}''") + void ldapBackendTypeNotFound(String backendName); @Message(level = MessageLevel.WARN, text = "Data file not found: {0}, creating sample data") @@ -73,6 +89,10 @@ public interface LdapMessages { text = "LDAP Search: {0} | {1}") void ldapSearch(String baseDn, String filter); + @Message(level = MessageLevel.ERROR, + text = "LDAP Search failed: {0} | {1}, {2}") + void ldapSearchFailed(String baseDn, String filter, @StackTrace(level = MessageLevel.DEBUG) Exception e); + @Message(level = MessageLevel.DEBUG, text = "LDAP Bind: {0}") void ldapBind(String dn); diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/BackendFactory.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/BackendFactory.java index f432a6dc5..a204ac460 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/BackendFactory.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/BackendFactory.java @@ -32,18 +32,25 @@ public class BackendFactory { private static final LdapMessages LOG = MessagesFactory.get(LdapMessages.class); public static LdapBackend createBackend(String backendName, Map<String, String> config) throws Exception { - // Use ServiceLoader to discover all available backends (built-in and external plugins) - ServiceLoader<LdapBackend> loader = ServiceLoader.load(LdapBackend.class); - for (LdapBackend backend : loader) { - if (backend.getName().equalsIgnoreCase(backendName)) { - LOG.ldapBackendLoading(backend.getName(), "ServiceLoader"); - backend.initialize(config); + String backendType = config.get("backendType"); + if (backendType == null) { + // No backend type configured found + LOG.ldapBackendTypeNotFound(backendName); + throw new IllegalArgumentException("No LDAP backend type configured for : " + backendName); + } + + // Use ServiceLoader to discover all available backend factories (built-in and external plugins) + ServiceLoader<LdapBackendFactory> loader = ServiceLoader.load(LdapBackendFactory.class); + for (LdapBackendFactory backendFactory : loader) { + if (backendFactory.getType().equalsIgnoreCase(backendType)) { + LOG.ldapBackendLoading(backendType, "ServiceLoader"); + LdapBackend backend = backendFactory.create(backendName, config); return backend; } } // No matching backend found - LOG.ldapBackendNotFound(backendName); - throw new IllegalArgumentException("No LDAP backend found for type: " + backendName); + LOG.ldapBackendNotFound(backendType, backendName); + throw new IllegalArgumentException("No LDAP backend factory of type " + backendType + " found for : " + backendName); } } 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 9c3d96aed..8546a3acd 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 @@ -41,9 +41,12 @@ import java.util.Map; public class FileBackend implements LdapBackend { private static final LdapMessages LOG = MessagesFactory.get(LdapMessages.class); + static final String TYPE = "file"; + private Map<String, UserData> users = new HashMap<>(); - private String dataFile; - private String baseDn; + private final String dataFile; + private final String baseDn; + private final String name; static class UserData { String username; @@ -58,16 +61,26 @@ public class FileBackend implements LdapBackend { List<UserData> users; } + public FileBackend(String name, Map<String, String> config) throws Exception { + this.name = name; + dataFile = config.getOrDefault("dataFile", "ldap-users.json"); + baseDn = config.getOrDefault("baseDn", "dc=proxy,dc=com"); + loadData(); + } + @Override public String getName() { - return "file"; + return name; } @Override - public void initialize(Map<String, String> config) throws Exception { - dataFile = config.getOrDefault("dataFile", "ldap-users.json"); - baseDn = config.getOrDefault("baseDn", "dc=proxy,dc=com"); - loadData(); + public String getType() { + return TYPE; + } + + @Override + public String getBaseDn() { + return baseDn; } private void loadData() throws Exception { diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/FileBackendFactory.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/FileBackendFactory.java new file mode 100644 index 000000000..847753940 --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/FileBackendFactory.java @@ -0,0 +1,32 @@ +/* + * 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.backend; + +import java.util.Map; + +public class FileBackendFactory implements LdapBackendFactory { + @Override + public LdapBackend create(String name, Map<String, String> config) throws Exception { + return new FileBackend(name, config); + } + + @Override + public String getType() { + return FileBackend.TYPE; + } +} 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 4c459ff26..f5cc9c75a 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 @@ -22,7 +22,6 @@ import org.apache.directory.api.ldap.model.name.Dn; import org.apache.directory.api.ldap.model.schema.SchemaManager; import java.util.List; -import java.util.Map; /** * Interface for pluggable LDAP backends. @@ -33,16 +32,21 @@ import java.util.Map; * - REST APIs (Knox, Ranger, etc.) */ public interface LdapBackend { + /** - * Get the name of this backend implementation + * Get the name of this backend */ String getName(); /** - * Initialize the backend with configuration - * @param config Configuration properties + * Get the type of this backend implementation + */ + String getType(); + + /** + * Get the base dn of this backend */ - void initialize(Map<String, String> config) throws Exception; + String getBaseDn(); /** * Get a user entry by username diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapBackendFactory.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapBackendFactory.java new file mode 100644 index 000000000..2ec00eb37 --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapBackendFactory.java @@ -0,0 +1,25 @@ +/* + * 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.backend; + +import java.util.Map; + +public interface LdapBackendFactory { + LdapBackend create(String name, Map<String, String> config) throws Exception; + String getType(); +} 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 59355e4ad..07337b049 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 @@ -54,6 +54,9 @@ import java.util.stream.Collectors; public class LdapProxyBackend implements LdapBackend { private static final LdapMessages LOG = MessagesFactory.get(LdapMessages.class); + static final String TYPE = "ldap"; + + private String name; private String ldapUrl; private String bindDn; private String bindPassword; @@ -83,13 +86,8 @@ public class LdapProxyBackend implements LdapBackend { // Connection pool for efficient connection reuse private LdapConnectionPool connectionPool; - @Override - public String getName() { - return "ldap"; - } - - @Override - public void initialize(Map<String, String> config) throws Exception { + public LdapProxyBackend(String name, Map<String, String> config) { + this.name = name; // Proxy base DN is for entries created in the proxy LDAP server proxyBaseDn = config.get("baseDn"); if (proxyBaseDn == null || proxyBaseDn.isEmpty()) { @@ -154,14 +152,28 @@ public class LdapProxyBackend implements LdapBackend { initializeConnectionPool(config); } + @Override + public String getName() { + return name; + } + + @Override + public String getType() { + return TYPE; + } + + @Override + public String getBaseDn() { + return remoteBaseDn; + } + /** * Initializes the LDAP connection pool with configurable parameters. * Uses a validating pool to ensure connections remain healthy. * * @param config Configuration map that may contain pool settings - * @throws Exception if connection pool initialization fails */ - private void initializeConnectionPool(Map<String, String> config) throws Exception { + private void initializeConnectionPool(Map<String, String> config) { // Configure connection settings LdapConnectionConfig connectionConfig = new LdapConnectionConfig(); connectionConfig.setLdapHost(host); @@ -186,7 +198,7 @@ public class LdapProxyBackend implements LdapBackend { connectionPool.setMaxTotal(maxActive); connectionPool.setTestOnBorrow(true); - LOG.ldapBackendLoading(getName(), "Initialized connection pool with maxActive=" + maxActive); + LOG.ldapBackendLoading(getType(), "Initialized connection pool with maxActive=" + maxActive); } private void parseLdapUrl(String url) { diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackendFactory.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackendFactory.java new file mode 100644 index 000000000..f138a827d --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackendFactory.java @@ -0,0 +1,32 @@ +/* + * 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.backend; + +import java.util.Map; + +public class LdapProxyBackendFactory implements LdapBackendFactory { + @Override + public LdapBackend create(String name, Map<String, String> config) throws Exception { + return new LdapProxyBackend(name, config); + } + + @Override + public String getType() { + return LdapProxyBackend.TYPE; + } +} diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/interceptor/DuplicateUserFilteringInterceptor.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/interceptor/DuplicateUserFilteringInterceptor.java new file mode 100644 index 000000000..fa7287df1 --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/interceptor/DuplicateUserFilteringInterceptor.java @@ -0,0 +1,87 @@ +/* + * 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.interceptor; + +import com.google.common.annotations.VisibleForTesting; +import org.apache.directory.api.ldap.model.cursor.CursorException; +import org.apache.directory.api.ldap.model.cursor.ListCursor; +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.server.core.api.filtering.EntryFilteringCursor; +import org.apache.directory.server.core.api.filtering.EntryFilteringCursorImpl; +import org.apache.directory.server.core.api.interceptor.BaseInterceptor; +import org.apache.directory.server.core.api.interceptor.context.SearchOperationContext; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class DuplicateUserFilteringInterceptor extends BaseInterceptor { + + public DuplicateUserFilteringInterceptor(String name) { + super(name); + } + + @Override + public EntryFilteringCursor search(SearchOperationContext ctx) throws LdapException { + // First execute the interceptor chain to get the results + List<Entry> filteredEntries = List.of(); + try (EntryFilteringCursor originalResults = next(ctx)) { + List<Entry> originalEntries = new ArrayList<>(); + try { + while (originalResults.next()) { + originalEntries.add(originalResults.get()); + } + originalResults.close(); + } catch (CursorException e) { + // rethrow exception on incomplete iteration + throw new LdapException(e); + } + filteredEntries = filterDuplicateUsers(originalEntries); + } catch (IOException e) { + // IOException would only occur after finishing iterating over results + // we can ignore this exception and return the filtered entries + } + return new EntryFilteringCursorImpl(new ListCursor<>(filteredEntries), ctx, schemaManager); + } + + @VisibleForTesting + List<Entry> filterDuplicateUsers(List<Entry> originalEntries) { + Set<Value> seenUids = new HashSet<>(); + List<Entry> filteredEntries = new ArrayList<>(); + + for (Entry entry : originalEntries) { + Attribute uid = entry.get("uid"); + if (uid == null) { + // keep entry because it's not a user + filteredEntries.add(entry); + } else { + Value uidValue = uid.get(); + if (!seenUids.contains(uidValue)) { + filteredEntries.add(entry); + seenUids.add(uidValue); + } + } + } + return filteredEntries; + } +} diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/interceptor/DuplicateUserFilteringInterceptorFactory.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/interceptor/DuplicateUserFilteringInterceptorFactory.java new file mode 100644 index 000000000..b443c2def --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/interceptor/DuplicateUserFilteringInterceptorFactory.java @@ -0,0 +1,36 @@ +/* + * 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.interceptor; + +import org.apache.directory.server.core.api.interceptor.Interceptor; + +import java.util.Map; + +public class DuplicateUserFilteringInterceptorFactory implements KnoxLdapInterceptorFactory { + public static final String TYPE = "duplicateuserfilter"; + + @Override + public Interceptor create(String name, Map<String, String> config) { + return new DuplicateUserFilteringInterceptor(name); + } + + @Override + public String getType() { + return TYPE; + } +} diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/interceptor/InterceptorFactory.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/interceptor/InterceptorFactory.java new file mode 100644 index 000000000..6b26cc116 --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/interceptor/InterceptorFactory.java @@ -0,0 +1,59 @@ +/* + * 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.interceptor; + +import org.apache.directory.server.core.api.interceptor.Interceptor; +import org.apache.knox.gateway.i18n.messages.MessagesFactory; +import org.apache.knox.gateway.services.ldap.LdapMessages; + +import java.util.Map; +import java.util.ServiceLoader; + +/** + * Factory for creating LDAP Interceptor implementations using ServiceLoader for full extensibility. + * Backends are discovered via META-INF/services/org.apache.knox.gateway.services.ldap.interceptor.KnoxLdapInterceptorFactory + * Built-in interceptors are registered via ServiceLoader along with any external plugins. + */ +public class InterceptorFactory { + private static final LdapMessages LOG = MessagesFactory.get(LdapMessages.class); + + public static Interceptor createInterceptor(String interceptorName, Map<String, String> config) throws Exception { + String interceptorType = config.get("interceptorType"); + if (interceptorType == null) { + // No backend type configured found + LOG.ldapInterceptorTypeNotFound(interceptorName); + throw new IllegalArgumentException("No LDAP interceptor type configured for : " + interceptorName); + } + + // Use ServiceLoader to discover all available interceptors (built-in and external plugins) + // Indirect instantiation through a factory is used to allow configuration of multiple instances + // of the same class of interceptor. e.g., if multiple backends are configured + ServiceLoader<KnoxLdapInterceptorFactory> loader = ServiceLoader.load(KnoxLdapInterceptorFactory.class); + for (KnoxLdapInterceptorFactory interceptorFactory : loader) { + if (interceptorFactory.getType().equalsIgnoreCase(interceptorType)) { + LOG.ldapInterceptorCreating(interceptorType, "ServiceLoader"); + Interceptor interceptor = interceptorFactory.create(interceptorName, config); + return interceptor; + } + } + + // No matching interceptor found + LOG.ldapInterceptorNotFound(interceptorType, interceptorName); + throw new IllegalArgumentException("No LDAP interceptor of type " + interceptorType + " found for : " + interceptorName); + } +} diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/interceptor/KnoxLdapInterceptorFactory.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/interceptor/KnoxLdapInterceptorFactory.java new file mode 100644 index 000000000..2eb4fb5f9 --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/interceptor/KnoxLdapInterceptorFactory.java @@ -0,0 +1,41 @@ +/* + * 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.interceptor; + +import org.apache.directory.server.core.api.interceptor.Interceptor; + +import java.util.Map; + +/** + * Factory interface for creating Interceptor instances. + */ +public interface KnoxLdapInterceptorFactory { + /** + * Instantiate and interceptor + * @param name the name of the interceptor + * @param config the configuration for the interceptor + * @return the interceptor + */ + Interceptor create(String name, Map<String, String> config) throws Exception; + + /** + * Get the type of interceptor this factory creates + * @return the type of interceptor ths factory creates + */ + String getType(); +} diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/GroupLookupInterceptor.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/interceptor/UserSearchInterceptor.java similarity index 77% rename from gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/GroupLookupInterceptor.java rename to gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/interceptor/UserSearchInterceptor.java index 1218c8c78..1ac2ef5a2 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/GroupLookupInterceptor.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/interceptor/UserSearchInterceptor.java @@ -15,13 +15,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.knox.gateway.services.ldap; +package org.apache.knox.gateway.services.ldap.interceptor; import org.apache.directory.api.ldap.model.constants.AuthenticationLevel; import org.apache.directory.api.ldap.model.cursor.ListCursor; import org.apache.directory.api.ldap.model.entry.Entry; import org.apache.directory.api.ldap.model.exception.LdapException; -import org.apache.directory.api.ldap.model.schema.SchemaManager; import org.apache.directory.server.core.api.CoreSession; import org.apache.directory.server.core.api.DirectoryService; import org.apache.directory.server.core.api.LdapPrincipal; @@ -32,27 +31,41 @@ import org.apache.directory.server.core.api.interceptor.context.BindOperationCon import org.apache.directory.server.core.api.interceptor.context.LookupOperationContext; import org.apache.directory.server.core.api.interceptor.context.SearchOperationContext; 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 org.apache.knox.gateway.services.ldap.backend.BackendFactory; import org.apache.knox.gateway.services.ldap.backend.LdapBackend; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; /** - * Interceptor for LDAP operations to proxy user searches to backend when not found locally + * Interceptor for LDAP operations to proxy user searches to backends when not found locally */ -public class GroupLookupInterceptor extends BaseInterceptor { +public class UserSearchInterceptor extends BaseInterceptor { + private static final LdapMessages LOG = MessagesFactory.get(LdapMessages.class); - private DirectoryService directoryService; - private LdapBackend backend; private static final Pattern UID_PATTERN = Pattern.compile(".*\\(uid=([^)]+)\\).*"); private static final Pattern CN_PATTERN = Pattern.compile(".*\\(cn=([^)]+)\\).*"); private static final Pattern SAMAACCOUNTNAME_PATTERN = Pattern.compile(".*\\(sAMAccountName=([^)]+)\\).*"); - public GroupLookupInterceptor(DirectoryService directoryService, LdapBackend backend) { - this.directoryService = directoryService; - this.backend = backend; + private final LdapBackend backend; + + public UserSearchInterceptor(String name, Map<String, String> config) throws Exception { + super(name); + backend = BackendFactory.createBackend(name, config); + } + + public LdapBackend getBackend() { + return backend; + } + + @Override + public void init(DirectoryService directoryService) throws LdapException { + super.init(directoryService); } @Override @@ -78,15 +91,11 @@ public class GroupLookupInterceptor extends BaseInterceptor { LOG.ldapSearch(baseDn, filter); - // First try the normal search + // First execute the next interceptor in the chain EntryFilteringCursor originalResults; - try { - originalResults = next(ctx); - } catch (Exception e) { - throw new LdapException(e); - } + originalResults = next(ctx); - // Check if this is a user search and if we got no results, try the backend + // Check if this is a user search and call the backends if (isUserSearch(filter)) { String username = extractUser(filter); @@ -98,42 +107,40 @@ public class GroupLookupInterceptor extends BaseInterceptor { } originalResults.close(); } catch (Exception e) { - // If we get an error or no results, try the backend + // If we get an error or no results, try the backends } - // If no local results, try backend - if (entries.isEmpty() && username != null) { + if (username != null) { try { - SchemaManager schemaManager = directoryService.getSchemaManager(); - if (username.contains("*")) { // Wildcard search - use searchUsers LOG.ldapSearch(baseDn, "wildcard user search: " + username); - List<Entry> backendEntries = backend.searchUsers(username, schemaManager); - // Return backend results directly without caching to avoid deadlock // (caching during an active search can cause ApacheDS locking issues) - entries.addAll(backendEntries); + entries.addAll(backend.searchUsers(username, schemaManager)); } else { - // Specific user lookup - LOG.ldapUserLoaded(username); - Entry backendEntry = backend.getUser(username, schemaManager); - - if (backendEntry != null) { - // Return backend result directly without caching - entries.add(backendEntry); - LOG.ldapUserEntry(backendEntry.toString()); - } else { - LOG.ldapUserNull(username); + // if no results, perform single-user search + if (entries.isEmpty()) { + // Specific user lookup + Entry backendEntry = backend.getUser(username, schemaManager); + LOG.ldapUserLoaded(username); + + if (backendEntry != null) { + // Return backend result directly without caching + entries.add(backendEntry); + LOG.ldapUserEntry(backendEntry.toString()); + } else { + LOG.ldapUserNull(username); + } } } } catch (Exception e) { - LOG.ldapServiceStopFailed(e); + LOG.ldapSearchFailed(baseDn, filter, e); } } // Return cursor with our results - use a simple approach - return new EntryFilteringCursorImpl(new ListCursor<>(entries), ctx, directoryService.getSchemaManager()); + return new EntryFilteringCursorImpl(new ListCursor<>(entries), ctx, schemaManager); } return originalResults; diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/interceptor/UserSearchInterceptorFactory.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/interceptor/UserSearchInterceptorFactory.java new file mode 100644 index 000000000..862d3a964 --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/interceptor/UserSearchInterceptorFactory.java @@ -0,0 +1,36 @@ +/* + * 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.interceptor; + +import org.apache.directory.server.core.api.interceptor.Interceptor; + +import java.util.Map; + +public class UserSearchInterceptorFactory implements KnoxLdapInterceptorFactory { + public static final String TYPE = "backend"; + + @Override + public Interceptor create(String name, Map<String, String> config) throws Exception { + return new UserSearchInterceptor(name, config); + } + + @Override + public String getType() { + return TYPE; + } +} diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/util/KnoxCLI.java b/gateway-server/src/main/java/org/apache/knox/gateway/util/KnoxCLI.java index b1465a7af..784c416c1 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/util/KnoxCLI.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/util/KnoxCLI.java @@ -1911,6 +1911,7 @@ public class KnoxCLI extends Configured implements Tool { return; } + services.start(); try { final KnoxLDAPService ldapService = services.getService(ServiceType.LDAP_SERVICE); out.println("Querying KnoxLDAPService for groups of user: " + username); diff --git a/gateway-server/src/main/resources/META-INF/services/org.apache.knox.gateway.services.ldap.backend.LdapBackend b/gateway-server/src/main/resources/META-INF/services/org.apache.knox.gateway.services.ldap.backend.LdapBackendFactory similarity index 83% copy from gateway-server/src/main/resources/META-INF/services/org.apache.knox.gateway.services.ldap.backend.LdapBackend copy to gateway-server/src/main/resources/META-INF/services/org.apache.knox.gateway.services.ldap.backend.LdapBackendFactory index c8bd82de6..424d400b8 100644 --- a/gateway-server/src/main/resources/META-INF/services/org.apache.knox.gateway.services.ldap.backend.LdapBackend +++ b/gateway-server/src/main/resources/META-INF/services/org.apache.knox.gateway.services.ldap.backend.LdapBackendFactory @@ -16,6 +16,6 @@ # limitations under the License. ########################################################################## -# Built-in LDAP backend implementations -org.apache.knox.gateway.services.ldap.backend.FileBackend -org.apache.knox.gateway.services.ldap.backend.LdapProxyBackend \ No newline at end of file +# Built-in LDAP backend factory implementations +org.apache.knox.gateway.services.ldap.backend.FileBackendFactory +org.apache.knox.gateway.services.ldap.backend.LdapProxyBackendFactory \ No newline at end of file diff --git a/gateway-server/src/main/resources/META-INF/services/org.apache.knox.gateway.services.ldap.backend.LdapBackend b/gateway-server/src/main/resources/META-INF/services/org.apache.knox.gateway.services.ldap.interceptor.KnoxLdapInterceptorFactory similarity index 80% rename from gateway-server/src/main/resources/META-INF/services/org.apache.knox.gateway.services.ldap.backend.LdapBackend rename to gateway-server/src/main/resources/META-INF/services/org.apache.knox.gateway.services.ldap.interceptor.KnoxLdapInterceptorFactory index c8bd82de6..3275b3329 100644 --- a/gateway-server/src/main/resources/META-INF/services/org.apache.knox.gateway.services.ldap.backend.LdapBackend +++ b/gateway-server/src/main/resources/META-INF/services/org.apache.knox.gateway.services.ldap.interceptor.KnoxLdapInterceptorFactory @@ -16,6 +16,6 @@ # limitations under the License. ########################################################################## -# Built-in LDAP backend implementations -org.apache.knox.gateway.services.ldap.backend.FileBackend -org.apache.knox.gateway.services.ldap.backend.LdapProxyBackend \ No newline at end of file +# Built-in LDAP interceptor factory implementations +org.apache.knox.gateway.services.ldap.interceptor.UserSearchInterceptorFactory +org.apache.knox.gateway.services.ldap.interceptor.DuplicateUserFilteringInterceptorFactory \ No newline at end of file diff --git a/gateway-server/src/main/resources/conf/gateway-site.xml b/gateway-server/src/main/resources/conf/gateway-site.xml index ce7525e2d..70ab3b188 100644 --- a/gateway-server/src/main/resources/conf/gateway-site.xml +++ b/gateway-server/src/main/resources/conf/gateway-site.xml @@ -63,9 +63,9 @@ limitations under the License. </property> <property> - <name>gateway.ldap.backend.type</name> - <value>ldap</value> - <description>Backend type for LDAP service. Currently supported: file, ldap. Future: jdbc, knox.</description> + <name>gateway.ldap.interceptor.names</name> + <value>ldapproxy,duplicatefilter</value> + <description>Interceptor names for LDAP service in priority order.</description> </property> <property> @@ -74,61 +74,86 @@ limitations under the License. <description>Path to JSON data file for file-based backend. Supports ${GATEWAY_DATA_HOME} variable.</description> </property> - <!-- LDAP backend proxy configuration (active when gateway.ldap.backend.type=ldap) --> + <!-- LDAP backend proxy configuration (active when gateway.ldap.interceptor.names includes ldapproxy) --> + <property> + <name>gateway.ldap.interceptor.ldapproxy.interceptorType</name> + <value>backend</value> + <description>Interceptor type.</description> + </property> + <property> + <name>gateway.ldap.interceptor.ldapproxy.backendType</name> + <value>ldap</value> + <description>Backend type for LDAP service. Currently supported: file, ldap. Future: jdbc, knox.</description> + </property> <property> - <name>gateway.ldap.backend.ldap.url</name> + <name>gateway.ldap.interceptor.ldapproxy.url</name> <value>ldap://localhost:33389</value> <description>LDAP server URL for proxy backend</description> </property> <property> - <name>gateway.ldap.backend.ldap.remoteBaseDn</name> + <name>gateway.ldap.interceptor.ldapproxy.remoteBaseDn</name> <value>dc=hadoop,dc=apache,dc=org</value> <description>Base DN of the remote LDAP server</description> </property> <property> - <name>gateway.ldap.backend.ldap.systemUsername</name> + <name>gateway.ldap.interceptor.ldapproxy.systemUsername</name> <value>uid=guest,ou=people,dc=hadoop,dc=apache,dc=org</value> <description>LDAP bind DN for proxy backend authentication</description> </property> <property> - <name>gateway.ldap.backend.ldap.systemPassword</name> + <name>gateway.ldap.interceptor.ldapproxy.systemPassword</name> <value>guest-password</value> <description>LDAP bind password for proxy backend authentication</description> </property> <!-- Backend-specific configuration using prefixed properties --> - <!-- Uncomment and configure based on backend type specified in gateway.ldap.backend.type --> + <!-- Uncomment and configure based on interceptor names specified in gateway.ldap.interceptor.names --> - <!-- File backend configuration (gateway.ldap.backend.type=file) --> + <!-- File backend configuration (gateway.ldap.interceptor.<interceptorName>.backendType=file) --> <!-- <property> - <name>gateway.ldap.backend.file.dataFile</name> + <name>gateway.ldap.interceptor.ldapfile.interceptorType</name> + <value>backend</value> + <description>Interceptor type.</description> + </property> + <property> + <name>gateway.ldap.interceptor.ldapfile.backendType</name> + <value>file</value> + <description>Backend type for LDAP service. Currently supported: file, ldap. Future: jdbc, knox.</description> + </property> + <property> + <name>gateway.ldap.interceptor.ldapfile.dataFile</name> <value>${GATEWAY_DATA_HOME}/ldap-users.json</value> <description>Path to JSON file containing user and group data</description> </property> --> - <!-- LDAP proxy backend configuration (gateway.ldap.backend.type=ldap) --> + <!-- LDAP proxy backend configuration (gateway.ldap.interceptor.<interceptorName>.backendType=ldap) --> <!-- This backend proxies to an external LDAP server (e.g., demo LDAP) --> <!-- Example 1: Using Knox demo LDAP server (default port 33389) <property> - <name>gateway.ldap.backend.ldap.url</name> + <name>gateway.ldap.interceptor.demoldap.backendType</name> + <value>ldap</value> + <description>Backend type for LDAP service. Currently supported: file, ldap. Future: jdbc, knox.</description> + </property> + <property> + <name>gateway.ldap.interceptor.demoldap.url</name> <value>ldap://localhost:33389</value> <description>LDAP server URL</description> </property> <property> - <name>gateway.ldap.backend.ldap.remoteBaseDn</name> + <name>gateway.ldap.interceptor.demoldap.remoteBaseDn</name> <value>dc=hadoop,dc=apache,dc=org</value> <description>Base DN of the remote LDAP server</description> </property> <property> - <name>gateway.ldap.backend.ldap.systemUsername</name> + <name>gateway.ldap.interceptor.demoldap.systemUsername</name> <value>uid=guest,ou=people,dc=hadoop,dc=apache,dc=org</value> <description>LDAP bind DN for authentication</description> </property> <property> - <name>gateway.ldap.backend.ldap.systemPassword</name> + <name>gateway.ldap.interceptor.demoldap.systemPassword</name> <value>guest-password</value> <description>LDAP bind password</description> </property> @@ -137,32 +162,37 @@ limitations under the License. <!-- Example 2: Using external LDAP with authentication (supports both naming conventions) <property> - <name>gateway.ldap.backend.ldap.url</name> + <name>gateway.ldap.interceptor.externalldap.backendType</name> + <value>ldap</value> + <description>Backend type for LDAP service. Currently supported: file, ldap. Future: jdbc, knox.</description> + </property> + <property> + <name>gateway.ldap.interceptor.externalldap.url</name> <value>ldap://ldap.example.com:389</value> <description>LDAP server URL</description> </property> <property> - <name>gateway.ldap.backend.ldap.remoteBaseDn</name> + <name>gateway.ldap.interceptor.externalldap.remoteBaseDn</name> <value>dc=example,dc=com</value> <description>Base DN of the remote LDAP server</description> </property> <property> - <name>gateway.ldap.backend.ldap.systemUsername</name> + <name>gateway.ldap.interceptor.externalldap.systemUsername</name> <value>cn=admin,dc=example,dc=com</value> <description>LDAP bind DN for authentication (or use bindDn)</description> </property> <property> - <name>gateway.ldap.backend.ldap.systemPassword</name> + <name>gateway.ldap.interceptor.externalldap.systemPassword</name> <value>secret</value> <description>LDAP bind password (or use bindPassword)</description> </property> <property> - <name>gateway.ldap.backend.ldap.userSearchBase</name> + <name>gateway.ldap.interceptor.externalldap.userSearchBase</name> <value>ou=people,dc=example,dc=com</value> <description>Base DN for user searches on remote server (defaults to ou=people,{remoteBaseDn})</description> </property> <property> - <name>gateway.ldap.backend.ldap.groupSearchBase</name> + <name>gateway.ldap.interceptor.externalldap.groupSearchBase</name> <value>ou=groups,dc=example,dc=com</value> <description>Base DN for group searches on remote server (defaults to ou=groups,{remoteBaseDn})</description> </property> @@ -170,58 +200,75 @@ limitations under the License. <!-- Alternative: Use host and port instead of URL <property> - <name>gateway.ldap.backend.ldap.host</name> + <name>gateway.ldap.interceptor.externalldap.host</name> <value>localhost</value> <description>LDAP server hostname</description> </property> <property> - <name>gateway.ldap.backend.ldap.port</name> + <name>gateway.ldap.interceptor.externalldap.port</name> <value>33389</value> <description>LDAP server port</description> </property> --> - <!-- Database backend configuration (gateway.ldap.backend.type=database) --> + <!-- Database backend configuration (gateway.ldap.backend.type=jdbc) --> <!-- <property> - <name>gateway.ldap.backend.database.jdbc-url</name> + <name>gateway.ldap.interceptor.jdbc.backendType</name> + <value>jdbc</value> + <description>Backend type for LDAP service. Currently supported: file, ldap. Future: jdbc, knox.</description> + </property> + <property> + <name>gateway.ldap.interceptor.jdbc.url</name> <value>jdbc:mysql://localhost:3306/knox</value> <description>JDBC connection URL</description> </property> <property> - <name>gateway.ldap.backend.database.driver</name> + <name>gateway.ldap.interceptor.jdbc.driver</name> <value>com.mysql.cj.jdbc.Driver</value> <description>JDBC driver class name</description> </property> <property> - <name>gateway.ldap.backend.database.username</name> + <name>gateway.ldap.interceptor.jdbc.username</name> <value>knox_user</value> <description>Database username</description> </property> <property> - <name>gateway.ldap.backend.database.password</name> + <name>gateway.ldap.interceptor.jdbc.password</name> <value>secret</value> <description>Database password</description> </property> --> - <!-- Knox Auth backend configuration (gateway.ldap.backend.type=knox-auth) --> + <!-- Knox Auth backend configuration (gateway.ldap.backend.type=knox) --> <!-- <property> - <name>gateway.ldap.backend.knox-auth.url</name> + <name>gateway.ldap.interceptor.knox.backendType</name> + <value>knox</value> + <description>Backend type for LDAP service. Currently supported: file, ldap. Future: jdbc, knox.</description> + </property> + <property> + <name>gateway.ldap.interceptor.knox.url</name> <value>https://knox-server/gateway/sandbox/knoxauth/api</value> <description>Knox authentication service URL</description> </property> <property> - <name>gateway.ldap.backend.knox-auth.username</name> + <name>gateway.ldap.interceptor.knox.username</name> <value>admin</value> <description>Knox admin username</description> </property> <property> - <name>gateway.ldap.backend.knox-auth.password</name> + <name>gateway.ldap.interceptor.knox.password</name> <value>admin-password</value> <description>Knox admin password</description> </property> --> + <!-- Duplicate Filter Interceptor --> + <property> + <name>gateway.ldap.interceptor.duplicatefilter.interceptorType</name> + <value>duplicateuserfilter</value> + <description>Interceptor type.</description> + </property> + </configuration> \ No newline at end of file diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManagerTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManagerTest.java index 4528c8b4a..c41afb0c0 100644 --- a/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManagerTest.java +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManagerTest.java @@ -19,17 +19,22 @@ package org.apache.knox.gateway.services.ldap; import org.apache.knox.gateway.config.GatewayConfig; import org.easymock.EasyMock; +import org.apache.directory.api.ldap.model.name.Dn; +import org.apache.knox.gateway.services.ldap.interceptor.UserSearchInterceptor; import org.junit.Test; import org.junit.Before; import org.junit.After; import java.io.File; +import java.net.ServerSocket; import java.util.HashMap; +import java.util.List; import java.util.Map; import static org.easymock.EasyMock.expect; import static org.easymock.EasyMock.replay; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertFalse; @@ -41,6 +46,7 @@ public class KnoxLDAPServerManagerTest { private KnoxLDAPServerManager serverManager; private File tempWorkDir; private File tempLdapFile; + private int port; @Before public void setUp() throws Exception { @@ -59,6 +65,11 @@ public class KnoxLDAPServerManagerTest { try (java.io.BufferedWriter writer = java.nio.file.Files.newBufferedWriter(tempLdapFile.toPath(), java.nio.charset.StandardCharsets.UTF_8)) { writer.write("{\"users\":[{\"dn\":\"uid=admin,ou=people,dc=test,dc=com\",\"uid\":\"admin\",\"cn\":\"Administrator\",\"userPassword\":\"admin-password\"}],\"groups\":[]}"); } + + // pick an unused port + try (ServerSocket socket = new ServerSocket(0)) { + port = socket.getLocalPort(); + } } @After @@ -77,16 +88,17 @@ public class KnoxLDAPServerManagerTest { public void testInitializeWithFileBackend() throws Exception { GatewayConfig mockConfig = EasyMock.createNiceMock(GatewayConfig.class); expect(mockConfig.getGatewayDataDir()).andReturn(tempWorkDir.getParent()).anyTimes(); - expect(mockConfig.getLDAPPort()).andReturn(3890).anyTimes(); + expect(mockConfig.getLDAPPort()).andReturn(port).anyTimes(); expect(mockConfig.getLDAPBaseDN()).andReturn("dc=test,dc=com").anyTimes(); - expect(mockConfig.getLDAPBackendType()).andReturn("file").anyTimes(); + expect(mockConfig.getLDAPInterceptorNames()).andReturn(List.of("filebackend")).anyTimes(); expect(mockConfig.getLDAPBackendDataFile()).andReturn(tempLdapFile.getAbsolutePath()).anyTimes(); - expect(mockConfig.getLDAPBackendConfig("file")).andReturn(new HashMap<>()).anyTimes(); + Map<String, String> backendConfig = createFileBackendInterceptorConfig(); + expect(mockConfig.getLDAPInterceptorConfig("filebackend")).andReturn(backendConfig).anyTimes(); replay(mockConfig); serverManager.initialize(mockConfig); - assertEquals("Port should be set correctly", 3890, serverManager.getPort()); + assertEquals("Port should be set correctly", port, serverManager.getPort()); assertEquals("Base DN should be set correctly", "dc=test,dc=com", serverManager.getBaseDn()); assertFalse("Should not be running after initialize", serverManager.isRunning()); } @@ -95,44 +107,62 @@ public class KnoxLDAPServerManagerTest { public void testInitializeWithLdapBackend() throws Exception { GatewayConfig mockConfig = EasyMock.createNiceMock(GatewayConfig.class); expect(mockConfig.getGatewayDataDir()).andReturn(tempWorkDir.getParent()).anyTimes(); - expect(mockConfig.getLDAPPort()).andReturn(3891).anyTimes(); - expect(mockConfig.getLDAPBaseDN()).andReturn("dc=proxy,dc=com").anyTimes(); - expect(mockConfig.getLDAPBackendType()).andReturn("ldap").anyTimes(); - - Map<String, String> backendConfig = new HashMap<>(); - backendConfig.put("url", "ldap://localhost:33389"); - backendConfig.put("remoteBaseDn", "dc=hadoop,dc=apache,dc=org"); - backendConfig.put("systemUsername", "cn=admin,dc=hadoop,dc=apache,dc=org"); - backendConfig.put("systemPassword", "admin-password"); - - expect(mockConfig.getLDAPBackendConfig("ldap")).andReturn(backendConfig).anyTimes(); + expect(mockConfig.getLDAPPort()).andReturn(port).anyTimes(); + expect(mockConfig.getLDAPBaseDN()).andReturn("dc=test,dc=com").anyTimes(); + expect(mockConfig.getLDAPInterceptorNames()).andReturn(List.of("ldapbackend")).anyTimes(); + Map<String, String> backendConfig = createLdapBackendInterceptorConfig(); + expect(mockConfig.getLDAPInterceptorConfig("ldapbackend")).andReturn(backendConfig).anyTimes(); replay(mockConfig); serverManager.initialize(mockConfig); - assertEquals("Port should be set correctly", 3891, serverManager.getPort()); - assertEquals("Base DN should be set correctly", "dc=proxy,dc=com", serverManager.getBaseDn()); + assertEquals("Port should be set correctly", port, serverManager.getPort()); + assertEquals("Base DN should be set correctly", "dc=test,dc=com", serverManager.getBaseDn()); assertFalse("Should not be running after initialize", serverManager.isRunning()); } @Test(expected = Exception.class) - public void testInitializeWithInvalidBackendType() throws Exception { + public void testInitializeWithInvalidInterceptorType() throws Exception { GatewayConfig mockConfig = EasyMock.createNiceMock(GatewayConfig.class); expect(mockConfig.getGatewayDataDir()).andReturn(tempWorkDir.getParent()).anyTimes(); - expect(mockConfig.getLDAPBackendType()).andReturn("invalid").anyTimes(); - expect(mockConfig.getLDAPBackendConfig("invalid")).andReturn(new HashMap<>()).anyTimes(); + expect(mockConfig.getLDAPInterceptorNames()).andReturn(List.of("invalid")).anyTimes(); + Map<String, String> backendConfig = new HashMap<>(); + backendConfig.put("interceptorType", "badinterceptor"); + expect(mockConfig.getLDAPInterceptorConfig("invalid")).andReturn(new HashMap<>()).anyTimes(); replay(mockConfig); serverManager.initialize(mockConfig); } + @Test + public void testInitializeWithMultipleBackends() throws Exception { + GatewayConfig mockConfig = EasyMock.createNiceMock(GatewayConfig.class); + expect(mockConfig.getGatewayDataDir()).andReturn(tempWorkDir.getParent()).anyTimes(); + expect(mockConfig.getLDAPPort()).andReturn(port).anyTimes(); + expect(mockConfig.getLDAPBaseDN()).andReturn("dc=test,dc=com").anyTimes(); + expect(mockConfig.getLDAPInterceptorNames()).andReturn(List.of("filebackend", "ldapbackend")).anyTimes(); + expect(mockConfig.getLDAPBackendDataFile()).andReturn(tempLdapFile.getAbsolutePath()).anyTimes(); + Map<String, String> fileBackendConfig = createFileBackendInterceptorConfig(); + expect(mockConfig.getLDAPInterceptorConfig("filebackend")).andReturn(fileBackendConfig).anyTimes(); + Map<String, String> ldapBackendConfig = createLdapBackendInterceptorConfig(); + expect(mockConfig.getLDAPInterceptorConfig("ldapbackend")).andReturn(ldapBackendConfig).anyTimes(); + replay(mockConfig); + + serverManager.initialize(mockConfig); + + assertEquals("Port should be set correctly", port, serverManager.getPort()); + assertEquals("Base DN should be set correctly", "dc=test,dc=com", serverManager.getBaseDn()); + assertFalse("Should not be running after initialize", serverManager.isRunning()); + } + @Test public void testLockFileCleanup() throws Exception { GatewayConfig mockConfig = EasyMock.createNiceMock(GatewayConfig.class); expect(mockConfig.getGatewayDataDir()).andReturn(tempWorkDir.getParent()).anyTimes(); - expect(mockConfig.getLDAPBackendType()).andReturn("file").anyTimes(); + expect(mockConfig.getLDAPInterceptorNames()).andReturn(List.of("filebackend")).anyTimes(); expect(mockConfig.getLDAPBackendDataFile()).andReturn(tempLdapFile.getAbsolutePath()).anyTimes(); - expect(mockConfig.getLDAPBackendConfig("file")).andReturn(new HashMap<>()).anyTimes(); + Map<String, String> backendConfig = createFileBackendInterceptorConfig(); + expect(mockConfig.getLDAPInterceptorConfig("filebackend")).andReturn(backendConfig).anyTimes(); replay(mockConfig); // The manager will use tempWorkDir.getParent()/ldap-server as workDir @@ -162,9 +192,11 @@ public class KnoxLDAPServerManagerTest { public void testStopBeforeStart() throws Exception { GatewayConfig mockConfig = EasyMock.createNiceMock(GatewayConfig.class); expect(mockConfig.getGatewayDataDir()).andReturn(tempWorkDir.getParent()).anyTimes(); - expect(mockConfig.getLDAPBackendType()).andReturn("file").anyTimes(); + expect(mockConfig.getLDAPInterceptorNames()).andReturn(List.of("filebackend")).anyTimes(); expect(mockConfig.getLDAPBackendDataFile()).andReturn(tempLdapFile.getAbsolutePath()).anyTimes(); - expect(mockConfig.getLDAPBackendConfig("file")).andReturn(new HashMap<>()).anyTimes(); + Map<String, String> backendConfig = createFileBackendInterceptorConfig(); + expect(mockConfig.getLDAPInterceptorConfig("filebackend")).andReturn(backendConfig).anyTimes(); + replay(mockConfig); serverManager.initialize(mockConfig); @@ -177,9 +209,10 @@ public class KnoxLDAPServerManagerTest { public void testMultipleStopCalls() throws Exception { GatewayConfig mockConfig = EasyMock.createNiceMock(GatewayConfig.class); expect(mockConfig.getGatewayDataDir()).andReturn(tempWorkDir.getParent()).anyTimes(); - expect(mockConfig.getLDAPBackendType()).andReturn("file").anyTimes(); + expect(mockConfig.getLDAPInterceptorNames()).andReturn(List.of("filebackend")).anyTimes(); expect(mockConfig.getLDAPBackendDataFile()).andReturn(tempLdapFile.getAbsolutePath()).anyTimes(); - expect(mockConfig.getLDAPBackendConfig("file")).andReturn(new HashMap<>()).anyTimes(); + Map<String, String> backendConfig = createFileBackendInterceptorConfig(); + expect(mockConfig.getLDAPInterceptorConfig("filebackend")).andReturn(backendConfig).anyTimes(); replay(mockConfig); serverManager.initialize(mockConfig); @@ -196,6 +229,119 @@ public class KnoxLDAPServerManagerTest { serverManager.start(); } + @Test + public void testStartWithFileBackend() throws Exception { + GatewayConfig mockConfig = EasyMock.createNiceMock(GatewayConfig.class); + expect(mockConfig.getGatewayDataDir()).andReturn(tempWorkDir.getParent()).anyTimes(); + expect(mockConfig.getLDAPPort()).andReturn(port).anyTimes(); + expect(mockConfig.getLDAPBaseDN()).andReturn("dc=test,dc=com").anyTimes(); + expect(mockConfig.getLDAPInterceptorNames()).andReturn(List.of("filebackend")).anyTimes(); + expect(mockConfig.getLDAPBackendDataFile()).andReturn(tempLdapFile.getAbsolutePath()).anyTimes(); + Map<String, String> backendConfig = createFileBackendInterceptorConfig(); + expect(mockConfig.getLDAPInterceptorConfig("filebackend")).andReturn(backendConfig).anyTimes(); + replay(mockConfig); + + serverManager.initialize(mockConfig); + + serverManager.start(); + + UserSearchInterceptor interceptor = (UserSearchInterceptor) serverManager.directoryService.getInterceptor("filebackend"); + assertNotNull("Interceptor should not be null", interceptor); + assertEquals("File backend dn should match configuration", + backendConfig.get("baseDn"), interceptor.getBackend().getBaseDn()); + // LdapNoSuchObjectException will be thrown if expected partition does not exist + serverManager.directoryService.getPartitionNexus().getPartition( + new Dn(serverManager.directoryService.getSchemaManager(), backendConfig.get("baseDn"))); + } + + @Test + public void testStartWithLdapProxyBackend() throws Exception { + GatewayConfig mockConfig = EasyMock.createNiceMock(GatewayConfig.class); + expect(mockConfig.getGatewayDataDir()).andReturn(tempWorkDir.getParent()).anyTimes(); + expect(mockConfig.getLDAPPort()).andReturn(port).anyTimes(); + expect(mockConfig.getLDAPBaseDN()).andReturn("dc=proxy,dc=com").anyTimes(); + expect(mockConfig.getLDAPInterceptorNames()).andReturn(List.of("ldapbackend")).anyTimes(); + Map<String, String> backendConfig = createLdapBackendInterceptorConfig(); + expect(mockConfig.getLDAPInterceptorConfig("ldapbackend")).andReturn(backendConfig).anyTimes(); + replay(mockConfig); + + serverManager.initialize(mockConfig); + + serverManager.start(); + + // Ensure that the partitions are created and backends registered with the UserSearchInterceptor + UserSearchInterceptor interceptor = (UserSearchInterceptor) serverManager.directoryService.getInterceptor("ldapbackend"); + assertNotNull("Interceptor should not be null", interceptor); + assertEquals("LDAP backend dn should match configuration", + backendConfig.get("remoteBaseDn"), interceptor.getBackend().getBaseDn()); + // LdapNoSuchObjectException will be thrown if expected partition does not exist + serverManager.directoryService.getPartitionNexus().getPartition( + new Dn(serverManager.directoryService.getSchemaManager(), backendConfig.get("baseDn"))); + } + + @Test + public void testStartWithMultipleBackends() throws Exception { + GatewayConfig mockConfig = EasyMock.createNiceMock(GatewayConfig.class); + expect(mockConfig.getGatewayDataDir()).andReturn(tempWorkDir.getParent()).anyTimes(); + expect(mockConfig.getLDAPPort()).andReturn(port).anyTimes(); + expect(mockConfig.getLDAPBaseDN()).andReturn("dc=test,dc=com").anyTimes(); + expect(mockConfig.getLDAPInterceptorNames()).andReturn(List.of("filebackend", "ldapbackend")).anyTimes(); + expect(mockConfig.getLDAPBackendDataFile()).andReturn(tempLdapFile.getAbsolutePath()).anyTimes(); + Map<String, String> fileBackendConfig = createFileBackendInterceptorConfig(); + expect(mockConfig.getLDAPInterceptorConfig("filebackend")).andReturn(fileBackendConfig).anyTimes(); + Map<String, String> ldapBackendConfig = createLdapBackendInterceptorConfig(); + expect(mockConfig.getLDAPInterceptorConfig("ldapbackend")).andReturn(ldapBackendConfig).anyTimes(); + replay(mockConfig); + + serverManager.initialize(mockConfig); + + serverManager.start(); + + // Ensure that the partitions are created and backends registered with the file backend interceptor + UserSearchInterceptor fileInterceptor = (UserSearchInterceptor) serverManager.directoryService.getInterceptor("filebackend"); + assertNotNull("Interceptor should not be null", fileInterceptor); + assertEquals("File backend dn should match configuration", + fileBackendConfig.get("baseDn"), fileInterceptor.getBackend().getBaseDn()); + // LdapNoSuchObjectException will be thrown if expected partition does not exist + serverManager.directoryService.getPartitionNexus().getPartition( + new Dn(serverManager.directoryService.getSchemaManager(), fileBackendConfig.get("baseDn"))); + + // Ensure that the partitions are created and backends registered with the ldap backend interceptor + UserSearchInterceptor ldapInterceptor = (UserSearchInterceptor) serverManager.directoryService.getInterceptor("ldapbackend"); + assertNotNull("Interceptor should not be null", ldapInterceptor); + assertEquals("LDAP backend dn should match configuration", + ldapBackendConfig.get("remoteBaseDn"), ldapInterceptor.getBackend().getBaseDn()); + // LdapNoSuchObjectException will be thrown if expected partition does not exist + serverManager.directoryService.getPartitionNexus().getPartition( + new Dn(serverManager.directoryService.getSchemaManager(), ldapBackendConfig.get("baseDn"))); + } + + @Test + public void testGetUserGroups() { + + } + + private Map<String, String> createFileBackendInterceptorConfig() { + Map<String, String> config = new HashMap<>(); + config.put("interceptorType", "backend"); + config.put("backendType", "file"); + config.put("baseDn", "dc=file,dc=com"); + config.put("dataFile", tempLdapFile.getAbsolutePath()); + return config; + } + + private Map<String, String> createLdapBackendInterceptorConfig() { + Map<String, String> config = new HashMap<>(); + config.put("interceptorType", "backend"); + config.put("backendType", "ldap"); + config.put("baseDn", "dc=proxy,dc=com"); + config.put("url", "ldap://localhost:33389"); + config.put("remoteBaseDn", "dc=hadoop,dc=apache,dc=org"); + config.put("systemUsername", "cn=admin,dc=hadoop,dc=apache,dc=org"); + config.put("systemPassword", "admin-password"); + return config; + } + private void cleanupTempFiles() { if (tempLdapFile != null && tempLdapFile.exists()) { tempLdapFile.delete(); 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 40777b253..399e05485 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 @@ -25,6 +25,7 @@ import org.junit.After; import java.io.File; import java.util.HashMap; +import java.util.List; import java.util.Map; import static org.junit.Assert.assertEquals; @@ -169,13 +170,15 @@ public class KnoxLDAPServiceTest { expect(mockConfig.getGatewayDataDir()).andReturn(tempDataDir.getAbsolutePath()).atLeastOnce(); expect(mockConfig.getLDAPPort()).andReturn(3890).times(1).andReturn(3891).anyTimes(); expect(mockConfig.getLDAPBaseDN()).andReturn("file".equals(backendType) ? "dc=test,dc=com" : "dc=proxy,dc=com").atLeastOnce(); - expect(mockConfig.getLDAPBackendType()).andReturn(backendType).atLeastOnce(); - expect(mockConfig.getLDAPBackendConfig(backendType)).andReturn(buildBackendConfig(backendType)).atLeastOnce(); + expect(mockConfig.getLDAPInterceptorNames()).andReturn(List.of("testbackend")).atLeastOnce(); + expect(mockConfig.getLDAPInterceptorConfig("testbackend")).andReturn(buildBackendConfig(backendType)).atLeastOnce(); replay(mockConfig); } private Map<String, String> buildBackendConfig(String backendType) { final Map<String, String> backendConfig = new HashMap<>(); + backendConfig.put("interceptorType", "backend"); + backendConfig.put("backendType", backendType); if ("ldap".equals(backendType)) { backendConfig.put("url", "ldap://localhost:33389"); backendConfig.put("remoteBaseDn", "dc=hadoop,dc=apache,dc=org"); diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/backend/BackendFactoryTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/backend/BackendFactoryTest.java index fde29a087..cfb8fa2d5 100644 --- a/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/backend/BackendFactoryTest.java +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/backend/BackendFactoryTest.java @@ -54,20 +54,20 @@ public class BackendFactoryTest { @Test public void testServiceLoaderDiscovery() { - ServiceLoader<LdapBackend> loader = ServiceLoader.load(LdapBackend.class); + ServiceLoader<LdapBackendFactory> loader = ServiceLoader.load(LdapBackendFactory.class); // Should discover at least the built-in backends boolean foundFileBackend = false; boolean foundLdapBackend = false; - for (LdapBackend backend : loader) { - String backendName = backend.getName(); - if ("file".equals(backendName)) { + for (LdapBackendFactory factory : loader) { + String backendType = factory.getType(); + if ("file".equals(backendType)) { foundFileBackend = true; - assertTrue("File backend should be FileBackend instance", backend instanceof FileBackend); - } else if ("ldap".equals(backendName)) { + assertTrue("File backend should be FileBackend instance", factory instanceof FileBackendFactory); + } else if ("ldap".equals(backendType)) { foundLdapBackend = true; - assertTrue("LDAP backend should be LdapProxyBackend instance", backend instanceof LdapProxyBackend); + assertTrue("LDAP backend should be LdapProxyBackend instance", factory instanceof LdapProxyBackendFactory); } } @@ -77,48 +77,57 @@ public class BackendFactoryTest { @Test public void testCreateFileBackend() throws Exception { - LdapBackend fileBackend = BackendFactory.createBackend("file", config); + config.put("backendType", "file"); + LdapBackend fileBackend = BackendFactory.createBackend("testbackend", config); assertNotNull("File backend should be created", fileBackend); assertTrue("Should create FileBackend instance", fileBackend instanceof FileBackend); - assertEquals("Backend name should be 'file'", "file", fileBackend.getName()); + assertEquals("Backend type should be 'file'", "file", fileBackend.getType()); + assertEquals("Backend name should be 'testbackend'", "testbackend", fileBackend.getName()); } @Test public void testCreateLdapBackend() throws Exception { + config.put("backendType", "ldap"); config.put("url", "ldap://localhost:389"); config.put("remoteBaseDn", "dc=hadoop,dc=apache,dc=org"); - LdapBackend ldapBackend = BackendFactory.createBackend("ldap", config); + LdapBackend ldapBackend = BackendFactory.createBackend("testbackend", config); assertNotNull("LDAP backend should be created", ldapBackend); assertTrue("Should create LdapProxyBackend instance", ldapBackend instanceof LdapProxyBackend); - assertEquals("Backend name should be 'ldap'", "ldap", ldapBackend.getName()); + assertEquals("Backend type should be 'ldap'", "ldap", ldapBackend.getType()); + assertEquals("Backend name should be 'testbackend'", "testbackend", ldapBackend.getName()); } @Test - public void testCaseInsensitiveBackendNames() throws Exception { + public void testCaseInsensitiveFileBackend() throws Exception { // Test uppercase - LdapBackend upperCaseBackend = BackendFactory.createBackend("FILE", config); - assertTrue("Should create FileBackend with uppercase name", upperCaseBackend instanceof FileBackend); + config.put("backendType", "FILE"); + LdapBackend upperCaseBackend = BackendFactory.createBackend("UPPER", config); + assertTrue("Should create FileBackend with uppercase type", upperCaseBackend instanceof FileBackend); // Test mixed case - LdapBackend mixedCaseBackend = BackendFactory.createBackend("File", config); - assertTrue("Should create FileBackend with mixed case name", mixedCaseBackend instanceof FileBackend); + config.put("backendType", "File"); + LdapBackend mixedCaseBackend = BackendFactory.createBackend("Mixed", config); + assertTrue("Should create FileBackend with mixed case type", mixedCaseBackend instanceof FileBackend); } @Test(expected = IllegalArgumentException.class) public void testUnknownBackendThrowsException() throws Exception { - BackendFactory.createBackend("unknown", config); + config.put("backendType", "unknown"); + BackendFactory.createBackend("testbackend", config); } @Test(expected = IllegalArgumentException.class) public void testNullBackendNameThrowsException() throws Exception { - BackendFactory.createBackend(null, config); + config.put("backendType", null); + BackendFactory.createBackend("testbackend", config); } @Test(expected = IllegalArgumentException.class) public void testEmptyBackendNameThrowsException() throws Exception { - BackendFactory.createBackend("", config); + config.put("backendType", ""); + BackendFactory.createBackend("testbackend", config); } } \ No newline at end of file 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 0aba49487..85152cea0 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 @@ -39,7 +39,6 @@ import org.apache.knox.gateway.security.ldap.SimpleDirectoryService; import org.apache.knox.gateway.services.ldap.SchemaManagerFactory; import org.junit.After; import org.junit.AfterClass; -import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; @@ -137,20 +136,17 @@ public class LdapProxyBackendTest { } } - @Before - public void setUp() throws Exception { - ldapProxyBackend = new LdapProxyBackend(); - } - @After public void tearDown() throws Exception { - ldapProxyBackend.close(); + if (ldapProxyBackend != null) { + ldapProxyBackend.close(); + } } @Test public void testGetUserByDefaultUserSearchFilter() throws Exception { // default searches by uid and uses group search for membership - ldapProxyBackend.initialize(ldapBackendConfig); + ldapProxyBackend = new LdapProxyBackend("testbackend", ldapBackendConfig); Entry entry = ldapProxyBackend.getUser("ldaptest1", schemaManager); validateUserEntry(entry, "ldaptest1", "TestCn1", "[email protected]", "Test user ldaptest1"); @@ -161,7 +157,7 @@ public class LdapProxyBackendTest { @Test public void testGetUserNotFound() throws Exception { - ldapProxyBackend.initialize(ldapBackendConfig); + ldapProxyBackend = new LdapProxyBackend("testbackend", ldapBackendConfig); Entry entry = ldapProxyBackend.getUser("nouser", schemaManager); assertNull(entry); @@ -170,7 +166,7 @@ public class LdapProxyBackendTest { @Test public void testGetUserByUID() throws Exception { Map<String, String> config = createConfigWithUserAttr("uid"); - ldapProxyBackend.initialize(config); + ldapProxyBackend = new LdapProxyBackend("testbackend", config); Entry entry = ldapProxyBackend.getUser("ldaptest1", schemaManager); validateUserEntry(entry, "ldaptest1", "TestCn1", "[email protected]", "Test user ldaptest1"); @@ -182,7 +178,7 @@ public class LdapProxyBackendTest { @Test public void testGetUserByCN() throws Exception { Map<String, String> config = createConfigWithUserAttr("cn"); - ldapProxyBackend.initialize(config); + ldapProxyBackend = new LdapProxyBackend("testbackend", config); Entry entry = ldapProxyBackend.getUser("TestCn1", schemaManager); validateUserEntry(entry, "TestCn1", "TestCn1", "[email protected]", "Test user ldaptest1"); @@ -194,7 +190,7 @@ public class LdapProxyBackendTest { @Test public void testGetUserBySAMAccountName() throws Exception { Map<String, String> config = createConfigWithUserAttr("sAMAccountName"); - ldapProxyBackend.initialize(config); + ldapProxyBackend = new LdapProxyBackend("testbackend", config); Entry entry = ldapProxyBackend.getUser("TestSam1", schemaManager); validateUserEntry(entry, "TestSam1", "TestCn1", "[email protected]", "Test user ldaptest1"); @@ -208,7 +204,7 @@ public class LdapProxyBackendTest { public void testGetUserUseMemberOf() throws Exception { Map<String, String> config = new HashMap<>(ldapBackendConfig); config.put("useMemberOf", "true"); - ldapProxyBackend.initialize(config); + ldapProxyBackend = new LdapProxyBackend("testbackend", config); Entry entry = ldapProxyBackend.getUser("ldaptest2", schemaManager); validateUserEntry(entry, "ldaptest2", "TestCn2", "[email protected]", "Test user ldaptest2"); @@ -219,7 +215,7 @@ public class LdapProxyBackendTest { @Test public void testGetUserGroups() throws Exception { - ldapProxyBackend.initialize(ldapBackendConfig); + ldapProxyBackend = new LdapProxyBackend("testbackend", ldapBackendConfig); List<String> userGroups = ldapProxyBackend.getUserGroups("ldaptest1"); assertTrue(userGroups.contains("group1")); @@ -228,7 +224,7 @@ public class LdapProxyBackendTest { @Test public void testGetUserGroupsNoGroups() throws Exception { - ldapProxyBackend.initialize(ldapBackendConfig); + ldapProxyBackend = new LdapProxyBackend("testbackend", ldapBackendConfig); List<String> userGroups = ldapProxyBackend.getUserGroups("ldaptest2"); assertTrue(userGroups.isEmpty()); @@ -236,7 +232,7 @@ public class LdapProxyBackendTest { @Test public void testGetUserGroupsNoUser() throws Exception { - ldapProxyBackend.initialize(ldapBackendConfig); + ldapProxyBackend = new LdapProxyBackend("testbackend", ldapBackendConfig); List<String> userGroups = ldapProxyBackend.getUserGroups("nobody"); assertTrue(userGroups.isEmpty()); @@ -246,7 +242,7 @@ public class LdapProxyBackendTest { public void testGetUserGroupsUseMemberOf() throws Exception { Map<String, String> config = new HashMap<>(ldapBackendConfig); config.put("useMemberOf", "true"); - ldapProxyBackend.initialize(config); + ldapProxyBackend = new LdapProxyBackend("testbackend", config); List<String> userGroups = ldapProxyBackend.getUserGroups("ldaptest2"); assertTrue(userGroups.contains("groupMemberOf1")); @@ -257,7 +253,7 @@ public class LdapProxyBackendTest { public void testGetUserGroupsUseMemberOfNoGroups() throws Exception { Map<String, String> config = new HashMap<>(ldapBackendConfig); config.put("useMemberOf", "true"); - ldapProxyBackend.initialize(config); + ldapProxyBackend = new LdapProxyBackend("testbackend", config); List<String> userGroups = ldapProxyBackend.getUserGroups("ldaptest1"); assertTrue(userGroups.isEmpty()); @@ -267,7 +263,7 @@ public class LdapProxyBackendTest { public void testGetUserGroupsUseMemberOfNoUser() throws Exception { Map<String, String> config = new HashMap<>(ldapBackendConfig); config.put("useMemberOf", "true"); - ldapProxyBackend.initialize(config); + ldapProxyBackend = new LdapProxyBackend("testbackend", config); List<String> userGroups = ldapProxyBackend.getUserGroups("nobody"); assertTrue(userGroups.isEmpty()); @@ -275,19 +271,19 @@ public class LdapProxyBackendTest { @Test public void testSearchUsers() throws Exception { - ldapProxyBackend.initialize(ldapBackendConfig); + ldapProxyBackend = new LdapProxyBackend("testbackend", ldapBackendConfig); validateUserSearch("*", 3, Set.of("ldaptest1", "ldaptest2", "guest")); } @Test public void testSearchUsersPartial() throws Exception { - ldapProxyBackend.initialize(ldapBackendConfig); + ldapProxyBackend = new LdapProxyBackend("testbackend", ldapBackendConfig); validateUserSearch("ldap*", 2, Set.of("ldaptest1", "ldaptest2")); } @Test public void testSearchUsersNoneFound() throws Exception { - ldapProxyBackend.initialize(ldapBackendConfig); + ldapProxyBackend = new LdapProxyBackend("testbackend", ldapBackendConfig); List<Entry> entries = ldapProxyBackend.searchUsers("nobody*", schemaManager); assertTrue(entries.isEmpty()); } @@ -295,21 +291,21 @@ public class LdapProxyBackendTest { @Test public void testSearchUsersByCn() throws Exception { Map<String, String> config = createConfigWithUserAttr("cn"); - ldapProxyBackend.initialize(config); + ldapProxyBackend = new LdapProxyBackend("testbackend", config); validateUserSearch("*", 3, Set.of("TestCn1", "TestCn2", "Guest")); } @Test public void testSearchUsersPartialByCn() throws Exception { Map<String, String> config = createConfigWithUserAttr("cn"); - ldapProxyBackend.initialize(config); + ldapProxyBackend = new LdapProxyBackend("testbackend", config); validateUserSearch("TestCn*", 2, Set.of("TestCn1", "TestCn2")); } @Test public void testSearchUsersNoneFoundByCn() throws Exception { Map<String, String> config = createConfigWithUserAttr("cn"); - ldapProxyBackend.initialize(config); + ldapProxyBackend = new LdapProxyBackend("testbackend", config); List<Entry> entries = ldapProxyBackend.searchUsers("nobody*", schemaManager); assertTrue(entries.isEmpty()); } @@ -317,21 +313,21 @@ public class LdapProxyBackendTest { @Test public void testSearchUsersBySAMAccountName() throws Exception { Map<String, String> config = createConfigWithUserAttr("sAMAccountName"); - ldapProxyBackend.initialize(config); + ldapProxyBackend = new LdapProxyBackend("testbackend", config); validateUserSearch("*", 2, Set.of("TestSam1", "TestSam2")); } @Test public void testSearchUsersPartialBySAMAccountName() throws Exception { Map<String, String> config = createConfigWithUserAttr("sAMAccountName"); - ldapProxyBackend.initialize(config); + ldapProxyBackend = new LdapProxyBackend("testbackend", config); validateUserSearch("TestSam*", 2, Set.of("TestSam1", "TestSam2")); } @Test public void testSearchUsersNoneFoundBySAMAccountName() throws Exception { Map<String, String> config = createConfigWithUserAttr("sAMAccountName"); - ldapProxyBackend.initialize(config); + ldapProxyBackend = new LdapProxyBackend("testbackend", config); List<Entry> entries = ldapProxyBackend.searchUsers("nobody*", schemaManager); assertTrue(entries.isEmpty()); } @@ -339,7 +335,7 @@ public class LdapProxyBackendTest { @Test public void testGetRecursiveUserGroupsDepth2() throws Exception { Map<String, String> config = createRecursiveConfig(2); - ldapProxyBackend.initialize(config); + ldapProxyBackend = new LdapProxyBackend("testbackend", config); List<String> userGroups = ldapProxyBackend.getUserGroups("recursiveUser"); assertEquals(4, userGroups.size()); @@ -352,7 +348,7 @@ public class LdapProxyBackendTest { @Test public void testGetRecursiveUserGroupsDepth4() throws Exception { Map<String, String> config = createRecursiveConfig(4); - ldapProxyBackend.initialize(config); + ldapProxyBackend = new LdapProxyBackend("testbackend", config); List<String> userGroups = ldapProxyBackend.getUserGroups("recursiveUser"); assertEquals(6, userGroups.size()); @@ -367,7 +363,7 @@ public class LdapProxyBackendTest { @Test public void testGetRecursiveUserGroupsWithCycle() throws Exception { Map<String, String> config = createRecursiveConfig(10); - ldapProxyBackend.initialize(config); + ldapProxyBackend = new LdapProxyBackend("testbackend", config); List<String> userGroups = ldapProxyBackend.getUserGroups("recursiveUser"); assertTrue(userGroups.contains("cycleGroupA")); @@ -377,7 +373,7 @@ public class LdapProxyBackendTest { @Test public void testGetUserRecursiveGroups() throws Exception { Map<String, String> config = createRecursiveConfig(5); - ldapProxyBackend.initialize(config); + ldapProxyBackend = new LdapProxyBackend("testbackend", config); Entry entry = ldapProxyBackend.getUser("recursiveUser", schemaManager); validateMemberOf(entry, Set.of( @@ -394,7 +390,7 @@ public class LdapProxyBackendTest { Map<String, String> config = createRecursiveConfig(5); final AtomicInteger cacheHits = new AtomicInteger(0); - ldapProxyBackend = new LdapProxyBackend() { + ldapProxyBackend = new LdapProxyBackend("testbackend", config) { @Override protected Map<String, Set<Entry>> createResolvedParentsCache() { return new HashMap<>() { @@ -408,7 +404,6 @@ public class LdapProxyBackendTest { }; } }; - ldapProxyBackend.initialize(config); // Search for all recursive users (recursiveUser and recursiveUser2) // They share level1Group, cycleGroupA, and all their ancestors. diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/interceptor/DuplicateUserFilteringInterceptorTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/interceptor/DuplicateUserFilteringInterceptorTest.java new file mode 100644 index 000000000..828937074 --- /dev/null +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/interceptor/DuplicateUserFilteringInterceptorTest.java @@ -0,0 +1,166 @@ +/* + * 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.interceptor; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.apache.directory.api.ldap.model.cursor.ListCursor; +import org.apache.directory.api.ldap.model.entry.Attribute; +import org.apache.directory.api.ldap.model.entry.DefaultEntry; +import org.apache.directory.api.ldap.model.entry.Entry; +import org.apache.directory.api.ldap.model.entry.Value; +import org.apache.directory.api.ldap.model.exception.LdapException; +import org.apache.directory.api.ldap.model.schema.SchemaManager; +import org.apache.directory.server.core.api.CoreSession; +import org.apache.directory.server.core.api.DirectoryService; +import org.apache.directory.server.core.api.filtering.EntryFilteringCursor; +import org.apache.directory.server.core.api.filtering.EntryFilteringCursorImpl; +import org.apache.directory.server.core.api.interceptor.BaseInterceptor; +import org.apache.directory.server.core.api.interceptor.context.SearchOperationContext; +import org.apache.knox.gateway.security.ldap.SimpleDirectoryService; +import org.apache.knox.gateway.services.ldap.SchemaManagerFactory; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.List; + +/** + * Unit tests for DuplicateUserFilteringInterceptor. + */ +public class DuplicateUserFilteringInterceptorTest { + + private static final String TEST_INTERCEPTOR = "TEST"; + private static final String NEXT_INTERCEPTOR = "NEXT"; + + private DuplicateUserFilteringInterceptor interceptor; + + private DirectoryService directoryService; + private SchemaManager schemaManager; + private ConfigurableEntriesTestInterceptor nextInterceptor; + private SearchOperationContext ctx; + private CoreSession session; + + @Before + public void setUp() throws Exception { + directoryService = new SimpleDirectoryService(); + directoryService.setShutdownHookEnabled(false); + schemaManager = SchemaManagerFactory.createSchemaManager(); + directoryService.setSchemaManager(schemaManager); + + interceptor = new DuplicateUserFilteringInterceptor(TEST_INTERCEPTOR); + interceptor.init(directoryService); + directoryService.addLast(interceptor); + + nextInterceptor = new ConfigurableEntriesTestInterceptor(NEXT_INTERCEPTOR); + nextInterceptor.init(directoryService); + directoryService.addLast(nextInterceptor); + + session = directoryService.getSession(); + + ctx = new SearchOperationContext(session); + ctx.setInterceptors(List.of(TEST_INTERCEPTOR, NEXT_INTERCEPTOR)); + + } + + @After + public void tearDown() throws Exception { + directoryService.shutdown(); + } + + @Test + public void testEmptyCursor() throws Exception { + nextInterceptor.setEntries(List.of()); + + try (EntryFilteringCursor results = interceptor.search(ctx)) { + assertFalse("Results should be empty", results.next()); + } + + EntryFilteringCursor nextInterceptorCursor = nextInterceptor.getCursor(); + assertTrue("Cursor must be closed", nextInterceptorCursor.isClosed()); + } + + @Test + public void testNoDuplicateEntries() throws Exception { + Entry entry1 = new DefaultEntry(schemaManager); + entry1.add("uid", "user1"); + Entry entry2 = new DefaultEntry(schemaManager); + entry2.add("uid", "user2"); + nextInterceptor.setEntries(List.of(entry1, entry2)); + + try (EntryFilteringCursor results = interceptor.search(ctx)) { + assertNextEntryUid(results, "user1"); + assertNextEntryUid(results, "user2"); + assertFalse("No more entries expected", results.next()); + } + + EntryFilteringCursor nextInterceptorCursor = nextInterceptor.getCursor(); + assertTrue("Cursor must be closed", nextInterceptorCursor.isClosed()); + } + + @Test + public void testDuplicateEntries() throws Exception { + Entry entry1 = new DefaultEntry(schemaManager); + entry1.add("uid", "user1"); + Entry entry2 = new DefaultEntry(schemaManager); + entry2.add("uid", "user1"); + nextInterceptor.setEntries(List.of(entry1, entry2)); + + try (EntryFilteringCursor results = interceptor.search(ctx)) { + assertNextEntryUid(results, "user1"); + assertFalse("No more entries expected", results.next()); + } + + EntryFilteringCursor nextInterceptorCursor = nextInterceptor.getCursor(); + assertTrue("Cursor must be closed", nextInterceptorCursor.isClosed()); + } + + private void assertNextEntryUid(EntryFilteringCursor cursor, String uid) throws Exception { + assertTrue("Cursor should have another entry", cursor.next()); + Entry entry = cursor.get(); + Attribute uidAttr = entry.get("uid"); + assertEquals("Attribute should have only one value", 1, uidAttr.size()); + Value value = uidAttr.get(); + assertEquals("Uid should match " + uid, uid, value.getString()); + } + + private static class ConfigurableEntriesTestInterceptor extends BaseInterceptor { + private List<Entry> entries; + private EntryFilteringCursor cursor; + + ConfigurableEntriesTestInterceptor(String name) { + super(name); + } + + public void setEntries(List<Entry> entries) { + this.entries = entries; + } + + public EntryFilteringCursor getCursor() { + return cursor; + } + + @Override + public EntryFilteringCursor search(SearchOperationContext searchContext) throws LdapException { + cursor = new EntryFilteringCursorImpl(new ListCursor<>(entries), searchContext, schemaManager); + return cursor; + } + } +} \ No newline at end of file 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 80ab4e0c8..745002ae3 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 @@ -1246,8 +1246,8 @@ public class GatewayTestConfig extends Configuration implements GatewayConfig { } @Override - public String getLDAPBackendType() { - return "file"; + public List<String> getLDAPInterceptorNames() { + return List.of("testinterceptor"); } @Override @@ -1261,7 +1261,7 @@ public class GatewayTestConfig extends Configuration implements GatewayConfig { } @Override - public Map<String, String> getLDAPBackendConfig(String backendType) { + public Map<String, String> getLDAPInterceptorConfig(String backendType) { return Collections.emptyMap(); } 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 6116a3d4c..7ecb2980d 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 @@ -128,7 +128,7 @@ public interface GatewayConfig { String LDAP_ENABLED = "gateway.ldap.enabled"; String LDAP_PORT = "gateway.ldap.port"; String LDAP_BASE_DN = "gateway.ldap.base.dn"; - String LDAP_BACKEND_TYPE = "gateway.ldap.backend.type"; + String LDAP_INTERCEPTOR_NAMES = "gateway.ldap.interceptor.names"; String LDAP_BACKEND_DATA_FILE = "gateway.ldap.backend.data.file"; String LDAP_RECURSIVE_GROUP_RESOLUTION = "gateway.ldap.recursive.group.resolution"; String LDAP_RECURSIVE_GROUP_RESOLUTION_MAX_DEPTH = "gateway.ldap.recursive.group.resolution.max.depth"; @@ -1069,10 +1069,10 @@ public interface GatewayConfig { */ String getLDAPBaseDN(); - /** - * @return the backend type for LDAP (file, ldap, jdbc, etc.) - */ - String getLDAPBackendType(); + /** + * @return the list of interceptor names for LDAP server + */ + List<String> getLDAPInterceptorNames(); /** * @return the path to the data file for file-based backend @@ -1080,14 +1080,14 @@ public interface GatewayConfig { String getLDAPBackendDataFile(); /** - * Get backend-specific configuration properties. - * Returns all properties with prefix "gateway.ldap.backend.{backendType}." + * Get interceptor-specific configuration properties. + * Returns all properties with prefix "gateway.ldap.interceptor.{interceptorName}." * with the prefix stripped from the keys. * - * @param backendType the backend type (e.g., "file", "ldap", "database") + * @param interceptor the interceptor name * @return map of configuration key-value pairs for the specified backend */ - Map<String, String> getLDAPBackendConfig(String backendType); + Map<String, String> getLDAPInterceptorConfig(String interceptor); /** * @return true if recursive group resolution is enabled for LDAP service diff --git a/knox-site/docs/service_ldap_server.md b/knox-site/docs/service_ldap_server.md index 51080f69a..1ad0f1ab0 100644 --- a/knox-site/docs/service_ldap_server.md +++ b/knox-site/docs/service_ldap_server.md @@ -7,7 +7,8 @@ The Knox LDAP Service provides an embedded LDAP server within the Knox Gateway. Introduced in [KNOX-3247](https://issues.apache.org/jira/browse/KNOX-3247), the Knox LDAP Service leverages Apache Directory Server (ApacheDS) to provide a lightweight, embedded LDAP server. Its primary goal is to provide a consistent LDAP interface for authentication and group lookups, even when the underlying identity store is not a traditional LDAP server or is a remote server that requires proxying. Key features include: -- **Pluggable Backends**: Support for different data sources (JSON files, remote LDAP/AD). +- **Pluggable LDAP Interceptors**: Support for customization of LDAP search behavior including combining results from multiple LDAP backends. +- **Pluggable Backends**: Support for multiple, different data sources (JSON files, remote LDAP/AD). - **Embedded Server**: No need for an external LDAP server for simple use cases or testing. - **Active Directory Integration**: Optimized for proxying to AD with support for `sAMAccountName`. @@ -16,13 +17,13 @@ Key features include: The Knox LDAP Service is integrated as a core gateway service. It consists of the following components: 1. **KnoxLDAPServerManager**: Manages the lifecycle of the ApacheDS instance. -2. **GroupLookupInterceptor**: A custom ApacheDS interceptor that captures search requests. If an entry is not found in the local ApacheDS partitions, it delegates the lookup to the configured backend. -3. **LdapBackend**: A pluggable interface for fetching user and group data. +2. **UserLookupInterceptor**: A custom ApacheDS interceptor that captures search requests. If an entry is not found in the local ApacheDS partitions, it delegates the lookup to the configured backend. +3. **LdapBackend**: A pluggable interface for fetching user and group data. Implementations are included for reading users from a file or connecting to a remote LDAP server. 4. **SchemaManagerFactory**: Programmatically extends the ApacheDS schema to include AD-specific attributes like `sAMAccountName`. When a client performs an LDAP search: 1. The request hits the embedded ApacheDS server. -2. The `GroupLookupInterceptor` intercepts the search. +2. The `UserLookupInterceptor` intercepts the search. 3. The interceptor checks the results of the local search. 4. If not found, it queries the configured `LdapBackend`. 5. Results from the backend are converted into LDAP entries and returned to the client. @@ -38,17 +39,39 @@ The service is configured in `gateway-site.xml`. | `gateway.ldap.enabled` | `false` | Enables or disables the embedded LDAP service. | | `gateway.ldap.port` | `3890` | The port on which the LDAP server listens. | | `gateway.ldap.base.dn` | `dc=proxy,dc=com` | The base DN for the LDAP server. | -| `gateway.ldap.backend.type` | `file` | The type of backend to use (`file` or `proxy`). | +| `gateway.ldap.interceptor.names` | N/A | A comma separated list of interceptors to use. A separate interceptor configuration block will be used for each name. | + +### Interceptor Types + +#### Common Interceptor Properties + +| Property | Default Value | Description | +| :--- | :--- | :--- | +| `gateway.ldap.interceptor.<name>.interceptorType` | N/A | The type of interceptor to use (`backend` or `duplicateuserfilter`). | + +#### Duplicate User Filter Interceptor (`backend`) + +The duplicate user filter interceptor ensures that each `Entry` has a unique `uid`. This interceptor removes entries that have a duplicate `uid`. + +#### User Search Interceptor (`duplicateuserfilter`) + +The user search interceptor is created if the `interceptorType` configuration is set to `backend`. This interceptor forwards search queries to its configured backend. ### Backend Types +#### Common Backend Properties + +| Property | Default Value | Description | +| :--- | :--- | :--- | +| `gateway.ldap.interceptor.<name>.backendType` | N/A | The type of backend to use (`file` or `ldap`). | + #### File Backend (`file`) The file backend reads user and group definitions from a JSON file. This is ideal for testing or small, static environments. | Property | Default Value | Description | | :--- | :--- | :--- | -| `gateway.ldap.backend.data.file` | `${GATEWAY_DATA_HOME}/ldap-users.json` | Path to the JSON data file. | +| `gateway.ldap.interceptor.<name>.file` | `${GATEWAY_DATA_HOME}/ldap-users.json` | Path to the JSON data file. | **JSON File Format Example:** ```json @@ -67,24 +90,24 @@ The file backend reads user and group definitions from a JSON file. This is idea } ``` -#### Proxy Backend (`proxy`) +#### LDAP Proxy Backend (`ldap`) The proxy backend delegates lookups to a remote LDAP or Active Directory server. | Property | Default Value | Description | | :--- | :--- | :--- | -| `gateway.ldap.backend.proxy.url` | N/A | Remote LDAP URL (e.g., `ldap://remote-host:389`). | -| `gateway.ldap.backend.proxy.host` | N/A | Host of remote LDAP (used if `url` is not provided). | -| `gateway.ldap.backend.proxy.port` | N/A | Port of remote LDAP (used if `url` is not provided). | -| `gateway.ldap.backend.proxy.remoteBaseDn` | N/A | **Required**. The base DN of the remote LDAP server. | -| `gateway.ldap.backend.proxy.systemUsername` | N/A | Bind DN for the remote server (alias: `bindDn`). | -| `gateway.ldap.backend.proxy.systemPassword` | N/A | Password for the bind DN (alias: `bindPassword`). | -| `gateway.ldap.backend.proxy.userSearchBase` | `ou=people,{remoteBaseDn}` | Base DN for user searches on the remote server. | -| `gateway.ldap.backend.proxy.groupSearchBase` | `ou=groups,{remoteBaseDn}` | Base DN for group searches on the remote server. | -| `gateway.ldap.backend.proxy.userIdentifierAttribute` | `uid` | Attribute used for user lookup (e.g., `sAMAccountName` for AD). | -| `gateway.ldap.backend.proxy.groupMemberAttribute` | `memberUid` | Attribute used for group membership (e.g., `member` for AD). | -| `gateway.ldap.backend.proxy.useMemberOf` | `false` | If `true`, use the `memberOf` attribute for efficient group lookups. | -| `gateway.ldap.backend.proxy.poolMaxActive` | `8` | Maximum number of active connections in the pool. | +| `gateway.ldap.interceptor.<name>.url` | N/A | Remote LDAP URL (e.g., `ldap://remote-host:389`). | +| `gateway.ldap.interceptor.<name>.host` | N/A | Host of remote LDAP (used if `url` is not provided). | +| `gateway.ldap.interceptor.<name>.port` | N/A | Port of remote LDAP (used if `url` is not provided). | +| `gateway.ldap.interceptor.<name>.remoteBaseDn` | N/A | **Required**. The base DN of the remote LDAP server. | +| `gateway.ldap.interceptor.<name>.systemUsername` | N/A | Bind DN for the remote server (alias: `bindDn`). | +| `gateway.ldap.interceptor.<name>.systemPassword` | N/A | Password for the bind DN (alias: `bindPassword`). | +| `gateway.ldap.interceptor.<name>.userSearchBase` | `ou=people,{remoteBaseDn}` | Base DN for user searches on the remote server. | +| `gateway.ldap.interceptor.<name>.groupSearchBase` | `ou=groups,{remoteBaseDn}` | Base DN for group searches on the remote server. | +| `gateway.ldap.interceptor.<name>.userIdentifierAttribute` | `uid` | Attribute used for user lookup (e.g., `sAMAccountName` for AD). | +| `gateway.ldap.interceptor.<name>.groupMemberAttribute` | `memberUid` | Attribute used for group membership (e.g., `member` for AD). | +| `gateway.ldap.interceptor.<name>.useMemberOf` | `false` | If `true`, use the `memberOf` attribute for efficient group lookups. | +| `gateway.interceptor.<name>.proxy.poolMaxActive` | `8` | Maximum number of active connections in the pool. | ## Active Directory (AD) Integration @@ -95,11 +118,12 @@ The Knox LDAP Service includes several optimizations for working with Active Dir - **Schema Extensions**: AD-specific attributes (`memberOf`, `sAMAccountName`) are programmatically added to the embedded ApacheDS schema to prevent "attribute not found" errors during proxying. - **Case Sensitivity**: Search filters are handled to accommodate AD's case-insensitive nature for user identifiers. -## Usage Example (AD Proxy) +## Usage Example -To configure Knox to act as an LDAP proxy for an Active Directory server: +To configure Knox to act as an LDAP proxy for a local file and an Active Directory server: ```xml +<!-- LDAP Proxy Service Configuration --> <property> <name>gateway.ldap.enabled</name> <value>true</value> @@ -109,37 +133,74 @@ To configure Knox to act as an LDAP proxy for an Active Directory server: <value>389</value> </property> <property> - <name>gateway.ldap.backend.type</name> - <value>proxy</value> + <name>gateway.ldap.base.dn</name> + <value>dc=proxy,dc=com</value> +</property> + +<property> + <name>gateway.ldap.interceptor.names</name> + <value>localfile,adexample,duplicatefilter</value> +</property> + +<!-- File-based LDAP backend --> +<property> + <name>gateway.ldap.interceptor.localfile.interceptorType</name> + <value>backend</value> +</property> +<property> + <name>gateway.ldap.interceptor.localfile.backendType</name> + <value>file</value> +</property> +<property> + <name>gateway.ldap.interceptor.localfile.file</name> + <value>${GATEWAY_DATA_HOME}/ldap-users.json</value> +</property> + +<!-- LDAP backend proxy configuration --> +<property> + <name>gateway.ldap.interceptor.adexample.interceptorType</name> + <value>backend</value> +</property> +<property> + <name>gateway.ldap.interceptor.adexample.backendType</name> + <value>ldap</value> </property> <property> - <name>gateway.ldap.backend.proxy.url</name> + <name>gateway.ldap.interceptor.adexample.url</name> <value>ldap://ad.example.com:389</value> </property> <property> - <name>gateway.ldap.backend.proxy.remoteBaseDn</name> + <name>gateway.ldap.interceptor.adexample.remoteBaseDn</name> <value>dc=example,dc=com</value> </property> <property> - <name>gateway.ldap.backend.proxy.systemUsername</name> - <value>CN=ReadOnlyUser,CN=Users,DC=example,DC=com</value> + <name>gateway.ldap.interceptor.adexample.systemUsername</name> + <value>cn=ReadOnlyUser,cn=Users,dc=example,dc=com</value> </property> <property> - <name>gateway.ldap.backend.proxy.systemPassword</name> - <value>secret-password</value> + <name>gateway.ldap.interceptor.adexample.systemPassword</name> + <value>guest-password</value> </property> <property> - <name>gateway.ldap.backend.proxy.userIdentifierAttribute</name> + <name>gateway.ldap.interceptor.adexample.userIdentifierAttribute</name> <value>sAMAccountName</value> </property> <property> - <name>gateway.ldap.backend.proxy.groupMemberAttribute</name> + <name>gateway.ldap.interceptor.adexample.groupMemberAttribute</name> <value>member</value> </property> <property> - <name>gateway.ldap.backend.proxy.useMemberOf</name> + <name>gateway.ldap.interceptor.adexample.useMemberOf</name> <value>true</value> </property> + +<!-- Duplicate Filter Interceptor --> +<property> + <name>gateway.ldap.interceptor.duplicatefilter.interceptorType</name> + <value>duplicateuserfilter</value> +</property> + +<!-- End LDAP Proxy Service Configuration --> ``` ## Troubleshooting
