diff --git a/gateway-server/pom.xml b/gateway-server/pom.xml index 75ca1dfe51..e50154095c 100644 --- a/gateway-server/pom.xml +++ b/gateway-server/pom.xml @@ -233,6 +233,10 @@ org.apache.commons commons-lang3 + + org.apache.commons + commons-pool2 + org.apache.commons commons-text @@ -465,6 +469,56 @@ com.google.guava guava + + com.google.code.gson + gson + + + + org.apache.directory.server + apacheds-core + provided + + + org.apache.directory.server + apacheds-core-api + provided + + + org.apache.directory.server + apacheds-protocol-shared + provided + + + org.apache.directory.server + apacheds-protocol-ldap + provided + + + org.apache.directory.server + apacheds-ldif-partition + provided + + + org.apache.directory.server + apacheds-jdbm-partition + provided + + + org.apache.directory.api + api-ldap-model + provided + + + org.apache.directory.api + api-ldap-schema-data + provided + + + org.apache.directory.api + api-ldap-client-api + provided + diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/GatewayMessages.java b/gateway-server/src/main/java/org/apache/knox/gateway/GatewayMessages.java index dc133cfdf5..7e82408797 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/GatewayMessages.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/GatewayMessages.java @@ -812,4 +812,17 @@ void failedToDiscoverClusterServices(String clusterName, String topologyName, @Message( level = MessageLevel.DEBUG, text = "Strict-Transport-Security header enabled with \"{0}\" option" ) void strictTransportHeaderEnabled(String option); + + // LDAP Service messages + @Message(level = MessageLevel.INFO, text = "LDAP service is enabled and will be started on port {0}") + void ldapServiceEnabled(int port); + + @Message(level = MessageLevel.INFO, text = "LDAP service is disabled") + void ldapServiceDisabled(); + + @Message(level = MessageLevel.ERROR, text = "Failed to start LDAP service: {0}") + void ldapServiceStartFailed(@StackTrace(level = MessageLevel.DEBUG) Exception e); + + @Message(level = MessageLevel.ERROR, text = "LDAP service not found or not properly registered") + void ldapServiceNotFound(); } 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 495d826460..da6b92e9bc 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 @@ -32,6 +32,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.Map; @@ -1700,4 +1701,65 @@ public boolean isStrictTransportEnabled() { public String getStrictTransportOption() { return get(STRICT_TRANSPORT_OPTION, DEFAULT_STRICT_TRANSPORT_OPTION); } + + // LDAP Service Configuration + @Override + public boolean isLDAPEnabled() { + return Boolean.parseBoolean(get(LDAP_ENABLED, "false")); + } + + @Override + public int getLDAPPort() { + return Integer.parseInt(get(LDAP_PORT, "3890")); + } + + @Override + public String getLDAPBaseDN() { + return get(LDAP_BASE_DN, "dc=proxy,dc=com"); + } + + @Override + public String getLDAPBackendType() { + return get(LDAP_BACKEND_TYPE, "file"); + } + + @Override + public String getLDAPBackendDataFile() { + String configuredPath = get(LDAP_BACKEND_DATA_FILE, null); + if (configuredPath != null && !configuredPath.isEmpty()) { + // Support ${GATEWAY_DATA_HOME} variable substitution + configuredPath = configuredPath.replace("${GATEWAY_DATA_HOME}", getGatewayDataDir()); + return configuredPath; + } + // Default to data directory if not configured + return getGatewayDataDir() + File.separator + "ldap-users.json"; + } + + @Override + public Set getPropertyNames() { + Set names = new HashSet<>(); + Iterator> iterator = this.iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + names.add(entry.getKey()); + } + return names; + } + + @Override + public Map getLDAPBackendConfig(String backendType) { + Map config = new HashMap<>(); + String prefix = "gateway.ldap.backend." + backendType + "."; + + for (String key : getPropertyNames()) { + if (key != null && key.startsWith(prefix)) { + String configKey = key.substring(prefix.length()); + String value = get(key); + if (value != null) { + config.put(configKey, value); + } + } + } + return config; + } } diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/DefaultGatewayServices.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/DefaultGatewayServices.java index 676202d5f6..06ce95d93a 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/DefaultGatewayServices.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/DefaultGatewayServices.java @@ -26,6 +26,7 @@ import org.apache.knox.gateway.descriptor.FilterParamDescriptor; import org.apache.knox.gateway.descriptor.ResourceDescriptor; import org.apache.knox.gateway.i18n.messages.MessagesFactory; +import org.apache.knox.gateway.services.ldap.KnoxLDAPService; import org.apache.knox.gateway.services.security.KeystoreService; import org.apache.knox.gateway.services.security.KeystoreServiceException; import org.apache.knox.gateway.topology.Provider; @@ -82,6 +83,13 @@ public void init(GatewayConfig config, Map options) throws Servic addService(ServiceType.CONCURRENT_SESSION_VERIFIER, gatewayServiceFactory.create(this, ServiceType.CONCURRENT_SESSION_VERIFIER, config, options)); addService(ServiceType.GATEWAY_STATUS_SERVICE, gatewayServiceFactory.create(this, ServiceType.GATEWAY_STATUS_SERVICE, config, options)); + + // LDAP Service - infrastructure service for embedded LDAP server + if (config.isLDAPEnabled()) { + KnoxLDAPService ldapService = new KnoxLDAPService(); + ldapService.init(config, options); + addService(ServiceType.LDAP_SERVICE, ldapService); + } } @Override 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/GroupLookupInterceptor.java new file mode 100644 index 0000000000..ea397fea76 --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/GroupLookupInterceptor.java @@ -0,0 +1,147 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.knox.gateway.services.ldap; + +import org.apache.directory.api.ldap.model.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.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.BindOperationContext; +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.backend.LdapBackend; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Interceptor for LDAP operations to proxy user searches to backend when not found locally + */ +public class GroupLookupInterceptor 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=([^)]+)\\).*"); + + public GroupLookupInterceptor(DirectoryService directoryService, LdapBackend backend) { + this.directoryService = directoryService; + this.backend = backend; + } + + @Override + public EntryFilteringCursor search(SearchOperationContext ctx) throws LdapException { + String filter = ctx.getFilter() != null ? ctx.getFilter().toString() : ""; + String baseDn = ctx.getDn() != null ? ctx.getDn().toString() : ""; + + LOG.ldapSearch(baseDn, filter); + + // First try the normal search + EntryFilteringCursor originalResults; + try { + originalResults = next(ctx); + } catch (Exception e) { + throw new LdapException(e); + } + + // Check if this is a user search and if we got no results, try the backend + if (isUserSearch(filter)) { + String username = extractUser(filter); + + // Check if we have any results from local search + List entries = new ArrayList<>(); + try { + while (originalResults.next()) { + entries.add(originalResults.get()); + } + originalResults.close(); + } catch (Exception e) { + // If we get an error or no results, try the backend + } + + // If no local results, try backend + if (entries.isEmpty() && username != null) { + try { + SchemaManager schemaManager = directoryService.getSchemaManager(); + + if (username.contains("*")) { + // Wildcard search - use searchUsers + LOG.ldapSearch(baseDn, "wildcard user search: " + username); + List 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); + } 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); + } + } + } catch (Exception e) { + LOG.ldapServiceStopFailed(e); + } + } + + // Return cursor with our results - use a simple approach + return new EntryFilteringCursorImpl(new ListCursor<>(entries), ctx, directoryService.getSchemaManager()); + } + + return originalResults; + } + + @Override + public void bind(BindOperationContext ctx) { + // Allow anonymous bind or simple bind + LOG.ldapBind(ctx.getDn() != null ? ctx.getDn().toString() : "anonymous"); + try { + next(ctx); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private boolean isUserSearch(String filter) { + return UID_PATTERN.matcher(filter).matches() || CN_PATTERN.matcher(filter).matches(); + } + + private String extractUser(String filter) { + Matcher uidMatcher = UID_PATTERN.matcher(filter); + if (uidMatcher.matches()) { + return uidMatcher.group(1); + } + + Matcher cnMatcher = CN_PATTERN.matcher(filter); + if (cnMatcher.matches()) { + return cnMatcher.group(1); + } + + return null; + } +} + 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 new file mode 100644 index 0000000000..849f8e3a96 --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManager.java @@ -0,0 +1,237 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.knox.gateway.services.ldap; + +import org.apache.directory.api.ldap.model.entry.DefaultEntry; +import org.apache.directory.api.ldap.model.entry.Entry; +import org.apache.directory.api.ldap.model.name.Dn; +import org.apache.directory.api.ldap.model.schema.SchemaManager; +import org.apache.directory.api.ldap.schema.loader.JarLdifSchemaLoader; +import org.apache.directory.api.ldap.schema.manager.impl.DefaultSchemaManager; +import org.apache.directory.server.core.DefaultDirectoryService; +import org.apache.directory.server.core.api.DirectoryService; +import org.apache.directory.server.core.api.InstanceLayout; +import org.apache.directory.server.core.api.schema.SchemaPartition; +import org.apache.directory.server.core.partition.impl.btree.jdbm.JdbmPartition; +import org.apache.directory.server.core.partition.ldif.LdifPartition; +import org.apache.directory.server.ldap.LdapServer; +import org.apache.directory.server.protocol.shared.transport.TcpTransport; +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 java.io.File; +import java.util.Map; + +/** + * Manages the ApacheDS LDAP server instance with pluggable backends + */ +public class KnoxLDAPServerManager { + private static final LdapMessages LOG = MessagesFactory.get(LdapMessages.class); + + private DirectoryService directoryService; + private LdapServer ldapServer; + private LdapBackend backend; + private File workDir; + private int port; + private String baseDn; + private String remoteBaseDn; + + /** + * Initialize the LDAP server with the given configuration + * + * @param workDir Directory for LDAP data storage + * @param port Port for LDAP server to listen on + * @param baseDn Base DN for LDAP entries in the proxy server + * @param backendType Type of backend to use + * @param backendConfig Backend-specific configuration + * @param remoteBaseDn Base DN of the remote LDAP server (for proxy backends) + */ + public void initialize(File workDir, int port, String baseDn, String backendType, Map backendConfig, String remoteBaseDn) throws Exception { + this.workDir = workDir; + this.port = port; + this.baseDn = baseDn; + this.remoteBaseDn = remoteBaseDn; + + // Initialize backend + backendConfig.put("baseDn", baseDn); + backend = BackendFactory.createBackend(backendType, backendConfig); + + // Clean up previous run if it didn't shut down cleanly + File lockFile = new File(workDir, "run/instance.lock"); + if (lockFile.exists()) { + LOG.ldapCleaningLockFile(lockFile.getAbsolutePath()); + lockFile.delete(); + } + + workDir.mkdirs(); + } + + /** + * Start the LDAP server + */ + public void start() throws Exception { + LOG.ldapServiceStarting(port, baseDn); + + // Initialize DirectoryService + directoryService = new DefaultDirectoryService(); + directoryService.setInstanceLayout(new InstanceLayout(workDir)); + + // Create and load schema manager manually + JarLdifSchemaLoader loader = new JarLdifSchemaLoader(); + SchemaManager schemaManager = new DefaultSchemaManager(loader); + schemaManager.loadAllEnabled(); + directoryService.setSchemaManager(schemaManager); + + // Initialize schema partition + LdifPartition schemaPartition = new LdifPartition(schemaManager, directoryService.getDnFactory()); + schemaPartition.setPartitionPath(new File(workDir, "schema").toURI()); + SchemaPartition schemaLdifPartition = new SchemaPartition(schemaManager); + schemaLdifPartition.setWrappedPartition(schemaPartition); + directoryService.setSchemaPartition(schemaLdifPartition); + + // Create system partition (required) + JdbmPartition systemPartition = new JdbmPartition(schemaManager, directoryService.getDnFactory()); + systemPartition.setId("system"); + systemPartition.setSuffixDn(new Dn(schemaManager, "ou=system")); + systemPartition.setPartitionPath(new File(workDir, "system").toURI()); + directoryService.setSystemPartition(systemPartition); + + // Create our custom partition for proxy base DN + JdbmPartition partition = new JdbmPartition(schemaManager, directoryService.getDnFactory()); + partition.setId("proxy"); + partition.setSuffixDn(new Dn(schemaManager, baseDn)); + 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); + } + + // Add our interceptor for group lookups + directoryService.addLast(new GroupLookupInterceptor(directoryService, backend)); + + // Allow anonymous access + directoryService.setAllowAnonymousAccess(true); + + // Start the service + directoryService.startup(); + + // Add base entries to the partition + createBaseEntries(schemaManager); + + // Create LDAP server on configured port + ldapServer = new LdapServer(); + ldapServer.setTransports(new TcpTransport(port)); + ldapServer.setDirectoryService(directoryService); + + ldapServer.start(); + + LOG.ldapServiceStarted(port); + } + + /** + * Stop the LDAP server + */ + public void stop() throws Exception { + LOG.ldapServiceStopping(port); + + if (ldapServer != null) { + try { + ldapServer.stop(); + } catch (Exception e) { + LOG.ldapServiceStopFailed(e); + } + } + + if (directoryService != null) { + try { + directoryService.shutdown(); + } catch (Exception e) { + LOG.ldapServiceStopFailed(e); + } + } + + 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 createBaseEntriesForDn(SchemaManager schemaManager, String dn) throws Exception { + Dn baseDnName = new Dn(schemaManager, dn); + if (!directoryService.getAdminSession().exists(baseDnName)) { + Entry baseDnEntry = new DefaultEntry(schemaManager); + baseDnEntry.setDn(baseDnName); + baseDnEntry.add("objectClass", "top", "domain"); + // Extract dc value from baseDn (e.g., "dc=proxy,dc=com" -> "proxy") + String dcValue = dn.split(",")[0].split("=")[1]; + baseDnEntry.add("dc", dcValue); + directoryService.getAdminSession().add(baseDnEntry); + } + + Dn usersOuDn = new Dn(schemaManager, "ou=people," + dn); + if (!directoryService.getAdminSession().exists(usersOuDn)) { + Entry usersOu = new DefaultEntry(schemaManager); + usersOu.setDn(usersOuDn); + usersOu.add("objectClass", "top", "organizationalUnit"); + usersOu.add("ou", "people"); + directoryService.getAdminSession().add(usersOu); + } + + Dn groupsOuDn = new Dn(schemaManager, "ou=groups," + dn); + if (!directoryService.getAdminSession().exists(groupsOuDn)) { + Entry groupsOu = new DefaultEntry(schemaManager); + groupsOu.setDn(groupsOuDn); + groupsOu.add("objectClass", "top", "organizationalUnit"); + groupsOu.add("ou", "groups"); + directoryService.getAdminSession().add(groupsOu); + } + } + + public int getPort() { + return port; + } + + public String getBaseDn() { + return baseDn; + } + + /** + * Check if the LDAP server is currently running. + * + * @return true if the server is running, false otherwise + */ + public boolean isRunning() { + return ldapServer != null && ldapServer.isStarted(); + } + +} diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/KnoxLDAPService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/KnoxLDAPService.java new file mode 100644 index 0000000000..1ec748da9d --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/KnoxLDAPService.java @@ -0,0 +1,130 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.knox.gateway.services.ldap; + +import org.apache.knox.gateway.config.GatewayConfig; +import org.apache.knox.gateway.i18n.messages.MessagesFactory; +import org.apache.knox.gateway.services.Service; +import org.apache.knox.gateway.services.ServiceLifecycleException; + +import java.io.File; +import java.util.Map; + +/** + * Knox LDAP Service - provides an embedded LDAP server with pluggable backends + * for user and group lookups. + */ +public class KnoxLDAPService implements Service { + private static final LdapMessages LOG = MessagesFactory.get(LdapMessages.class); + + private KnoxLDAPServerManager ldapServerManager; + private boolean enabled; + + @Override + public void init(GatewayConfig config, Map options) throws ServiceLifecycleException { + this.enabled = config.isLDAPEnabled(); + + if (!enabled) { + return; + } + + try { + // Initialize the LDAP server manager with configuration + ldapServerManager = new KnoxLDAPServerManager(); + + // Prepare work directory for LDAP data + File gatewayDataDir = new File(config.getGatewayDataDir()); + File ldapWorkDir = new File(gatewayDataDir, "ldap-server"); + + // Get configuration + int port = config.getLDAPPort(); + String baseDn = config.getLDAPBaseDN(); + String backendType = config.getLDAPBackendType(); + + // Get backend-specific configuration using prefixed properties + Map 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()); + } + + // For proxy backends, extract remoteBaseDn if present + String remoteBaseDn = backendConfig.get("remoteBaseDn"); + + // Initialize but don't start yet + ldapServerManager.initialize(ldapWorkDir, port, baseDn, backendType, backendConfig, remoteBaseDn); + + } catch (Exception e) { + throw new ServiceLifecycleException("Failed to initialize LDAP service", e); + } + } + + @Override + public void start() throws ServiceLifecycleException { + if (!enabled) { + return; + } + + try { + // Start the LDAP server + ldapServerManager.start(); + } catch (Exception e) { + LOG.ldapServiceStartFailed(e); + throw new ServiceLifecycleException("Failed to start LDAP service", e); + } + } + + @Override + public void stop() throws ServiceLifecycleException { + if (!enabled || ldapServerManager == null) { + return; + } + + try { + ldapServerManager.stop(); + } catch (Exception e) { + LOG.ldapServiceStopFailed(e); + throw new ServiceLifecycleException("Failed to stop LDAP service", e); + } + } + + /** + * Get the port the LDAP server is listening on + */ + public int getLdapPort() { + return ldapServerManager != null ? ldapServerManager.getPort() : -1; + } + + /** + * Get the base DN for LDAP entries + */ + public String getBaseDn() { + return ldapServerManager != null ? ldapServerManager.getBaseDn() : null; + } + + /** + * Check if the LDAP service is enabled + */ + public boolean isEnabled() { + return enabled; + } +} 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 new file mode 100644 index 0000000000..a03c0b6890 --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/LdapMessages.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; + +import org.apache.knox.gateway.i18n.messages.Message; +import org.apache.knox.gateway.i18n.messages.MessageLevel; +import org.apache.knox.gateway.i18n.messages.Messages; +import org.apache.knox.gateway.i18n.messages.StackTrace; + +@Messages(logger = "org.apache.knox.gateway.services.ldap") +public interface LdapMessages { + + @Message(level = MessageLevel.INFO, + text = "Starting LDAP service on port {0} with base DN: {1}") + void ldapServiceStarting(int port, String baseDn); + + @Message(level = MessageLevel.INFO, + text = "LDAP service started successfully on port {0}") + void ldapServiceStarted(int port); + + @Message(level = MessageLevel.INFO, + text = "Stopping LDAP service on port {0}") + void ldapServiceStopping(int port); + + @Message(level = MessageLevel.INFO, + text = "LDAP service stopped successfully") + void ldapServiceStopped(); + + @Message(level = MessageLevel.ERROR, + text = "Failed to start LDAP service: {0}") + void ldapServiceStartFailed(@StackTrace(level = MessageLevel.DEBUG) Exception e); + + @Message(level = MessageLevel.ERROR, + text = "Failed to stop LDAP service: {0}") + void ldapServiceStopFailed(@StackTrace(level = MessageLevel.DEBUG) Exception e); + + @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.WARN, + text = "Data file not found: {0}, creating sample data") + void ldapDataFileNotFound(String dataFile); + + @Message(level = MessageLevel.INFO, + text = "Loaded {0} users from {1}") + void ldapUsersLoaded(int count, String dataFile); + + @Message(level = MessageLevel.INFO, + text = "Created sample data file: {0}") + void ldapSampleDataCreated(String path); + + @Message(level = MessageLevel.DEBUG, + text = "LDAP Search: {0} | {1}") + void ldapSearch(String baseDn, String filter); + + @Message(level = MessageLevel.DEBUG, + text = "LDAP Bind: {0}") + void ldapBind(String dn); + + @Message(level = MessageLevel.INFO, + text = "Loaded user from backend: {0}") + void ldapUserLoaded(String username); + + @Message(level = MessageLevel.INFO, + text = "Cleaning up old lock file: {0}") + void ldapCleaningLockFile(String lockFile); +} 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 new file mode 100644 index 0000000000..f432a6dc5a --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/BackendFactory.java @@ -0,0 +1,49 @@ +/* + * 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 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 loading backend implementations using ServiceLoader for full extensibility. + * Backends are discovered via META-INF/services/org.apache.knox.gateway.services.ldap.backend.LdapBackend + * Built-in backends (file, ldap) are registered via ServiceLoader along with any external plugins. + */ +public class BackendFactory { + private static final LdapMessages LOG = MessagesFactory.get(LdapMessages.class); + + public static LdapBackend createBackend(String backendName, Map config) throws Exception { + // Use ServiceLoader to discover all available backends (built-in and external plugins) + ServiceLoader loader = ServiceLoader.load(LdapBackend.class); + for (LdapBackend backend : loader) { + if (backend.getName().equalsIgnoreCase(backendName)) { + LOG.ldapBackendLoading(backend.getName(), "ServiceLoader"); + backend.initialize(config); + return backend; + } + } + + // No matching backend found + LOG.ldapBackendNotFound(backendName); + throw new IllegalArgumentException("No LDAP backend found for type: " + 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 new file mode 100644 index 0000000000..b0cdcd8607 --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/FileBackend.java @@ -0,0 +1,144 @@ +/* + * 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 com.google.gson.Gson; +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.schema.SchemaManager; +import org.apache.knox.gateway.i18n.messages.MessagesFactory; +import org.apache.knox.gateway.services.ldap.LdapMessages; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * File-based backend that reads user/group data from JSON + */ +public class FileBackend implements LdapBackend { + private static final LdapMessages LOG = MessagesFactory.get(LdapMessages.class); + + private Map users = new HashMap<>(); + private String dataFile; + private String baseDn; + + static class UserData { + String username; + String cn; + String sn; + List groups; + Map attributes; + } + + static class BackendData { + List users; + } + + @Override + public String getName() { + return "file"; + } + + @Override + public void initialize(Map config) throws Exception { + dataFile = config.getOrDefault("dataFile", "ldap-users.json"); + baseDn = config.getOrDefault("baseDn", "dc=proxy,dc=com"); + loadData(); + } + + private void loadData() throws Exception { + Path path = Paths.get(dataFile); + + if (!Files.exists(path)) { + LOG.ldapDataFileNotFound(dataFile); + throw new Exception("LDAP data file not found: " + dataFile + ". Please create the file with user data before starting the service."); + } + + String json = Files.readString(path); + Gson gson = new Gson(); + BackendData data = gson.fromJson(json, BackendData.class); + + if (data != null && data.users != null) { + for (UserData user : data.users) { + users.put(user.username, user); + } + LOG.ldapUsersLoaded(users.size(), dataFile); + } + } + + @Override + public Entry getUser(String username, SchemaManager schemaManager) throws Exception { + UserData userData = users.get(username); + if (userData == null) { + return null; + } + + Entry entry = new DefaultEntry(schemaManager); + entry.setDn("uid=" + userData.username + ",ou=Users," + baseDn); + entry.add("objectClass", "top"); + entry.add("objectClass", "person"); + entry.add("objectClass", "organizationalPerson"); + entry.add("objectClass", "inetOrgPerson"); + entry.add("uid", userData.username); + entry.add("cn", userData.cn); + entry.add("sn", userData.sn); + + // Add groups as description + if (userData.groups != null && !userData.groups.isEmpty()) { + entry.add("description", "Groups: " + String.join(", ", userData.groups)); + } + + // Add custom attributes + if (userData.attributes != null) { + for (Map.Entry attr : userData.attributes.entrySet()) { + entry.add(attr.getKey(), attr.getValue()); + } + } + + return entry; + } + + @Override + public List getUserGroups(String username) throws Exception { + UserData userData = users.get(username); + return userData != null && userData.groups != null ? userData.groups : Collections.emptyList(); + } + + @Override + public List searchUsers(String filter, SchemaManager schemaManager) throws Exception { + List results = new ArrayList<>(); + + // Simple filter matching - just check if username matches + for (String username : users.keySet()) { + if (filter.contains("uid=" + username) || filter.contains("*")) { + Entry entry = getUser(username, schemaManager); + if (entry != null) { + results.add(entry); + } + } + } + + return results; + } +} 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 new file mode 100644 index 0000000000..6530c13b37 --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapBackend.java @@ -0,0 +1,68 @@ +/* + * 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 org.apache.directory.api.ldap.model.entry.Entry; +import org.apache.directory.api.ldap.model.schema.SchemaManager; + +import java.util.List; +import java.util.Map; + +/** + * Interface for pluggable LDAP backends. + * Implementations can provide user/group data from various sources: + * - File-based (JSON, LDIF, properties) + * - JDBC databases + * - Remote LDAP servers (proxy/federation) + * - REST APIs (Knox, Ranger, etc.) + */ +public interface LdapBackend { + /** + * Get the name of this backend implementation + */ + String getName(); + + /** + * Initialize the backend with configuration + * @param config Configuration properties + */ + void initialize(Map config) throws Exception; + + /** + * Get a user entry by username + * @param username The username to look up + * @param schemaManager Schema manager for creating entries + * @return Entry or null if not found + */ + Entry getUser(String username, SchemaManager schemaManager) throws Exception; + + /** + * Get groups for a user + * @param username The username + * @return List of group names + */ + List getUserGroups(String username) throws Exception; + + /** + * Search for users matching a filter + * @param filter LDAP filter string (simplified) + * @param schemaManager Schema manager for creating entries + * @return List of matching entries + */ + List searchUsers(String filter, SchemaManager schemaManager) throws Exception; +} 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 new file mode 100644 index 0000000000..2c37cb135f --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackend.java @@ -0,0 +1,452 @@ +/* + * 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 org.apache.directory.api.ldap.model.cursor.CursorException; +import org.apache.directory.api.ldap.model.cursor.EntryCursor; +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.exception.LdapException; +import org.apache.directory.api.ldap.model.message.SearchScope; +import org.apache.directory.api.ldap.model.schema.SchemaManager; +import org.apache.directory.ldap.client.api.DefaultLdapConnectionFactory; +import org.apache.directory.ldap.client.api.LdapConnection; +import org.apache.directory.ldap.client.api.LdapConnectionConfig; +import org.apache.directory.ldap.client.api.LdapConnectionPool; +import org.apache.directory.ldap.client.api.ValidatingPoolableLdapConnectionFactory; +import org.apache.knox.gateway.i18n.messages.MessagesFactory; +import org.apache.knox.gateway.services.ldap.LdapMessages; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * LDAP backend that proxies to an external LDAP server. + * Can use central LDAP configuration or backend-specific configuration. + */ +public class LdapProxyBackend implements LdapBackend { + private static final LdapMessages LOG = MessagesFactory.get(LdapMessages.class); + + private String ldapUrl; + private String bindDn; + private String bindPassword; + private String userSearchBase; + private String groupSearchBase; + private String proxyBaseDn; // Base DN for proxy entries (e.g., dc=proxy,dc=com) + private String remoteBaseDn; // Base DN for remote server searches (e.g., dc=hadoop,dc=apache,dc=org) + private int port; + private String host; + + // Configurable attributes for AD/LDAP compatibility + private String userIdentifierAttribute = "uid"; // uid for LDAP, sAMAccountName for AD + private String userSearchFilter = "({userIdAttr}={username})"; // Will be populated with userIdentifierAttribute + private String groupMemberAttribute = "memberUid"; // member for AD, memberUid for POSIX + private boolean useMemberOf; // Use memberOf attribute for group lookup (efficient for AD) + + // Connection pool for efficient connection reuse + private LdapConnectionPool connectionPool; + + @Override + public String getName() { + return "ldap"; + } + + @Override + public void initialize(Map config) throws Exception { + // Proxy base DN is for entries created in the proxy LDAP server + proxyBaseDn = config.get("baseDn"); + if (proxyBaseDn == null || proxyBaseDn.isEmpty()) { + throw new IllegalArgumentException("baseDn is required for LDAP proxy backend"); + } + + // Remote base DN is for searching the remote LDAP server + remoteBaseDn = config.get("remoteBaseDn"); + if (remoteBaseDn == null || remoteBaseDn.isEmpty()) { + throw new IllegalArgumentException("remoteBaseDn is required for LDAP proxy backend - this is the base DN of the remote LDAP server"); + } + + // Support both url and host/port configuration + ldapUrl = config.get("url"); + if (ldapUrl != null && !ldapUrl.isEmpty()) { + // Parse URL to extract host and port + parseLdapUrl(ldapUrl); + } else { + host = config.get("host"); + if (host == null || host.isEmpty()) { + throw new IllegalArgumentException("Either 'url' or 'host' is required for LDAP proxy backend"); + } + String portStr = config.get("port"); + if (portStr == null || portStr.isEmpty()) { + throw new IllegalArgumentException("'port' is required when using 'host' configuration"); + } + port = Integer.parseInt(portStr); + ldapUrl = "ldap://" + host + ":" + port; + } + + // Support both naming conventions: bindDn/bindPassword and systemUsername/systemPassword + bindDn = config.get("bindDn"); + if (bindDn == null || bindDn.isEmpty()) { + bindDn = config.get("systemUsername"); + } + + bindPassword = config.get("bindPassword"); + if (bindPassword == null || bindPassword.isEmpty()) { + bindPassword = config.get("systemPassword"); + } + + // Search bases use the remote server's base DN + userSearchBase = config.getOrDefault("userSearchBase", "ou=people," + remoteBaseDn); + groupSearchBase = config.getOrDefault("groupSearchBase", "ou=groups," + remoteBaseDn); + + // Configure attribute mappings for AD/LDAP compatibility + userIdentifierAttribute = config.getOrDefault("userIdentifierAttribute", "uid"); + config.getOrDefault("userDnTemplate", "uid={username},ou=Users,{baseDn}"); + groupMemberAttribute = config.getOrDefault("groupMemberAttribute", "memberUid"); + useMemberOf = Boolean.parseBoolean(config.getOrDefault("useMemberOf", "false")); + + // Build search filter template + userSearchFilter = "(" + userIdentifierAttribute + "={username})"; + + LOG.ldapBackendLoading(getName(), "Proxying " + proxyBaseDn + " to " + ldapUrl + " (" + remoteBaseDn + ") with " + + userIdentifierAttribute + " attribute" + + (useMemberOf ? " using memberOf lookups" : " using group searches")); + + // Initialize connection pool + initializeConnectionPool(config); + } + + /** + * 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 config) throws Exception { + // Configure connection settings + LdapConnectionConfig connectionConfig = new LdapConnectionConfig(); + connectionConfig.setLdapHost(host); + connectionConfig.setLdapPort(port); + + if (bindDn != null && !bindDn.isEmpty()) { + connectionConfig.setName(bindDn); + connectionConfig.setCredentials(bindPassword); + } + + // Connection pool configuration (with sensible defaults) + int maxActive = Integer.parseInt(config.getOrDefault("poolMaxActive", "8")); + + // Create connection factory + DefaultLdapConnectionFactory factory = new DefaultLdapConnectionFactory(connectionConfig); + + // Create validating poolable connection factory to test connections + ValidatingPoolableLdapConnectionFactory poolFactory = new ValidatingPoolableLdapConnectionFactory(factory); + + // Create the pool with max size + connectionPool = new LdapConnectionPool(poolFactory); + connectionPool.setMaxTotal(maxActive); + connectionPool.setTestOnBorrow(true); + + LOG.ldapBackendLoading(getName(), "Initialized connection pool with maxActive=" + maxActive); + } + + private void parseLdapUrl(String url) { + // Simple URL parsing for ldap://host:port + if (url.startsWith("ldap://")) { + String hostPort = url.substring(7); + int colonIdx = hostPort.indexOf(':'); + if (colonIdx > 0) { + host = hostPort.substring(0, colonIdx); + try { + port = Integer.parseInt(hostPort.substring(colonIdx + 1)); + } catch (NumberFormatException e) { + port = 389; + } + } else { + host = hostPort; + port = 389; + } + } else if (url.startsWith("ldaps://")) { + String hostPort = url.substring(8); + int colonIdx = hostPort.indexOf(':'); + if (colonIdx > 0) { + host = hostPort.substring(0, colonIdx); + try { + port = Integer.parseInt(hostPort.substring(colonIdx + 1)); + } catch (NumberFormatException e) { + port = 636; + } + } else { + host = hostPort; + port = 636; + } + } + } + + /** + * Gets a connection from the connection pool. + * Connections obtained from this method should be released back to the pool + * using releaseConnection() when done. + * + * @return An LDAP connection from the pool + * @throws Exception if unable to get a connection from the pool + */ + private LdapConnection getConnection() throws Exception { + return connectionPool.getConnection(); + } + + /** + * Releases a connection back to the pool. + * This method should be called in a finally block to ensure connections are returned. + * + * @param connection The connection to release back to the pool + */ + private void releaseConnection(LdapConnection connection) { + if (connection != null) { + try { + connectionPool.releaseConnection(connection); + } catch (Exception e) { + LOG.ldapServiceStopFailed(e); + } + } + } + + /** + * Closes the connection pool and releases all resources. + * Should be called when the backend is being shut down. + */ + public void close() { + if (connectionPool != null) { + try { + connectionPool.close(); + } catch (Exception e) { + LOG.ldapServiceStopFailed(e); + } + } + } + + @Override + public Entry getUser(String username, SchemaManager schemaManager) throws Exception { + LdapConnection connection = null; + try { + connection = getConnection(); + // Search for user using configurable attribute + String filter = userSearchFilter.replace("{username}", username); + EntryCursor cursor = connection.search(userSearchBase, filter, SearchScope.SUBTREE, "*"); + + if (cursor.next()) { + Entry sourceEntry = cursor.get(); + Entry entry = createProxyEntry(sourceEntry, username, connection, schemaManager); + cursor.close(); + return entry; + } + + cursor.close(); + return null; + } finally { + releaseConnection(connection); + } + } + + @Override + public List getUserGroups(String username) throws Exception { + LdapConnection connection = null; + try { + connection = getConnection(); + if (useMemberOf) { + // Use memberOf attribute for efficient AD lookups + return getUserGroupsViaMemberOf(connection, username); + } else { + // Use traditional group search approach + String filter = userSearchFilter.replace("{username}", username); + EntryCursor cursor = connection.search(userSearchBase, filter, SearchScope.SUBTREE, "dn"); + + if (cursor.next()) { + String userDn = cursor.get().getDn().toString(); + cursor.close(); + return getUserGroupsInternal(connection, userDn, username); + } + + cursor.close(); + } + return Collections.emptyList(); + } finally { + releaseConnection(connection); + } + } + + private List getUserGroupsViaMemberOf(LdapConnection connection, String username) throws LdapException, CursorException, IOException { + List groups = new ArrayList<>(); + + // Search for user and retrieve memberOf attribute + String filter = userSearchFilter.replace("{username}", username); + EntryCursor cursor = connection.search(userSearchBase, filter, SearchScope.SUBTREE, "memberOf"); + + if (cursor.next()) { + Entry userEntry = cursor.get(); + Attribute memberOfAttr = userEntry.get("memberOf"); + + if (memberOfAttr != null) { + // Extract group names from DNs + for (org.apache.directory.api.ldap.model.entry.Value value : memberOfAttr) { + String groupDn = value.getString(); + String groupName = extractGroupNameFromDn(groupDn); + if (groupName != null) { + groups.add(groupName); + } + } + } + } + + cursor.close(); + return groups; + } + + private String extractGroupNameFromDn(String groupDn) { + // Extract CN from DN like "CN=Domain Admins,CN=Users,DC=company,DC=com" + if (groupDn.toLowerCase(Locale.ROOT).startsWith("cn=")) { + int commaIdx = groupDn.indexOf(','); + if (commaIdx > 0) { + return groupDn.substring(3, commaIdx); + } + } + return null; + } + + private List getUserGroupsInternal(LdapConnection connection, String userDn, String username) throws LdapException, CursorException, IOException { + List groups = new ArrayList<>(); + + // Search for groups where user is a member - build filter based on configuration + String filter; + if ("member".equals(groupMemberAttribute)) { + // AD style - uses full DN + filter = "(|" + + "(member=" + userDn + ")" + + "(uniqueMember=" + userDn + ")" + + ")"; + } else { + // POSIX style - uses username + filter = "(|" + + "(memberUid=" + username + ")" + + "(member=" + userDn + ")" + + "(uniqueMember=" + userDn + ")" + + ")"; + } + + EntryCursor cursor = connection.search(groupSearchBase, filter, SearchScope.SUBTREE, "cn"); + + while (cursor.next()) { + Entry groupEntry = cursor.get(); + Attribute cnAttr = groupEntry.get("cn"); + if (cnAttr != null) { + groups.add(cnAttr.getString()); + } + } + + cursor.close(); + return groups; + } + + @Override + public List searchUsers(String filter, SchemaManager schemaManager) throws Exception { + List results = new ArrayList<>(); + LdapConnection connection = null; + + try { + connection = getConnection(); + String searchValue = filter.contains("*") ? "*" : filter; + String ldapFilter = "(" + userIdentifierAttribute + "=" + searchValue + ")"; + EntryCursor cursor = connection.search(userSearchBase, ldapFilter, SearchScope.SUBTREE, "*"); + + while (cursor.next()) { + Entry sourceEntry = cursor.get(); + Attribute idAttr = sourceEntry.get(userIdentifierAttribute); + if (idAttr != null) { + String username = idAttr.getString(); + Entry entry = createProxyEntry(sourceEntry, username, connection, schemaManager); + results.add(entry); + } + } + + cursor.close(); + return results; + } finally { + releaseConnection(connection); + } + } + + /** + * Creates a proxy entry from a backend source entry with all required attributes. + * This method standardizes the conversion of backend LDAP entries to proxy entries, + * preserving the backend DN and copying all standard user attributes. + * + * @param sourceEntry The entry from the backend LDAP server + * @param username The username for the entry + * @param connection The LDAP connection for fetching group information + * @param schemaManager The schema manager for creating entries + * @return A new Entry with backend DN and all copied attributes + * @throws Exception if entry creation or attribute copying fails + */ + private Entry createProxyEntry(Entry sourceEntry, String username, LdapConnection connection, SchemaManager schemaManager) throws Exception { + // Standard proxy approach: return entry with backend DN unchanged + // This preserves DN integrity for bind operations and DN references + Entry entry = new DefaultEntry(schemaManager); + entry.setDn(sourceEntry.getDn()); + + // Copy all attributes as-is from backend + copyAttribute(sourceEntry, entry, "objectClass"); + copyAttribute(sourceEntry, entry, userIdentifierAttribute); + + // Map identifier attribute to uid for consistency if needed + if (!"uid".equals(userIdentifierAttribute)) { + Attribute idAttr = sourceEntry.get(userIdentifierAttribute); + if (idAttr != null) { + entry.add("uid", idAttr.getString()); + } + } + + copyAttribute(sourceEntry, entry, "cn"); + copyAttribute(sourceEntry, entry, "sn"); + copyAttribute(sourceEntry, entry, "mail"); + copyAttribute(sourceEntry, entry, "description"); + copyAttribute(sourceEntry, entry, "memberOf"); // Preserve group memberships + + // Get user's groups + List groups = getUserGroupsInternal(connection, sourceEntry.getDn().toString(), username); + if (!groups.isEmpty()) { + entry.add("description", "Groups: " + String.join(", ", groups)); + } + + return entry; + } + + private void copyAttribute(Entry source, Entry target, String attributeName) throws LdapException { + Attribute attr = source.get(attributeName); + if (attr != null) { + // Copy all values of the attribute (important for multi-valued attributes like objectClass) + for (org.apache.directory.api.ldap.model.entry.Value value : attr) { + target.add(attributeName, value.getString()); + } + } + } +} 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.LdapBackend new file mode 100644 index 0000000000..c8bd82de6c --- /dev/null +++ b/gateway-server/src/main/resources/META-INF/services/org.apache.knox.gateway.services.ldap.backend.LdapBackend @@ -0,0 +1,21 @@ +########################################################################## +# 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. +########################################################################## + +# 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 diff --git a/gateway-server/src/main/resources/conf/gateway-site.xml b/gateway-server/src/main/resources/conf/gateway-site.xml index ff5293b569..9669a1941c 100644 --- a/gateway-server/src/main/resources/conf/gateway-site.xml +++ b/gateway-server/src/main/resources/conf/gateway-site.xml @@ -37,4 +37,185 @@ limitations under the License. The directory within GATEWAY_HOME that contains gateway topology files and deployments. + + + gateway.ldap.enabled + true + Enable the embedded LDAP service for user and group lookups. Set to true to enable. + + + + gateway.ldap.port + 3890 + Port for the LDAP service to listen on. Default is 3890. + + + + gateway.ldap.base.dn + dc=proxy,dc=com + Base DN for LDAP entries in the proxy server. Default is dc=proxy,dc=com. + + + + gateway.ldap.backend.type + ldap + Backend type for LDAP service. Currently supported: file, ldap. Future: jdbc, knox. + + + + gateway.ldap.backend.data.file + ${GATEWAY_DATA_HOME}/ldap-users.json + Path to JSON data file for file-based backend. Supports ${GATEWAY_DATA_HOME} variable. + + + + + gateway.ldap.backend.ldap.url + ldap://localhost:33389 + LDAP server URL for proxy backend + + + gateway.ldap.backend.ldap.remoteBaseDn + dc=hadoop,dc=apache,dc=org + Base DN of the remote LDAP server + + + gateway.ldap.backend.ldap.systemUsername + uid=guest,ou=people,dc=hadoop,dc=apache,dc=org + LDAP bind DN for proxy backend authentication + + + gateway.ldap.backend.ldap.systemPassword + guest-password + LDAP bind password for proxy backend authentication + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/AbstractGatewayServicesTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/AbstractGatewayServicesTest.java index f38bc8b271..b27358c7cb 100644 --- a/gateway-server/src/test/java/org/apache/knox/gateway/services/AbstractGatewayServicesTest.java +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/AbstractGatewayServicesTest.java @@ -65,7 +65,8 @@ public void testAddStartAndStop() throws ServiceLifecycleException { ServiceType.SERVICE_REGISTRY_SERVICE, ServiceType.CONCURRENT_SESSION_VERIFIER, ServiceType.REMOTE_CONFIGURATION_MONITOR, - ServiceType.GATEWAY_STATUS_SERVICE + ServiceType.GATEWAY_STATUS_SERVICE, + ServiceType.LDAP_SERVICE }; assertNotEquals(ServiceType.values(), orderedServiceTypes); 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 new file mode 100644 index 0000000000..a9450a96ff --- /dev/null +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManagerTest.java @@ -0,0 +1,188 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.knox.gateway.services.ldap; + +import org.junit.Test; +import org.junit.Before; +import org.junit.After; + +import java.io.File; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; + +/** + * Unit tests for KnoxLDAPServerManager. + */ +public class KnoxLDAPServerManagerTest { + + private KnoxLDAPServerManager serverManager; + private File tempWorkDir; + private File tempLdapFile; + + @Before + public void setUp() throws Exception { + serverManager = new KnoxLDAPServerManager(); + + // Create temporary work directory + tempWorkDir = File.createTempFile("knox-ldap-work", ""); + tempWorkDir.delete(); + tempWorkDir.mkdirs(); + tempWorkDir.deleteOnExit(); + + // Create temporary LDAP data file + tempLdapFile = File.createTempFile("ldap-test", ".json"); + tempLdapFile.deleteOnExit(); + + 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\":[]}"); + } + } + + @After + public void tearDown() throws Exception { + if (serverManager != null) { + try { + serverManager.stop(); + } catch (Exception e) { + // Ignore cleanup errors + } + } + cleanupTempFiles(); + } + + @Test + public void testInitializeWithFileBackend() throws Exception { + Map backendConfig = createFileBackendConfig(); + + serverManager.initialize(tempWorkDir, 3890, "dc=test,dc=com", "file", backendConfig, null); + + assertEquals("Port should be set correctly", 3890, 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 testInitializeWithLdapBackend() throws Exception { + Map backendConfig = createLdapBackendConfig(); + + serverManager.initialize(tempWorkDir, 3891, "dc=proxy,dc=com", "ldap", backendConfig, "dc=hadoop,dc=apache,dc=org"); + + assertEquals("Port should be set correctly", 3891, serverManager.getPort()); + assertEquals("Base DN should be set correctly", "dc=proxy,dc=com", serverManager.getBaseDn()); + assertFalse("Should not be running after initialize", serverManager.isRunning()); + } + + @Test(expected = Exception.class) + public void testInitializeWithInvalidBackendType() throws Exception { + Map backendConfig = new HashMap<>(); + backendConfig.put("baseDn", "dc=test,dc=com"); + + serverManager.initialize(tempWorkDir, 3890, "dc=test,dc=com", "invalid", backendConfig, null); + } + + @Test + public void testLockFileCleanup() throws Exception { + // Create a lock file to simulate previous unclean shutdown + File runDir = new File(tempWorkDir, "run"); + runDir.mkdirs(); + File lockFile = new File(runDir, "instance.lock"); + lockFile.createNewFile(); + assertTrue("Lock file should exist before initialization", lockFile.exists()); + + Map backendConfig = createFileBackendConfig(); + serverManager.initialize(tempWorkDir, 3890, "dc=test,dc=com", "file", backendConfig, null); + + assertFalse("Lock file should be cleaned up during initialization", lockFile.exists()); + } + + @Test + public void testGettersBeforeInitialization() { + assertEquals("Port should be 0 before initialization", 0, serverManager.getPort()); + assertEquals("Base DN should be null before initialization", null, serverManager.getBaseDn()); + assertFalse("Should not be running before initialization", serverManager.isRunning()); + } + + @Test + public void testStopBeforeStart() throws Exception { + Map backendConfig = createFileBackendConfig(); + serverManager.initialize(tempWorkDir, 3890, "dc=test,dc=com", "file", backendConfig, null); + + // Should not throw exception when stopping before starting + serverManager.stop(); + } + + @Test + public void testMultipleStopCalls() throws Exception { + Map backendConfig = createFileBackendConfig(); + serverManager.initialize(tempWorkDir, 3890, "dc=test,dc=com", "file", backendConfig, null); + + // Multiple stop calls should not throw exceptions + serverManager.stop(); + serverManager.stop(); + serverManager.stop(); + } + + @Test(expected = Exception.class) + public void testStartWithoutInitialize() throws Exception { + // Should throw exception when starting without initialization + serverManager.start(); + } + + private Map createFileBackendConfig() { + Map config = new HashMap<>(); + config.put("baseDn", "dc=test,dc=com"); + config.put("dataFile", tempLdapFile.getAbsolutePath()); + return config; + } + + private Map createLdapBackendConfig() { + Map config = new HashMap<>(); + 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(); + } + if (tempWorkDir != null && tempWorkDir.exists()) { + // Clean up work directory recursively + deleteRecursively(tempWorkDir); + } + } + + private void deleteRecursively(File file) { + if (file.isDirectory()) { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteRecursively(child); + } + } + } + file.delete(); + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..dbdb081dac --- /dev/null +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServiceTest.java @@ -0,0 +1,194 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.knox.gateway.services.ldap; + +import org.apache.knox.gateway.config.GatewayConfig; +import org.apache.knox.gateway.services.ServiceLifecycleException; +import org.junit.Test; +import org.junit.Before; +import org.junit.After; + +import java.io.File; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; +import static org.easymock.EasyMock.createMock; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.replay; +import static org.easymock.EasyMock.verify; + +/** + * Unit tests for KnoxLDAPService. + */ +public class KnoxLDAPServiceTest { + + private KnoxLDAPService ldapService; + private GatewayConfig mockConfig; + private File tempDataDir; + private File tempLdapFile; + + @Before + public void setUp() throws Exception { + ldapService = new KnoxLDAPService(); + mockConfig = createMock(GatewayConfig.class); + + // Create temporary directories and files + tempDataDir = File.createTempFile("knox-ldap-test", ""); + tempDataDir.delete(); + tempDataDir.mkdirs(); + tempDataDir.deleteOnExit(); + + tempLdapFile = new File(tempDataDir, "ldap-users.json"); + try (java.io.BufferedWriter writer = java.nio.file.Files.newBufferedWriter(tempLdapFile.toPath(), java.nio.charset.StandardCharsets.UTF_8)) { + writer.write("{\"users\":[],\"groups\":[]}"); + } + tempLdapFile.deleteOnExit(); + } + + @After + public void tearDown() throws Exception { + if (ldapService != null) { + try { + ldapService.stop(); + } catch (Exception e) { + // Ignore cleanup errors + } + } + if (tempLdapFile != null && tempLdapFile.exists()) { + tempLdapFile.delete(); + } + if (tempDataDir != null && tempDataDir.exists()) { + tempDataDir.delete(); + } + } + + @Test + public void testInitWithLdapDisabled() throws Exception { + expect(mockConfig.isLDAPEnabled()).andReturn(false); + replay(mockConfig); + + ldapService.init(mockConfig, new HashMap<>()); + + assertFalse("LDAP service should not be enabled", ldapService.isEnabled()); + assertEquals("LDAP port should be -1 when disabled", -1, ldapService.getLdapPort()); + + verify(mockConfig); + } + + @Test + public void testInitWithLdapEnabledFileBackend() throws Exception { + setupMockConfigForFileBackend(); + replay(mockConfig); + + ldapService.init(mockConfig, new HashMap<>()); + + assertTrue("LDAP service should be enabled", ldapService.isEnabled()); + assertEquals("Base DN should match config", "dc=test,dc=com", ldapService.getBaseDn()); + + verify(mockConfig); + } + + @Test + public void testInitWithLdapEnabledLdapBackend() throws Exception { + setupMockConfigForLdapBackend(); + replay(mockConfig); + + ldapService.init(mockConfig, new HashMap<>()); + + assertTrue("LDAP service should be enabled", ldapService.isEnabled()); + assertEquals("Base DN should match config", "dc=proxy,dc=com", ldapService.getBaseDn()); + + verify(mockConfig); + } + + @Test(expected = ServiceLifecycleException.class) + public void testInitWithInvalidBackendType() throws Exception { + expect(mockConfig.isLDAPEnabled()).andReturn(true); + expect(mockConfig.getGatewayDataDir()).andReturn(tempDataDir.getAbsolutePath()); + expect(mockConfig.getLDAPPort()).andReturn(3890); + expect(mockConfig.getLDAPBaseDN()).andReturn("dc=test,dc=com"); + expect(mockConfig.getLDAPBackendType()).andReturn("invalid"); + expect(mockConfig.getLDAPBackendConfig("invalid")).andReturn(new HashMap<>()); + replay(mockConfig); + + ldapService.init(mockConfig, new HashMap<>()); + } + + @Test + public void testStartWhenDisabled() throws Exception { + expect(mockConfig.isLDAPEnabled()).andReturn(false); + replay(mockConfig); + + ldapService.init(mockConfig, new HashMap<>()); + + // Should not throw exception + ldapService.start(); + + verify(mockConfig); + } + + @Test + public void testStopWhenDisabled() throws Exception { + expect(mockConfig.isLDAPEnabled()).andReturn(false); + replay(mockConfig); + + ldapService.init(mockConfig, new HashMap<>()); + + // Should not throw exception + ldapService.stop(); + + verify(mockConfig); + } + + @Test + public void testGettersWhenNotInitialized() { + assertEquals("LDAP port should be -1 when not initialized", -1, ldapService.getLdapPort()); + assertEquals("Base DN should be null when not initialized", null, ldapService.getBaseDn()); + assertFalse("Should not be enabled when not initialized", ldapService.isEnabled()); + } + + private void setupMockConfigForFileBackend() { + expect(mockConfig.isLDAPEnabled()).andReturn(true); + expect(mockConfig.getGatewayDataDir()).andReturn(tempDataDir.getAbsolutePath()); + expect(mockConfig.getLDAPPort()).andReturn(3890); + expect(mockConfig.getLDAPBaseDN()).andReturn("dc=test,dc=com"); + expect(mockConfig.getLDAPBackendType()).andReturn("file"); + + Map fileBackendConfig = new HashMap<>(); + fileBackendConfig.put("dataFile", tempLdapFile.getAbsolutePath()); + expect(mockConfig.getLDAPBackendConfig("file")).andReturn(fileBackendConfig); + } + + private void setupMockConfigForLdapBackend() { + expect(mockConfig.isLDAPEnabled()).andReturn(true); + expect(mockConfig.getGatewayDataDir()).andReturn(tempDataDir.getAbsolutePath()); + expect(mockConfig.getLDAPPort()).andReturn(3890); + expect(mockConfig.getLDAPBaseDN()).andReturn("dc=proxy,dc=com"); + expect(mockConfig.getLDAPBackendType()).andReturn("ldap"); + + Map ldapBackendConfig = new HashMap<>(); + ldapBackendConfig.put("url", "ldap://localhost:33389"); + ldapBackendConfig.put("remoteBaseDn", "dc=hadoop,dc=apache,dc=org"); + ldapBackendConfig.put("systemUsername", "cn=admin,dc=hadoop,dc=apache,dc=org"); + ldapBackendConfig.put("systemPassword", "admin-password"); + expect(mockConfig.getLDAPBackendConfig("ldap")).andReturn(ldapBackendConfig); + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..fde29a0870 --- /dev/null +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/backend/BackendFactoryTest.java @@ -0,0 +1,124 @@ +/* + * 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 org.junit.Test; +import org.junit.Before; + +import java.util.HashMap; +import java.util.Map; +import java.util.ServiceLoader; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests to verify ServiceLoader discovery of LDAP backends. + */ +public class BackendFactoryTest { + + private Map config; + + @Before + public void setUp() throws Exception { + config = new HashMap<>(); + config.put("baseDn", "dc=test,dc=com"); + + // Create a temporary data file for FileBackend tests + java.io.File tempFile = java.io.File.createTempFile("ldap-test", ".json"); + tempFile.deleteOnExit(); + + // Write minimal valid JSON + try (java.io.BufferedWriter writer = java.nio.file.Files.newBufferedWriter(tempFile.toPath(), java.nio.charset.StandardCharsets.UTF_8)) { + writer.write("{\"users\":[],\"groups\":[]}\n"); + } + + config.put("dataFile", tempFile.getAbsolutePath()); + } + + @Test + public void testServiceLoaderDiscovery() { + ServiceLoader loader = ServiceLoader.load(LdapBackend.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)) { + foundFileBackend = true; + assertTrue("File backend should be FileBackend instance", backend instanceof FileBackend); + } else if ("ldap".equals(backendName)) { + foundLdapBackend = true; + assertTrue("LDAP backend should be LdapProxyBackend instance", backend instanceof LdapProxyBackend); + } + } + + assertTrue("ServiceLoader should discover file backend", foundFileBackend); + assertTrue("ServiceLoader should discover ldap backend", foundLdapBackend); + } + + @Test + public void testCreateFileBackend() throws Exception { + LdapBackend fileBackend = BackendFactory.createBackend("file", 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()); + } + + @Test + public void testCreateLdapBackend() throws Exception { + config.put("url", "ldap://localhost:389"); + config.put("remoteBaseDn", "dc=hadoop,dc=apache,dc=org"); + + LdapBackend ldapBackend = BackendFactory.createBackend("ldap", 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()); + } + + @Test + public void testCaseInsensitiveBackendNames() throws Exception { + // Test uppercase + LdapBackend upperCaseBackend = BackendFactory.createBackend("FILE", config); + assertTrue("Should create FileBackend with uppercase name", upperCaseBackend instanceof FileBackend); + + // Test mixed case + LdapBackend mixedCaseBackend = BackendFactory.createBackend("File", config); + assertTrue("Should create FileBackend with mixed case name", mixedCaseBackend instanceof FileBackend); + } + + @Test(expected = IllegalArgumentException.class) + public void testUnknownBackendThrowsException() throws Exception { + BackendFactory.createBackend("unknown", config); + } + + @Test(expected = IllegalArgumentException.class) + public void testNullBackendNameThrowsException() throws Exception { + BackendFactory.createBackend(null, config); + } + + @Test(expected = IllegalArgumentException.class) + public void testEmptyBackendNameThrowsException() throws Exception { + BackendFactory.createBackend("", config); + } +} \ 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 5d96dfbb47..7c848d236c 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 @@ -1202,4 +1202,40 @@ public String getStrictTransportOption() { public boolean isTopologyAsyncSupported(String topology) { return false; } + + // LDAP Service Configuration + @Override + public boolean isLDAPEnabled() { + return false; + } + + @Override + public int getLDAPPort() { + return 3890; + } + + @Override + public String getLDAPBaseDN() { + return "dc=test,dc=com"; + } + + @Override + public String getLDAPBackendType() { + return "file"; + } + + @Override + public String getLDAPBackendDataFile() { + return getGatewayDataPath().resolve("ldap-users.json").toString(); + } + + @Override + public Set getPropertyNames() { + return Collections.emptySet(); + } + + @Override + public Map getLDAPBackendConfig(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 ad3fee6d8c..153f2c568d 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 @@ -123,6 +123,13 @@ public interface GatewayConfig { String DEPLOYMENT_PATH_ALIAS = ".path.alias."; + // LDAP Service Configuration + 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_BACKEND_DATA_FILE = "gateway.ldap.backend.data.file"; + /** * The location of the gateway configuration. * Subdirectories will be: topologies @@ -1031,4 +1038,44 @@ public interface GatewayConfig { * @return the strict transport option if set; otherwise return the default value 'max-age=31536000' */ String getStrictTransportOption(); + + /** + * @return true if the embedded LDAP service is enabled; otherwise false + */ + boolean isLDAPEnabled(); + + /** + * @return the port for the LDAP service to listen on + */ + int getLDAPPort(); + + /** + * @return the base DN for LDAP entries + */ + String getLDAPBaseDN(); + + /** + * @return the backend type for LDAP (file, ldap, jdbc, etc.) + */ + String getLDAPBackendType(); + + /** + * @return the path to the data file for file-based backend + */ + String getLDAPBackendDataFile(); + + /** + * Get backend-specific configuration properties. + * Returns all properties with prefix "gateway.ldap.backend.{backendType}." + * with the prefix stripped from the keys. + * + * @param backendType the backend type (e.g., "file", "ldap", "database") + * @return map of configuration key-value pairs for the specified backend + */ + Map getLDAPBackendConfig(String backendType); + + /** + * @return set of all property names in the configuration + */ + Set getPropertyNames(); } diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/ServiceType.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/ServiceType.java index de4a83db9a..d96db8d495 100644 --- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/ServiceType.java +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/ServiceType.java @@ -38,7 +38,8 @@ public enum ServiceType { TOPOLOGY_SERVICE("TopologyService"), CONCURRENT_SESSION_VERIFIER("ConcurrentSessionVerifier"), REMOTE_CONFIGURATION_MONITOR("RemoteConfigurationMonitor"), - GATEWAY_STATUS_SERVICE("GatewayStatusService"); + GATEWAY_STATUS_SERVICE("GatewayStatusService"), + LDAP_SERVICE("LDAPService"); private final String serviceTypeName; private final String shortName; diff --git a/knox-token-management-ui/package.json b/knox-token-management-ui/package.json index c3c363404e..85a8a83fd6 100644 --- a/knox-token-management-ui/package.json +++ b/knox-token-management-ui/package.json @@ -6,7 +6,7 @@ "scripts": { "start": "ng serve --verbose=true", "build": "ng build", - "build-prod": "ng build --configuration production", + "build-prod": "NODE_OPTIONS='--no-warnings' ng build --configuration production", "lint": "eslint token-management" }, "private": true, diff --git a/pom.xml b/pom.xml index 4309d8b54d..db2df1d3eb 100644 --- a/pom.xml +++ b/pom.xml @@ -189,6 +189,7 @@ 1.3.5 3.6.1 3.9.0 + 2.7.0 1.10.0 2.9.1 1.2.7 @@ -2087,6 +2088,11 @@ commons-net ${commons-net.version} + + org.apache.commons + commons-pool2 + ${commons-pool2.version} + org.apache.commons commons-text @@ -2124,7 +2130,6 @@ apacheds-jdbm ${apacheds-jdbm.version} - org.apache.directory.server apacheds-core @@ -2167,6 +2172,16 @@ + + org.apache.directory.server + apacheds-ldif-partition + ${apacheds.directory.server.version} + + + org.apache.directory.server + apacheds-jdbm-partition + ${apacheds.directory.server.version} + org.apache.directory.api @@ -2183,6 +2198,11 @@ api-ldap-model ${apacheds.directory.api.version} + + org.apache.directory.api + api-ldap-schema-data + ${apacheds.directory.api.version} + org.apache.directory.api api-util