浏览代码

Add 'SslProfileExtension' SPI interface (#134609)

This new SPI extension point allows plugins to define new SSL profiles
(contexts) that will be automatically loaded and managed by the
SSLService

Each extension defines the settings prefix(s) that it uses
(e.g.  "foo.bar.ssl") and then the SSL Service reads those settings
and constructs `SslConfiguration` and `SslProfile` objects from those
settings. The `SslProfile` is provided back to the extension for its
use
Tim Vernum 4 周之前
父节点
当前提交
4d79a59bd7
共有 17 个文件被更改,包括 415 次插入53 次删除
  1. 5 0
      docs/changelog/134609.yaml
  2. 1 0
      x-pack/plugin/core/src/main/java/module-info.java
  3. 12 3
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackPlugin.java
  4. 6 6
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/SSLConfigurationReloader.java
  5. 115 32
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/SSLService.java
  6. 37 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/extension/SslProfileExtension.java
  7. 7 8
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/ssl/SSLConfigurationReloaderTests.java
  8. 69 0
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/ssl/SSLServiceTests.java
  9. 4 3
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/ssl/SslSettingsLoaderTests.java
  10. 17 0
      x-pack/plugin/security/qa/ssl-extension/build.gradle
  11. 60 0
      x-pack/plugin/security/qa/ssl-extension/src/javaRestTest/java/org/elasticsearch/xpack/core/ssl/extension/SslProfileExtensionIT.java
  12. 22 0
      x-pack/plugin/security/qa/ssl-extension/src/javaRestTest/resources/ca.crt
  13. 9 0
      x-pack/plugin/security/qa/ssl-extension/src/main/java/module-info.java
  14. 15 0
      x-pack/plugin/security/qa/ssl-extension/src/main/java/org/elasticsearch/test/xpack/core/ssl/extension/SslExtensionTestPlugin.java
  15. 25 0
      x-pack/plugin/security/qa/ssl-extension/src/main/java/org/elasticsearch/test/xpack/core/ssl/extension/TestSslProfile.java
  16. 8 0
      x-pack/plugin/security/qa/ssl-extension/src/main/resources/META-INF/services/org.elasticsearch.xpack.core.ssl.extension.SslProfileExtension
  17. 3 1
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ldap/LdapSessionFactoryTests.java

+ 5 - 0
docs/changelog/134609.yaml

@@ -0,0 +1,5 @@
+pr: 134609
+summary: Add 'SslProfileExtension' SPI interface
+area: TLS
+type: enhancement
+issues: []

+ 1 - 0
x-pack/plugin/core/src/main/java/module-info.java

@@ -191,6 +191,7 @@ module org.elasticsearch.xcore {
     exports org.elasticsearch.xpack.core.sql;
     exports org.elasticsearch.xpack.core.ssl.action;
     exports org.elasticsearch.xpack.core.ssl.cert;
+    exports org.elasticsearch.xpack.core.ssl.extension;
     exports org.elasticsearch.xpack.core.ssl.rest;
     exports org.elasticsearch.xpack.core.ssl;
     exports org.elasticsearch.xpack.core.template;

+ 12 - 3
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackPlugin.java

@@ -28,7 +28,6 @@ import org.elasticsearch.common.settings.IndexScopedSettings;
 import org.elasticsearch.common.settings.Setting;
 import org.elasticsearch.common.settings.Settings;
 import org.elasticsearch.common.settings.SettingsFilter;
-import org.elasticsearch.common.ssl.SslConfiguration;
 import org.elasticsearch.common.util.BigArrays;
 import org.elasticsearch.core.Booleans;
 import org.elasticsearch.env.Environment;
@@ -110,6 +109,7 @@ import org.elasticsearch.xpack.core.security.authc.TokenMetadata;
 import org.elasticsearch.xpack.core.security.authz.RoleMappingMetadata;
 import org.elasticsearch.xpack.core.ssl.SSLConfigurationReloader;
 import org.elasticsearch.xpack.core.ssl.SSLService;
+import org.elasticsearch.xpack.core.ssl.extension.SslProfileExtension;
 import org.elasticsearch.xpack.core.termsenum.action.TermsEnumAction;
 import org.elasticsearch.xpack.core.termsenum.action.TransportTermsEnumAction;
 import org.elasticsearch.xpack.core.termsenum.rest.RestTermsEnumAction;
@@ -185,6 +185,8 @@ public class XPackPlugin extends XPackClientPlugin
     private static SetOnce<XPackLicenseState> licenseState = new SetOnce<>();
     private static SetOnce<LicenseService> licenseService = new SetOnce<>();
 
+    private final List<SslProfileExtension> sslExtensions = new ArrayList<>();
+
     public XPackPlugin(final Settings settings) {
         super();
         // FIXME: The settings might be changed after this (e.g. from "additionalSettings" method in other plugins)
@@ -465,6 +467,8 @@ public class XPackPlugin extends XPackClientPlugin
         List<Setting<?>> settings = super.getSettings();
         settings.add(SourceOnlySnapshotRepository.SOURCE_ONLY);
 
+        settings.addAll(SSLService.getExtensionSettings(this.sslExtensions));
+
         // Don't register the license setting if there is an alternate implementation loaded as an extension.
         // this relies on the order in which methods are called - loadExtensions, (this method) getSettings, then createComponents
         if (getSharedLicenseService() == null) {
@@ -496,9 +500,9 @@ public class XPackPlugin extends XPackClientPlugin
      * of SSLContexts when configuration files change on disk.
      */
     private SSLService createSSLService(Environment environment, ResourceWatcherService resourceWatcherService) {
-        final Map<String, SslConfiguration> sslConfigurations = SSLService.getSSLConfigurations(environment);
+        final SSLService.LoadedSslConfigurations sslConfigurations = SSLService.getSSLConfigurations(environment, this.sslExtensions);
         // Must construct the reloader before the SSL service so that we don't miss any config changes, see #54867
-        final SSLConfigurationReloader reloader = new SSLConfigurationReloader(resourceWatcherService, sslConfigurations.values());
+        final SSLConfigurationReloader reloader = new SSLConfigurationReloader(resourceWatcherService, sslConfigurations);
         final SSLService sslService = new SSLService(environment, sslConfigurations);
         reloader.setSSLService(sslService);
         setSslService(sslService);
@@ -507,6 +511,11 @@ public class XPackPlugin extends XPackClientPlugin
 
     @Override
     public void loadExtensions(ExtensionLoader loader) {
+        loadLicenseService(loader);
+        this.sslExtensions.addAll(loader.loadExtensions(SslProfileExtension.class));
+    }
+
+    private void loadLicenseService(ExtensionLoader loader) {
         List<MutableLicenseService> licenseServices = loader.loadExtensions(MutableLicenseService.class);
         if (licenseServices.size() > 1) {
             throw new IllegalStateException(MutableLicenseService.class + " may not have multiple implementations");

+ 6 - 6
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/SSLConfigurationReloader.java

@@ -45,17 +45,17 @@ public final class SSLConfigurationReloader {
         }
     };
 
-    public SSLConfigurationReloader(ResourceWatcherService resourceWatcherService, Collection<SslConfiguration> sslConfigurations) {
-        startWatching(reloadConsumer(sslServiceFuture), resourceWatcherService, sslConfigurations);
+    public SSLConfigurationReloader(ResourceWatcherService resourceWatcherService, SSLService.LoadedSslConfigurations sslConfiguration) {
+        startWatching(reloadConsumer(sslServiceFuture), resourceWatcherService, sslConfiguration);
     }
 
     // for testing
     SSLConfigurationReloader(
         Consumer<SslConfiguration> reloadConsumer,
         ResourceWatcherService resourceWatcherService,
-        Collection<SslConfiguration> sslConfigurations
+        SSLService.LoadedSslConfigurations sslConfiguration
     ) {
-        startWatching(reloadConsumer, resourceWatcherService, sslConfigurations);
+        startWatching(reloadConsumer, resourceWatcherService, sslConfiguration);
     }
 
     public void setSSLService(SSLService sslService) {
@@ -84,10 +84,10 @@ public final class SSLConfigurationReloader {
     private static void startWatching(
         Consumer<SslConfiguration> reloadConsumer,
         ResourceWatcherService resourceWatcherService,
-        Collection<SslConfiguration> sslConfigurations
+        SSLService.LoadedSslConfigurations sslConfigurations
     ) {
         Map<Path, List<SslConfiguration>> pathToConfigurationsMap = new HashMap<>();
-        for (SslConfiguration sslConfiguration : sslConfigurations) {
+        for (SslConfiguration sslConfiguration : sslConfigurations.configurations()) {
             final Collection<Path> filesToMonitor = sslConfiguration.getDependentFiles();
             for (Path file : filesToMonitor) {
                 pathToConfigurationsMap.compute(file, (path, list) -> {

+ 115 - 32
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/SSLService.java

@@ -27,10 +27,12 @@ import org.elasticsearch.common.ssl.SslKeyConfig;
 import org.elasticsearch.common.ssl.SslTrustConfig;
 import org.elasticsearch.common.util.Maps;
 import org.elasticsearch.common.util.set.Sets;
+import org.elasticsearch.core.Tuple;
 import org.elasticsearch.env.Environment;
 import org.elasticsearch.xpack.core.XPackSettings;
 import org.elasticsearch.xpack.core.common.socket.SocketAccess;
 import org.elasticsearch.xpack.core.ssl.cert.CertificateInfo;
+import org.elasticsearch.xpack.core.ssl.extension.SslProfileExtension;
 import org.elasticsearch.xpack.core.watcher.WatcherField;
 
 import java.io.IOException;
@@ -111,23 +113,74 @@ public class SSLService {
         Setting.Property.NodeScope
     );
 
+    public static Collection<? extends Setting<?>> getExtensionSettings(List<SslProfileExtension> extensions) {
+        Objects.requireNonNull(extensions, "SSL Extensions not configured yet");
+
+        return getSettingPrefixes(extensions).entrySet()
+            .stream()
+            .peek(e -> logger.info("SSL extension [{}] defines context [{}]", e.getValue().getClass(), e.getKey()))
+            .map(Map.Entry::getKey)
+            .map(prefix -> SSLConfigurationSettings.withPrefix(prefix + ".", false))
+            .map(SSLConfigurationSettings::getEnabledSettings)
+            .flatMap(Collection::stream)
+            .toList();
+    }
+
+    /**
+     *  Used for sharing internal configuration information between {@link SSLService} and {@link SSLConfigurationReloader}
+     */
+    public static class LoadedSslConfigurations {
+        /**
+         * This is a mapping from "context name" (in general use, the name of a setting key)
+         * to a configuration.
+         * This allows us to easily answer the question "What is the configuration for ssl in realm XYZ?"
+         * Multiple "context names" may map to the same configuration (either by object-identity or by object-equality).
+         * For example "xpack.http.ssl" may exist as a name in this map and have the global ssl configuration as a value
+         */
+        private final Map<String, SslConfiguration> configurations;
+        /**
+         * This is a mapping from "context name" (aka "profile name") to the extension class that defined it.
+         * A name will only exist in this map if it was defined by an extension. There may be (will be) entries in {@link #configurations}
+         * for which there is no corresponding entry in {@code extensions}.
+         */
+        private final Map<String, SslProfileExtension> extensions;
+
+        public LoadedSslConfigurations(Map<String, SslConfiguration> configurations, Map<String, SslProfileExtension> extensions) {
+            this.configurations = configurations;
+            this.extensions = extensions;
+            extensions.keySet().forEach(extKey -> {
+                assert configurations.containsKey(extKey) : "Extension context [" + extKey + "] does not have a configuration";
+            });
+        }
+
+        /**
+         * Package access for use in {@link SSLConfigurationReloader}
+         */
+        Iterable<SslConfiguration> configurations() {
+            return Collections.unmodifiableCollection(this.configurations.values());
+        }
+
+        // package access for test
+        SslConfiguration configuration(String name) {
+            return this.configurations.get(name);
+        }
+
+        // package access for test
+        Map<String, SslProfileExtension> extensions() {
+            return Collections.unmodifiableMap(extensions);
+        }
+    }
+
     private final Environment env;
     private final Settings settings;
     private final boolean diagnoseTrustExceptions;
 
-    /**
-     * This is a mapping from "context name" (in general use, the name of a setting key)
-     * to a configuration.
-     * This allows us to easily answer the question "What is the configuration for ssl in realm XYZ?"
-     * Multiple "context names" may map to the same configuration (either by object-identity or by object-equality).
-     * For example "xpack.http.ssl" may exist as a name in this map and have the global ssl configuration as a value
-     */
-    private final Map<String, SslConfiguration> sslConfigurations;
+    private final LoadedSslConfigurations loadedConfiguration;
 
     /**
      * A mapping from an SslConfiguration to a pre-built context.
      * <p>
-     * This is managed separately to the {@link #sslConfigurations} map, so that a single configuration (by object equality)
+     * This is managed separately to the {@link #loadedConfiguration} map, so that a single configuration (by object equality)
      * always maps to the same {@link SSLContextHolder}, even if it is being used within a different context-name.
      */
     private final Map<SslConfiguration, SSLContextHolder> sslContexts;
@@ -145,12 +198,12 @@ public class SSLService {
      * contexts created from these configurations will be cached.
      */
     @SuppressWarnings("this-escape")
-    public SSLService(Environment environment, Map<String, SslConfiguration> sslConfigurations) {
+    public SSLService(Environment environment, LoadedSslConfigurations loadedConfiguration) {
         this.env = environment;
         this.settings = env.settings();
         this.diagnoseTrustExceptions = DIAGNOSE_TRUST_EXCEPTIONS_SETTING.get(environment.settings());
-        this.sslConfigurations = sslConfigurations;
-        this.sslContexts = loadSslConfigurations(this.sslConfigurations);
+        this.loadedConfiguration = loadedConfiguration;
+        this.sslContexts = loadSslConfigurations(this.loadedConfiguration);
     }
 
     @SuppressWarnings("this-escape")
@@ -159,19 +212,19 @@ public class SSLService {
         this.env = environment;
         this.settings = env.settings();
         this.diagnoseTrustExceptions = DIAGNOSE_TRUST_EXCEPTIONS_SETTING.get(settings);
-        this.sslConfigurations = getSSLConfigurations(env, this.settings);
-        this.sslContexts = loadSslConfigurations(this.sslConfigurations);
+        this.loadedConfiguration = getSSLConfigurations(env, this.settings);
+        this.sslContexts = loadSslConfigurations(this.loadedConfiguration);
     }
 
     private SSLService(
         Environment environment,
-        Map<String, SslConfiguration> sslConfigurations,
+        LoadedSslConfigurations loadedConfiguration,
         Map<SslConfiguration, SSLContextHolder> sslContexts
     ) {
         this.env = environment;
         this.settings = env.settings();
         this.diagnoseTrustExceptions = DIAGNOSE_TRUST_EXCEPTIONS_SETTING.get(environment.settings());
-        this.sslConfigurations = sslConfigurations;
+        this.loadedConfiguration = loadedConfiguration;
         this.sslContexts = sslContexts;
     }
 
@@ -181,10 +234,10 @@ public class SSLService {
      * have been created during initialization
      */
     public SSLService createDynamicSSLService() {
-        return new SSLService(env, sslConfigurations, sslContexts) {
+        return new SSLService(env, loadedConfiguration, sslContexts) {
 
             @Override
-            Map<SslConfiguration, SSLContextHolder> loadSslConfigurations(Map<String, SslConfiguration> unused) {
+            Map<SslConfiguration, SSLContextHolder> loadSslConfigurations(LoadedSslConfigurations unused) {
                 // we don't need to load anything...
                 return Collections.emptyMap();
             }
@@ -296,7 +349,7 @@ public class SSLService {
     }
 
     public Set<String> getTransportProfileContextNames() {
-        return this.sslConfigurations.keySet()
+        return this.loadedConfiguration.configurations.keySet()
             .stream()
             .filter(k -> k.startsWith("transport.profiles."))
             .collect(Collectors.toUnmodifiableSet());
@@ -404,7 +457,7 @@ public class SSLService {
             // but listing all of them would be confusing (e.g. some might be the default realms)
             // This needs to be a supplier (deferred evaluation) because we might load more configurations after this context is built.
             final Supplier<String> contextName = () -> {
-                final List<String> names = sslConfigurations.entrySet()
+                final List<String> names = loadedConfiguration.configurations.entrySet()
                     .stream()
                     .filter(e -> e.getValue().equals(configuration))
                     .limit(2) // we only need to distinguishing between 0/1/many
@@ -422,12 +475,19 @@ public class SSLService {
         return trustManager;
     }
 
-    public static Map<String, SslConfiguration> getSSLConfigurations(Environment env) {
-        return getSSLConfigurations(env, env.settings());
+    public static LoadedSslConfigurations getSSLConfigurations(Environment env, List<SslProfileExtension> extensions) {
+        return getSSLConfigurations(env, env.settings(), extensions);
     }
 
-    private static Map<String, SslConfiguration> getSSLConfigurations(Environment env, Settings settings) {
-        final Map<String, Settings> sslSettingsMap = getSSLSettingsMap(settings);
+    @Deprecated
+    private static LoadedSslConfigurations getSSLConfigurations(Environment env, Settings settings) {
+        return getSSLConfigurations(env, settings, List.of());
+    }
+
+    private static LoadedSslConfigurations getSSLConfigurations(Environment env, Settings settings, List<SslProfileExtension> extensions) {
+        final Map<String, SslProfileExtension> extensionContexts = getSettingPrefixes(extensions);
+
+        final Map<String, Settings> sslSettingsMap = getSSLSettingsMap(settings, extensionContexts.keySet());
         final Map<String, SslConfiguration> sslConfigurationMap = Maps.newMapWithExpectedSize(sslSettingsMap.size());
         sslSettingsMap.forEach((key, sslSettings) -> {
             if (key.endsWith(".")) {
@@ -440,7 +500,23 @@ public class SSLService {
                 throw new ElasticsearchSecurityException("failed to load SSL configuration [{}] - {}", e, key, e.getMessage());
             }
         });
-        return Collections.unmodifiableMap(sslConfigurationMap);
+        return new LoadedSslConfigurations(sslConfigurationMap, extensionContexts);
+    }
+
+    private static Map<String, SslProfileExtension> getSettingPrefixes(List<SslProfileExtension> extensions) {
+        return extensions.stream().flatMap(ext -> ext.getSettingPrefixes().stream().map(prefix -> {
+            if (prefix.endsWith(".ssl")) {
+                return new Tuple<>(prefix, ext);
+            } else {
+                final String message = Strings.format(
+                    "SSL Extension [%s] defines setting prefix [%s] that does not end with [.ssl]",
+                    ext,
+                    prefix
+                );
+                logger.warn(message);
+                throw new IllegalArgumentException(message);
+            }
+        })).collect(Collectors.toMap(Tuple::v1, Tuple::v2));
     }
 
     private static Function<KeyStore, KeyStore> getKeyStoreFilter(String sslContext) {
@@ -474,7 +550,7 @@ public class SSLService {
         return null;
     }
 
-    static Map<String, Settings> getSSLSettingsMap(Settings settings) {
+    static Map<String, Settings> getSSLSettingsMap(Settings settings, Set<String> additionalContexts) {
         final Map<String, Settings> sslSettingsMap = new HashMap<>();
         sslSettingsMap.put(XPackSettings.HTTP_SSL_PREFIX, getHttpTransportSSLSettings(settings));
         sslSettingsMap.put("xpack.http.ssl", settings.getByPrefix("xpack.http.ssl."));
@@ -494,17 +570,24 @@ public class SSLService {
             XPackSettings.REMOTE_CLUSTER_CLIENT_SSL_PREFIX,
             settings.getByPrefix(XPackSettings.REMOTE_CLUSTER_CLIENT_SSL_PREFIX)
         );
+        additionalContexts.forEach(prefix -> sslSettingsMap.put(prefix, settings.getByPrefix(prefix + ".")));
         return Collections.unmodifiableMap(sslSettingsMap);
     }
 
     /**
      * Parses the settings to load all SslConfiguration objects that will be used.
      */
-    Map<SslConfiguration, SSLContextHolder> loadSslConfigurations(Map<String, SslConfiguration> sslConfigurationMap) {
-        final Map<SslConfiguration, SSLContextHolder> sslContextHolders = Maps.newMapWithExpectedSize(sslConfigurationMap.size());
-        sslConfigurationMap.forEach((key, sslConfiguration) -> {
+    Map<SslConfiguration, SSLContextHolder> loadSslConfigurations(LoadedSslConfigurations loadedConfiguration) {
+        final Map<SslConfiguration, SSLContextHolder> sslContextHolders = Maps.newMapWithExpectedSize(
+            loadedConfiguration.configurations.size()
+        );
+        loadedConfiguration.configurations.forEach((key, sslConfiguration) -> {
             try {
-                sslContextHolders.computeIfAbsent(sslConfiguration, this::createSslContext);
+                var context = sslContextHolders.computeIfAbsent(sslConfiguration, this::createSslContext);
+                var extension = loadedConfiguration.extensions.get(key);
+                if (extension != null) {
+                    extension.applyProfile(key, context);
+                }
             } catch (SslConfigException e) {
                 throw new ElasticsearchSecurityException("failed to load SSL configuration [{}] - {}", e, key, e.getMessage());
             } catch (Exception e) {
@@ -898,12 +981,12 @@ public class SSLService {
         if (contextName.endsWith(".")) {
             contextName = contextName.substring(0, contextName.length() - 1);
         }
-        final SslConfiguration configuration = sslConfigurations.get(contextName);
+        final SslConfiguration configuration = loadedConfiguration.configurations.get(contextName);
         if (configuration == null) {
             logger.warn(
                 "Cannot find SSL configuration for context {}. Known contexts are: {}",
                 contextName,
-                Strings.collectionToCommaDelimitedString(sslConfigurations.keySet())
+                Strings.collectionToCommaDelimitedString(loadedConfiguration.configurations.keySet())
             );
         } else {
             logger.debug("SSL configuration [{}] is [{}]", contextName, configuration);

+ 37 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/extension/SslProfileExtension.java

@@ -0,0 +1,37 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.core.ssl.extension;
+
+import org.elasticsearch.xpack.core.ssl.SslProfile;
+
+import java.util.Set;
+
+import javax.net.ssl.SSLContext;
+
+/**
+ * A SPI extension point for defining SSL profiles.
+ * Elasticsearch has a standard way of defining SSL Configuration in YAML (see {@link org.elasticsearch.common.ssl.SslConfigurationLoader})
+ * and we refer to each of these as either a "profile" or "context" (these are interchangeable, and both are used in the code,
+ * however the latter can be confused with {@link SSLContext}).
+ * Each profile is loaded on node startup, validated and its source files (PEM certificates, etc) are monitored for changes.
+ * This extension point makes it easy for modules and plugins to define new profiles.
+ */
+public interface SslProfileExtension {
+
+    /**
+     * @return the setting prefixes that this extension supports. For example {@code xpack.foo.ssl}
+     * It must end in {@code ".ssl"}
+     */
+    Set<String> getSettingPrefixes();
+
+    /**
+     * Called after each SSL profile has been loaded and validated
+     */
+    void applyProfile(String prefix, SslProfile profile);
+
+}

+ 7 - 8
x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/ssl/SSLConfigurationReloaderTests.java

@@ -66,7 +66,6 @@ import java.security.PrivilegedExceptionAction;
 import java.security.UnrecoverableKeyException;
 import java.security.cert.Certificate;
 import java.security.cert.CertificateException;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
@@ -388,7 +387,7 @@ public class SSLConfigurationReloaderTests extends ESTestCase {
                 latch.countDown();
             }
         };
-        new SSLConfigurationReloader(reloadConsumer, resourceWatcherService, SSLService.getSSLConfigurations(env).values());
+        new SSLConfigurationReloader(reloadConsumer, resourceWatcherService, SSLService.getSSLConfigurations(env, List.of()));
 
         final SSLContext context = sslService.sslContextHolder(config).sslContext();
 
@@ -440,7 +439,7 @@ public class SSLConfigurationReloaderTests extends ESTestCase {
                 latch.countDown();
             }
         };
-        new SSLConfigurationReloader(reloadConsumer, resourceWatcherService, SSLService.getSSLConfigurations(env).values());
+        new SSLConfigurationReloader(reloadConsumer, resourceWatcherService, SSLService.getSSLConfigurations(env, List.of()));
 
         final SSLContext context = sslService.sslContextHolder(config).sslContext();
 
@@ -482,7 +481,7 @@ public class SSLConfigurationReloaderTests extends ESTestCase {
                 latch.countDown();
             }
         };
-        new SSLConfigurationReloader(reloadConsumer, resourceWatcherService, SSLService.getSSLConfigurations(env).values());
+        new SSLConfigurationReloader(reloadConsumer, resourceWatcherService, SSLService.getSSLConfigurations(env, List.of()));
 
         final SSLContext context = sslService.sslContextHolder(config).sslContext();
 
@@ -522,7 +521,7 @@ public class SSLConfigurationReloaderTests extends ESTestCase {
                 latch.countDown();
             }
         };
-        new SSLConfigurationReloader(reloadConsumer, resourceWatcherService, SSLService.getSSLConfigurations(env).values());
+        new SSLConfigurationReloader(reloadConsumer, resourceWatcherService, SSLService.getSSLConfigurations(env, List.of()));
 
         final SSLContext context = sslService.sslContextHolder(config).sslContext();
 
@@ -556,7 +555,7 @@ public class SSLConfigurationReloaderTests extends ESTestCase {
         final ResourceWatcherService mockResourceWatcher = Mockito.mock(ResourceWatcherService.class);
         Mockito.when(mockResourceWatcher.add(Mockito.any(), Mockito.any()))
             .thenThrow(randomBoolean() ? new AccessControlException("access denied in test") : new IOException("file error for testing"));
-        final Collection<SslConfiguration> configurations = SSLService.getSSLConfigurations(env).values();
+        final SSLService.LoadedSslConfigurations configurations = SSLService.getSSLConfigurations(env, List.of());
         try {
             new SSLConfigurationReloader(ignore -> {}, mockResourceWatcher, configurations);
         } catch (Exception e) {
@@ -587,7 +586,7 @@ public class SSLConfigurationReloaderTests extends ESTestCase {
         ).put("path.home", createTempDir()).build();
 
         final Environment env = newEnvironment(settings);
-        final Collection<SslConfiguration> configurations = SSLService.getSSLConfigurations(env).values();
+        final SSLService.LoadedSslConfigurations configurations = SSLService.getSSLConfigurations(env, List.of());
         new SSLConfigurationReloader(ignore -> {}, mockResourceWatcher, configurations);
 
         assertThat(
@@ -642,7 +641,7 @@ public class SSLConfigurationReloaderTests extends ESTestCase {
                 }
             }
         };
-        new SSLConfigurationReloader(reloadConsumer, resourceWatcherService, SSLService.getSSLConfigurations(env).values());
+        new SSLConfigurationReloader(reloadConsumer, resourceWatcherService, SSLService.getSSLConfigurations(env, List.of()));
         // Baseline checks
         preChecks.accept(sslService.sslContextHolder(config).sslContext());
 

+ 69 - 0
x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/ssl/SSLServiceTests.java

@@ -28,12 +28,14 @@ import org.elasticsearch.common.ssl.SslVerificationMode;
 import org.elasticsearch.common.ssl.TrustEverythingConfig;
 import org.elasticsearch.core.CheckedRunnable;
 import org.elasticsearch.core.SuppressForbidden;
+import org.elasticsearch.core.Tuple;
 import org.elasticsearch.env.Environment;
 import org.elasticsearch.env.TestEnvironment;
 import org.elasticsearch.test.ESTestCase;
 import org.elasticsearch.test.junit.annotations.Network;
 import org.elasticsearch.xpack.core.XPackSettings;
 import org.elasticsearch.xpack.core.ssl.cert.CertificateInfo;
+import org.elasticsearch.xpack.core.ssl.extension.SslProfileExtension;
 import org.junit.Before;
 
 import java.nio.file.Path;
@@ -53,6 +55,7 @@ import java.util.Enumeration;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -70,12 +73,14 @@ import javax.net.ssl.X509ExtendedTrustManager;
 import javax.net.ssl.X509TrustManager;
 
 import static org.elasticsearch.test.TestMatchers.throwableWithMessage;
+import static org.hamcrest.Matchers.aMapWithSize;
 import static org.hamcrest.Matchers.arrayContainingInAnyOrder;
 import static org.hamcrest.Matchers.arrayWithSize;
 import static org.hamcrest.Matchers.contains;
 import static org.hamcrest.Matchers.containsString;
 import static org.hamcrest.Matchers.equalTo;
 import static org.hamcrest.Matchers.greaterThan;
+import static org.hamcrest.Matchers.hasEntry;
 import static org.hamcrest.Matchers.hasItem;
 import static org.hamcrest.Matchers.hasItemInArray;
 import static org.hamcrest.Matchers.instanceOf;
@@ -972,6 +977,70 @@ public class SSLServiceTests extends ESTestCase {
         }
     }
 
+    public void testLoadProfilesFromExtensions() {
+        final Map<SslProfileExtension, Map<String, SslProfile>> appliedProfiles = new HashMap<>();
+        final Map<String, Tuple<SslClientAuthenticationMode, SslVerificationMode>> allExtensionsPrefixes = new HashMap<>();
+
+        final Settings.Builder settings = Settings.builder().put(env.settings()).put("xpack.http.ssl.verification_mode", "certificate");
+
+        final List<SslProfileExtension> extensions = randomList(1, 3, () -> {
+            final Set<String> prefixes = randomSet(
+                1,
+                3,
+                () -> randomValueOtherThanMany(
+                    allExtensionsPrefixes::containsKey,
+                    () -> randomAlphaOfLengthBetween(3, 8) + "." + randomAlphaOfLengthBetween(5, 10) + ".ssl"
+                )
+            );
+            for (var prefix : prefixes) {
+                final SslClientAuthenticationMode clientAuthMode = randomFrom(SslClientAuthenticationMode.values());
+                final SslVerificationMode verificationMode = randomFrom(SslVerificationMode.values());
+                settings.put(prefix + ".client_authentication", clientAuthMode.name().toLowerCase(Locale.ROOT));
+                settings.put(prefix + ".verification_mode", verificationMode.name().toLowerCase(Locale.ROOT));
+                allExtensionsPrefixes.put(prefix, new Tuple<>(clientAuthMode, verificationMode));
+            }
+            return new SslProfileExtension() {
+
+                @Override
+                public Set<String> getSettingPrefixes() {
+                    return prefixes;
+                }
+
+                @Override
+                public void applyProfile(String name, SslProfile profile) {
+                    appliedProfiles.computeIfAbsent(this, ignore -> new HashMap<>()).put(name, profile);
+                }
+            };
+        });
+
+        env = newEnvironment(settings.build());
+        final SSLService.LoadedSslConfigurations loadedConfiguration = SSLService.getSSLConfigurations(env, extensions);
+
+        assertThat(loadedConfiguration.extensions().keySet(), equalTo(allExtensionsPrefixes.keySet()));
+        for (var ext : extensions) {
+            for (var ctx : ext.getSettingPrefixes()) {
+                assertThat(loadedConfiguration.extensions(), hasEntry(ctx, ext));
+                final SslConfiguration cfg = loadedConfiguration.configuration(ctx);
+                assertThat(cfg, notNullValue());
+                assertThat(cfg.clientAuth(), equalTo(allExtensionsPrefixes.get(ctx).v1()));
+                assertThat(cfg.verificationMode(), equalTo(allExtensionsPrefixes.get(ctx).v2()));
+            }
+        }
+        assertThat(appliedProfiles, aMapWithSize(0));
+
+        final SSLService service = new SSLService(env, loadedConfiguration);
+
+        assertThat(appliedProfiles.keySet(), equalTo(Set.copyOf(extensions)));
+        for (var ext : extensions) {
+            for (var ctx : ext.getSettingPrefixes()) {
+                var profile = appliedProfiles.get(ext).get(ctx);
+                assertThat(profile, notNullValue());
+                assertThat(profile.configuration(), equalTo(loadedConfiguration.configuration(ctx)));
+                assertThat(service.profile(ctx), sameInstance(profile));
+            }
+        }
+    }
+
     private CloseableHttpAsyncClient getAsyncHttpClient(SSLIOSessionStrategy sslStrategy) throws Exception {
         try {
             return AccessController.doPrivileged(

+ 4 - 3
x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/ssl/SslSettingsLoaderTests.java

@@ -31,6 +31,7 @@ import java.nio.file.Path;
 import java.security.cert.X509Certificate;
 import java.util.Arrays;
 import java.util.Map;
+import java.util.Set;
 import java.util.stream.Collectors;
 
 import javax.net.ssl.KeyManager;
@@ -80,7 +81,7 @@ public class SslSettingsLoaderTests extends ESTestCase {
         if (randomBoolean()) {
             builder.put(RemoteClusterPortSettings.REMOTE_CLUSTER_SERVER_ENABLED.getKey(), false);
         }
-        final Map<String, Settings> settingsMap = SSLService.getSSLSettingsMap(builder.build());
+        final Map<String, Settings> settingsMap = SSLService.getSSLSettingsMap(builder.build(), Set.of());
         // Server (SSL is not built when port is not enabled)
         assertThat(settingsMap, not(hasKey(XPackSettings.REMOTE_CLUSTER_SERVER_SSL_PREFIX)));
         // Client (SSL is always built)
@@ -98,7 +99,7 @@ public class SslSettingsLoaderTests extends ESTestCase {
      */
     public void testRemoteClusterPortConfigurationIsInjectedWithDefaults() {
         Settings testSettings = Settings.builder().put(RemoteClusterPortSettings.REMOTE_CLUSTER_SERVER_ENABLED.getKey(), true).build();
-        Map<String, Settings> settingsMap = SSLService.getSSLSettingsMap(testSettings);
+        Map<String, Settings> settingsMap = SSLService.getSSLSettingsMap(testSettings, Set.of());
         // Server
         assertThat(settingsMap, hasKey(XPackSettings.REMOTE_CLUSTER_SERVER_SSL_PREFIX));
         SslConfiguration sslConfiguration = getSslConfiguration(settingsMap.get(XPackSettings.REMOTE_CLUSTER_SERVER_SSL_PREFIX));
@@ -131,7 +132,7 @@ public class SslSettingsLoaderTests extends ESTestCase {
             .put(XPackSettings.REMOTE_CLUSTER_CLIENT_SSL_PREFIX + SslConfigurationKeys.VERIFICATION_MODE, "certificate")
             .setSecureSettings(secureSettings)
             .build();
-        Map<String, Settings> settingsMap = SSLService.getSSLSettingsMap(testSettings);
+        Map<String, Settings> settingsMap = SSLService.getSSLSettingsMap(testSettings, Set.of());
 
         // Server
         assertThat(settingsMap, hasKey(XPackSettings.REMOTE_CLUSTER_SERVER_SSL_PREFIX));

+ 17 - 0
x-pack/plugin/security/qa/ssl-extension/build.gradle

@@ -0,0 +1,17 @@
+
+apply plugin: 'elasticsearch.base-internal-es-plugin'
+apply plugin: 'elasticsearch.internal-java-rest-test'
+
+esplugin {
+  name = 'test-ssl-extension'
+  description = 'A test plugin to test the SPI behaviour of SslProfileExtension'
+  classname = 'org.elasticsearch.test.xpack.core.ssl.extension.SslExtensionTestPlugin'
+  extendedPlugins = [ "x-pack-core" ]
+}
+
+dependencies {
+  compileOnly project(':x-pack:plugin:core')
+  clusterPlugins project(':x-pack:plugin:security:qa:ssl-extension')
+}
+
+tasks.named("javadoc").configure { enabled = false }

+ 60 - 0
x-pack/plugin/security/qa/ssl-extension/src/javaRestTest/java/org/elasticsearch/xpack/core/ssl/extension/SslProfileExtensionIT.java

@@ -0,0 +1,60 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.core.ssl.extension;
+
+import org.elasticsearch.client.Request;
+import org.elasticsearch.client.Response;
+import org.elasticsearch.common.settings.SecureString;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.common.util.concurrent.ThreadContext;
+import org.elasticsearch.test.cluster.ElasticsearchCluster;
+import org.elasticsearch.test.cluster.local.distribution.DistributionType;
+import org.elasticsearch.test.cluster.util.resource.Resource;
+import org.elasticsearch.test.rest.ESRestTestCase;
+import org.hamcrest.Matchers;
+import org.junit.ClassRule;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.hamcrest.Matchers.everyItem;
+import static org.hamcrest.Matchers.hasEntry;
+import static org.hamcrest.Matchers.hasItem;
+
+public class SslProfileExtensionIT extends ESRestTestCase {
+
+    @ClassRule
+    public static ElasticsearchCluster cluster = ElasticsearchCluster.local()
+        .distribution(DistributionType.INTEG_TEST)
+        .plugin("test-ssl-extension")
+        .configFile("test.ssl.ca.crt", Resource.fromClasspath("ca.crt"))
+        .setting("test.ssl.certificate_authorities", "test.ssl.ca.crt")
+        .setting("xpack.security.enabled", "true")
+        .user("admin", "pass/word")
+        .build();
+
+    @Override
+    protected String getTestRestCluster() {
+        return cluster.getHttpAddresses();
+    }
+
+    @Override
+    protected Settings restClientSettings() {
+        String basicAuth = basicAuthHeaderValue("admin", new SecureString("pass/word".toCharArray()));
+        return Settings.builder().put(super.restClientSettings()).put(ThreadContext.PREFIX + ".Authorization", basicAuth).build();
+    }
+
+    public void testCertificateIsLoaded() throws Exception {
+        final Response certResponse = client().performRequest(new Request("GET", "/_ssl/certificates"));
+        final List<Object> certs = entityAsList(certResponse);
+
+        assertThat(certs, everyItem(Matchers.instanceOf(Map.class)));
+        assertThat(certs, hasItem(hasEntry("path", "test.ssl.ca.crt")));
+    }
+
+}

+ 22 - 0
x-pack/plugin/security/qa/ssl-extension/src/javaRestTest/resources/ca.crt

@@ -0,0 +1,22 @@
+-----BEGIN CERTIFICATE-----
+MIIDpDCCAoygAwIBAgIVANGxEu40f6y7RKB+jQXVFYELQsDqMA0GCSqGSIb3DQEB
+CwUAMFkxEzARBgoJkiaJk/IsZAEZFgNvcmcxHTAbBgoJkiaJk/IsZAEZFg1lbGFz
+dGljc2VhcmNoMQ0wCwYDVQQLEwR0ZXN0MRQwEgYDVQQDEwtzc2wtcHJvZmlsZTAe
+Fw0yNTA5MTExMDM0MzdaFw0yODA5MTAxMDM0MzdaMFkxEzARBgoJkiaJk/IsZAEZ
+FgNvcmcxHTAbBgoJkiaJk/IsZAEZFg1lbGFzdGljc2VhcmNoMQ0wCwYDVQQLEwR0
+ZXN0MRQwEgYDVQQDEwtzc2wtcHJvZmlsZTCCASIwDQYJKoZIhvcNAQEBBQADggEP
+ADCCAQoCggEBANOLE978cjIrvsSnXRBaDllbT0K9pQl/CXVOJ9+rqCEpz1SSOKNB
+L0XB2P/dE67KiuZ9HXIRLwm+N7L0rF9NPOecRpvxc6MW9Zu3yO8B1lzgYBLh7QEF
+pmG1m+y17fpaKE3x2JhEKAihzkligbIDShE94MACDNCyQqJAPOrd9uXrgUMXwVc9
+F5vzmx3nZ7hmgpZWwM5Ms6khTzNYLIyytBBQjriHY34r8oV5yHAAMqbhwEshP8nY
+XugWMAFRhfGQobZxBCZjuHdnZrKAP3jyt7FW0ZiVg/zTngDvJvgpafUR1gHpM/Lr
+xTSNWtAMLfd0sR+inPeXsRCdUflxPBEWSVUCAwEAAaNjMGEwHQYDVR0OBBYEFDKv
+vGiYq0mo0WSF/7an9CeQd9XRMB8GA1UdIwQYMBaAFDKvvGiYq0mo0WSF/7an9CeQ
+d9XRMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEB
+CwUAA4IBAQC0Wt6joSJKuwIrm768qbX7Q2WVcgnhjsYuczZle81D47o5sxIOb2Ir
+O6LDGCeWCM0yti4WPfNikAodZ4H8UoZBHFl95VpJWpDnaAfNB3S6ymXPAhpvktbM
+JSxeHTYKiH8IbrB3JBAItHnutklLr32pFYidG3ZP0AhtNnTWrBVTyVBEQM1vpFfA
+GLKI4zvOU5G+alXE1X1IC/M4nK6NTFkApmWvl9qnHRD1Vq80OAIOqvve/xrCdDwB
+tB/jHlKGHnoyRv5J3d0MWvVac2cngn0VL/yTVkAQE1XOAuw/5XwZxwNXWcWhGRQr
+7hFLYWQKaj9gxKXQwXfnVgoMZC2NKZu3
+-----END CERTIFICATE-----

+ 9 - 0
x-pack/plugin/security/qa/ssl-extension/src/main/java/module-info.java

@@ -0,0 +1,9 @@
+import org.elasticsearch.test.xpack.core.ssl.extension.TestSslProfile;
+import org.elasticsearch.xpack.core.ssl.extension.SslProfileExtension;
+
+module org.elasticsearch.internal.ssl {
+    requires org.elasticsearch.server;
+    requires org.elasticsearch.xcore;
+
+    provides SslProfileExtension with TestSslProfile;
+}

+ 15 - 0
x-pack/plugin/security/qa/ssl-extension/src/main/java/org/elasticsearch/test/xpack/core/ssl/extension/SslExtensionTestPlugin.java

@@ -0,0 +1,15 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.test.xpack.core.ssl.extension;
+
+import org.elasticsearch.plugins.Plugin;
+
+/**
+ * Dummy plugin, does not implement any methods, only exists in order to allow {@link TestSslProfile} to be loaded via SPI.
+ */
+public class SslExtensionTestPlugin extends Plugin {}

+ 25 - 0
x-pack/plugin/security/qa/ssl-extension/src/main/java/org/elasticsearch/test/xpack/core/ssl/extension/TestSslProfile.java

@@ -0,0 +1,25 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.test.xpack.core.ssl.extension;
+
+import org.elasticsearch.xpack.core.ssl.SslProfile;
+import org.elasticsearch.xpack.core.ssl.extension.SslProfileExtension;
+
+import java.util.Set;
+
+public class TestSslProfile implements SslProfileExtension {
+    @Override
+    public Set<String> getSettingPrefixes() {
+        return Set.of("test.ssl");
+    }
+
+    @Override
+    public void applyProfile(String prefix, SslProfile profile) {
+        // no-op
+    }
+}

+ 8 - 0
x-pack/plugin/security/qa/ssl-extension/src/main/resources/META-INF/services/org.elasticsearch.xpack.core.ssl.extension.SslProfileExtension

@@ -0,0 +1,8 @@
+#
+# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+# or more contributor license agreements. Licensed under the Elastic License
+# 2.0; you may not use this file except in compliance with the Elastic License
+# 2.0.
+#
+
+org.elasticsearch.test.xpack.core.ssl.extension.TestSslProfile

+ 3 - 1
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ldap/LdapSessionFactoryTests.java

@@ -320,7 +320,9 @@ public class LdapSessionFactoryTests extends LdapTestCase {
         SecureString userPass = new SecureString("pass");
 
         try (ResourceWatcherService resourceWatcher = new ResourceWatcherService(settings, threadPool)) {
-            new SSLConfigurationReloader(resourceWatcher, SSLService.getSSLConfigurations(environment).values()).setSSLService(sslService);
+            new SSLConfigurationReloader(resourceWatcher, SSLService.getSSLConfigurations(environment, List.of())).setSSLService(
+                sslService
+            );
 
             final FileTime oldModifiedTime = Files.getLastModifiedTime(ldapCaPath);
             Files.copy(fakeCa, ldapCaPath, StandardCopyOption.REPLACE_EXISTING);