From 9a70591f31e8e30e096c82efaad1ff612d74cf96 Mon Sep 17 00:00:00 2001 From: Laura Trotta Date: Tue, 12 Aug 2025 16:17:55 +0200 Subject: [PATCH 1/2] Add support for Elasticsearch API-key-based authentication See gh-46167 Signed-off-by: Laura Trotta --- .../ElasticsearchConnectionDetails.java | 8 +++++++ .../ElasticsearchProperties.java | 12 ++++++++++ ...ElasticsearchRestClientConfigurations.java | 13 +++++++++++ ...earchRestClientAutoConfigurationTests.java | 23 +++++++++++++++++++ 4 files changed, 56 insertions(+) diff --git a/module/spring-boot-elasticsearch/src/main/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchConnectionDetails.java b/module/spring-boot-elasticsearch/src/main/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchConnectionDetails.java index 6c3ae251162..10b99bd6f8e 100644 --- a/module/spring-boot-elasticsearch/src/main/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchConnectionDetails.java +++ b/module/spring-boot-elasticsearch/src/main/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchConnectionDetails.java @@ -57,6 +57,14 @@ public interface ElasticsearchConnectionDetails extends ConnectionDetails { return null; } + /** + * APIKey for authentication with Elasticsearch. + * @return APIKey for authentication with Elasticsearch or {@code null} + */ + default @Nullable String getAPIKey() { + return null; + } + /** * Prefix added to the path of every request sent to Elasticsearch. * @return prefix added to the path of every request sent to Elasticsearch or diff --git a/module/spring-boot-elasticsearch/src/main/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchProperties.java b/module/spring-boot-elasticsearch/src/main/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchProperties.java index d6d98718686..4189533bb42 100644 --- a/module/spring-boot-elasticsearch/src/main/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchProperties.java +++ b/module/spring-boot-elasticsearch/src/main/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchProperties.java @@ -48,6 +48,10 @@ public class ElasticsearchProperties { * Password for authentication with Elasticsearch. */ private @Nullable String password; + /** + * APIKey for authentication with Elasticsearch. + */ + private @Nullable String APIKey; /** * Connection timeout used when communicating with Elasticsearch. @@ -95,6 +99,14 @@ public class ElasticsearchProperties { this.password = password; } + public @Nullable String getAPIKey() { + return this.APIKey; + } + + public void setAPIKey(@Nullable String APIKey) { + this.APIKey = APIKey; + } + public Duration getConnectionTimeout() { return this.connectionTimeout; } diff --git a/module/spring-boot-elasticsearch/src/main/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchRestClientConfigurations.java b/module/spring-boot-elasticsearch/src/main/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchRestClientConfigurations.java index 5ca31778274..253c7727fdf 100644 --- a/module/spring-boot-elasticsearch/src/main/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchRestClientConfigurations.java +++ b/module/spring-boot-elasticsearch/src/main/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchRestClientConfigurations.java @@ -36,7 +36,9 @@ import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder; import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy; import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; +import org.apache.hc.core5.http.Header; import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.message.BasicHeader; import org.apache.hc.core5.reactor.IOReactorConfig; import org.apache.hc.core5.reactor.ssl.SSLBufferMode; import org.apache.hc.core5.util.Timeout; @@ -67,6 +69,7 @@ import org.springframework.util.StringUtils; * @author Moritz Halbritter * @author Andy Wilkinson * @author Phillip Webb + * @author Laura Trotta */ class ElasticsearchRestClientConfigurations { @@ -99,6 +102,11 @@ class ElasticsearchRestClientConfigurations { .stream() .map((node) -> new HttpHost(node.protocol().getScheme(), node.hostname(), node.port())) .toArray(HttpHost[]::new)); + if (connectionDetails.getAPIKey() != null) { + builder.setDefaultHeaders(new Header[]{ + new BasicHeader("Authorization", "ApiKey " + connectionDetails.getAPIKey()), + }); + } builder.setHttpClientConfigCallback((httpClientBuilder) -> builderCustomizers.orderedStream() .forEach((customizer) -> customizer.customize(httpClientBuilder))); builder.setConnectionManagerCallback((connectionManagerBuilder) -> builderCustomizers.orderedStream() @@ -275,6 +283,11 @@ class ElasticsearchRestClientConfigurations { return this.properties.getPassword(); } + @Override + public @Nullable String getAPIKey() { + return this.properties.getAPIKey(); + } + @Override public @Nullable String getPathPrefix() { return this.properties.getPathPrefix(); diff --git a/module/spring-boot-elasticsearch/src/test/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchRestClientAutoConfigurationTests.java b/module/spring-boot-elasticsearch/src/test/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchRestClientAutoConfigurationTests.java index a80a819d0d6..d35091a1bf1 100644 --- a/module/spring-boot-elasticsearch/src/test/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchRestClientAutoConfigurationTests.java +++ b/module/spring-boot-elasticsearch/src/test/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchRestClientAutoConfigurationTests.java @@ -19,6 +19,7 @@ package org.springframework.boot.elasticsearch.autoconfigure; import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import co.elastic.clients.transport.rest5_client.low_level.Node; import co.elastic.clients.transport.rest5_client.low_level.Rest5Client; @@ -33,6 +34,7 @@ import org.apache.hc.client5.http.config.RequestConfig; import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder; import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder; import org.apache.hc.core5.function.Resolver; +import org.apache.hc.core5.http.Header; import org.apache.hc.core5.http.HttpHost; import org.apache.hc.core5.http.config.Registry; import org.apache.hc.core5.util.Timeout; @@ -47,6 +49,7 @@ import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.boot.testsupport.classpath.resources.WithPackageResources; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.then; @@ -62,6 +65,7 @@ import static org.mockito.Mockito.mock; * @author Andy Wilkinson * @author Moritz Halbritter * @author Phillip Webb + * @author Laura Trotta */ class ElasticsearchRestClientAutoConfigurationTests { @@ -134,6 +138,25 @@ class ElasticsearchRestClientAutoConfigurationTests { }); } + @Test + void configureUriWithAPiKey() { + this.contextRunner.withPropertyValues("spring.elasticsearch.uris=http://user@localhost:9200","spring.elasticsearch.apikey=some-apiKey").run((context) -> { + Rest5Client client = context.getBean(Rest5Client.class); + assertThat(client.getNodes().stream().map(Node::getHost).map(HttpHost::toString)) + .containsExactly("http://localhost:9200"); + assertThat(client) + .extracting("defaultHeaders", InstanceOfAssertFactories.list(Header.class)) + .satisfies(( defaultHeaders) -> { + Optional authHeader = defaultHeaders.stream() + .filter(x -> x.getName().equals("Authorization")) + .findFirst(); + assertThat(authHeader).isPresent(); + assertThat(authHeader.get().getValue()).isEqualTo("ApiKey some-apiKey"); + }); + }); + } + + @Test void configureUriWithUsernameOnly() { this.contextRunner.withPropertyValues("spring.elasticsearch.uris=http://user@localhost:9200").run((context) -> { From 86deef6abfb08bc53a49d63d677bd4074c3c9dbd Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Tue, 26 Aug 2025 11:06:28 +0100 Subject: [PATCH 2/2] Polish "Add support for Elasticsearch API-key-based authentication" See gh-46167 --- .../ElasticsearchConnectionDetails.java | 4 +- .../ElasticsearchProperties.java | 13 +++-- ...ElasticsearchRestClientConfigurations.java | 11 ++-- ...earchRestClientAutoConfigurationTests.java | 56 ++++++++++++------- 4 files changed, 49 insertions(+), 35 deletions(-) diff --git a/module/spring-boot-elasticsearch/src/main/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchConnectionDetails.java b/module/spring-boot-elasticsearch/src/main/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchConnectionDetails.java index 10b99bd6f8e..e3ccd83381e 100644 --- a/module/spring-boot-elasticsearch/src/main/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchConnectionDetails.java +++ b/module/spring-boot-elasticsearch/src/main/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchConnectionDetails.java @@ -59,9 +59,9 @@ public interface ElasticsearchConnectionDetails extends ConnectionDetails { /** * APIKey for authentication with Elasticsearch. - * @return APIKey for authentication with Elasticsearch or {@code null} + * @return the API key for authentication with Elasticsearch or {@code null} */ - default @Nullable String getAPIKey() { + default @Nullable String getApiKey() { return null; } diff --git a/module/spring-boot-elasticsearch/src/main/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchProperties.java b/module/spring-boot-elasticsearch/src/main/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchProperties.java index 4189533bb42..1c5143cbe3c 100644 --- a/module/spring-boot-elasticsearch/src/main/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchProperties.java +++ b/module/spring-boot-elasticsearch/src/main/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchProperties.java @@ -48,10 +48,11 @@ public class ElasticsearchProperties { * Password for authentication with Elasticsearch. */ private @Nullable String password; + /** - * APIKey for authentication with Elasticsearch. + * API key for authentication with Elasticsearch. */ - private @Nullable String APIKey; + private @Nullable String apiKey; /** * Connection timeout used when communicating with Elasticsearch. @@ -99,12 +100,12 @@ public class ElasticsearchProperties { this.password = password; } - public @Nullable String getAPIKey() { - return this.APIKey; + public @Nullable String getApiKey() { + return this.apiKey; } - public void setAPIKey(@Nullable String APIKey) { - this.APIKey = APIKey; + public void setApiKey(@Nullable String apiKey) { + this.apiKey = apiKey; } public Duration getConnectionTimeout() { diff --git a/module/spring-boot-elasticsearch/src/main/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchRestClientConfigurations.java b/module/spring-boot-elasticsearch/src/main/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchRestClientConfigurations.java index 253c7727fdf..ca6d738c237 100644 --- a/module/spring-boot-elasticsearch/src/main/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchRestClientConfigurations.java +++ b/module/spring-boot-elasticsearch/src/main/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchRestClientConfigurations.java @@ -102,10 +102,9 @@ class ElasticsearchRestClientConfigurations { .stream() .map((node) -> new HttpHost(node.protocol().getScheme(), node.hostname(), node.port())) .toArray(HttpHost[]::new)); - if (connectionDetails.getAPIKey() != null) { - builder.setDefaultHeaders(new Header[]{ - new BasicHeader("Authorization", "ApiKey " + connectionDetails.getAPIKey()), - }); + if (connectionDetails.getApiKey() != null) { + builder.setDefaultHeaders( + new Header[] { new BasicHeader("Authorization", "ApiKey " + connectionDetails.getApiKey()), }); } builder.setHttpClientConfigCallback((httpClientBuilder) -> builderCustomizers.orderedStream() .forEach((customizer) -> customizer.customize(httpClientBuilder))); @@ -284,8 +283,8 @@ class ElasticsearchRestClientConfigurations { } @Override - public @Nullable String getAPIKey() { - return this.properties.getAPIKey(); + public @Nullable String getApiKey() { + return this.properties.getApiKey(); } @Override diff --git a/module/spring-boot-elasticsearch/src/test/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchRestClientAutoConfigurationTests.java b/module/spring-boot-elasticsearch/src/test/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchRestClientAutoConfigurationTests.java index d35091a1bf1..64e07a15532 100644 --- a/module/spring-boot-elasticsearch/src/test/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchRestClientAutoConfigurationTests.java +++ b/module/spring-boot-elasticsearch/src/test/java/org/springframework/boot/elasticsearch/autoconfigure/ElasticsearchRestClientAutoConfigurationTests.java @@ -19,7 +19,6 @@ package org.springframework.boot.elasticsearch.autoconfigure; import java.time.Duration; import java.util.ArrayList; import java.util.List; -import java.util.Optional; import co.elastic.clients.transport.rest5_client.low_level.Node; import co.elastic.clients.transport.rest5_client.low_level.Rest5Client; @@ -49,7 +48,6 @@ import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.boot.testsupport.classpath.resources.WithPackageResources; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.web.client.RestClient; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.then; @@ -138,25 +136,6 @@ class ElasticsearchRestClientAutoConfigurationTests { }); } - @Test - void configureUriWithAPiKey() { - this.contextRunner.withPropertyValues("spring.elasticsearch.uris=http://user@localhost:9200","spring.elasticsearch.apikey=some-apiKey").run((context) -> { - Rest5Client client = context.getBean(Rest5Client.class); - assertThat(client.getNodes().stream().map(Node::getHost).map(HttpHost::toString)) - .containsExactly("http://localhost:9200"); - assertThat(client) - .extracting("defaultHeaders", InstanceOfAssertFactories.list(Header.class)) - .satisfies(( defaultHeaders) -> { - Optional authHeader = defaultHeaders.stream() - .filter(x -> x.getName().equals("Authorization")) - .findFirst(); - assertThat(authHeader).isPresent(); - assertThat(authHeader.get().getValue()).isEqualTo("ApiKey some-apiKey"); - }); - }); - } - - @Test void configureUriWithUsernameOnly() { this.contextRunner.withPropertyValues("spring.elasticsearch.uris=http://user@localhost:9200").run((context) -> { @@ -216,6 +195,41 @@ class ElasticsearchRestClientAutoConfigurationTests { }); } + @Test + void whenApiKeyIsConfiguredThenAuthorizationHeaderIsPresent() { + this.contextRunner.withPropertyValues("spring.elasticsearch.api-key=some-api-key").run((context) -> { + Rest5Client client = context.getBean(Rest5Client.class); + assertThat(client).extracting("defaultHeaders", InstanceOfAssertFactories.list(Header.class)) + .satisfiesOnlyOnce((header) -> { + assertThat(header.getName().equals("Authorization")); + assertThat(header.getValue().equals("ApiKey some-api-key")); + }); + }); + } + + @Test + void whenApiKeyAndUsernameAndPasswordAreConfiguredThenBothFormsOfCredentialsArePresent() { + this.contextRunner + .withPropertyValues("spring.elasticsearch.api-key=some-api-key", "spring.elasticsearch.username=alice", + "spring.elasticsearch.password=secret") + .run((context) -> { + Rest5Client client = context.getBean(Rest5Client.class); + assertThat(client).extracting("defaultHeaders", InstanceOfAssertFactories.list(Header.class)) + .satisfiesOnlyOnce((header) -> { + assertThat(header.getName().equals("Authorization")); + assertThat(header.getValue().equals("ApiKey some-api-key")); + }); + assertThat(client) + .extracting("client.credentialsProvider", InstanceOfAssertFactories.type(CredentialsProvider.class)) + .satisfies((credentialsProvider) -> { + UsernamePasswordCredentials defaultCredentials = (UsernamePasswordCredentials) credentialsProvider + .getCredentials(new AuthScope(null, -1), null); + assertThat(defaultCredentials.getUserPrincipal().getName()).isEqualTo("alice"); + assertThat(defaultCredentials.getUserPassword()).containsExactly("secret".toCharArray()); + }); + }); + } + @Test void configureWithCustomPathPrefix() { this.contextRunner.withPropertyValues("spring.elasticsearch.path-prefix=/some/prefix").run((context) -> {