diff --git a/spring-cloud-config-server/pom.xml b/spring-cloud-config-server/pom.xml index 6d686260..db8efbdc 100644 --- a/spring-cloud-config-server/pom.xml +++ b/spring-cloud-config-server/pom.xml @@ -236,6 +236,11 @@ spring-core-test test + + org.testcontainers + vault + test + diff --git a/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/vault/SpringVaultClientConfiguration.java b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/vault/SpringVaultClientConfiguration.java index 0eee4427..1f473b48 100644 --- a/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/vault/SpringVaultClientConfiguration.java +++ b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/vault/SpringVaultClientConfiguration.java @@ -25,12 +25,14 @@ import org.apache.commons.logging.LogFactory; import org.springframework.cloud.config.server.environment.ConfigTokenProvider; import org.springframework.cloud.config.server.environment.VaultEnvironmentProperties; import org.springframework.cloud.config.server.environment.VaultEnvironmentProperties.AuthenticationMethod; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.Resource; import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.util.StringUtils; import org.springframework.vault.VaultException; import org.springframework.vault.authentication.ClientAuthentication; +import org.springframework.vault.authentication.SessionManager; import org.springframework.vault.client.RestTemplateBuilder; import org.springframework.vault.client.VaultClients; import org.springframework.vault.client.VaultEndpoint; @@ -99,6 +101,28 @@ public class SpringVaultClientConfiguration extends AbstractVaultConfiguration { return restTemplateBuilder; } + /* + * We provide our own SessionManager bean here overriding what is done in {@link + * AbstractVaultConfiguration} because when a Vault token is provided via a HTTP + * header (meaning our ConfigTokenProvider is an instance of {@link + * HttpRequestConfigTokenProvider}). If we don't then {@link + * AbstractVaultConfiguration} will create an instance of {@link + * LifecycleAwareSessionManager} which persists the token until it is revoked. This + * means any subsequent requests to the config server will use whatever token is + * persisted in {@link LifecycleAwareSessionManager} and not what is set in the + * header. By using {@link StatelessSessionManager} when a token is provided via a + * header, we ensure that the token we use to make the request to Vault is the one + * provided in the header. + */ + @Override + @Bean + public SessionManager sessionManager() { + if (vaultProperties.getAuthentication() == null && !StringUtils.hasText(vaultProperties.getToken())) { + return new StatelessSessionManager(clientAuthentication()); + } + return super.sessionManager(); + } + @Override public SslConfiguration sslConfiguration() { if (vaultProperties.isSkipSslValidation()) { diff --git a/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/vault/StatelessSessionManager.java b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/vault/StatelessSessionManager.java new file mode 100644 index 00000000..d8d54e57 --- /dev/null +++ b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/vault/StatelessSessionManager.java @@ -0,0 +1,50 @@ +/* + * Copyright 2018-2025 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.cloud.config.server.environment.vault; + +import java.util.concurrent.locks.ReentrantLock; + +import org.springframework.util.Assert; +import org.springframework.vault.authentication.ClientAuthentication; +import org.springframework.vault.authentication.SessionManager; +import org.springframework.vault.support.VaultToken; + +/** + * @author Ryan Baxter + */ +public class StatelessSessionManager implements SessionManager { + + private final ClientAuthentication clientAuthentication; + + private final ReentrantLock lock = new ReentrantLock(); + + public StatelessSessionManager(ClientAuthentication clientAuthentication) { + Assert.notNull(clientAuthentication, "ClientAuthentication must not be null"); + this.clientAuthentication = clientAuthentication; + } + + public VaultToken getSessionToken() { + this.lock.lock(); + try { + return this.clientAuthentication.login(); + } + finally { + this.lock.unlock(); + } + } + +} diff --git a/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/environment/vault/StatelessSessionManagerTests.java b/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/environment/vault/StatelessSessionManagerTests.java new file mode 100644 index 00000000..fbc98446 --- /dev/null +++ b/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/environment/vault/StatelessSessionManagerTests.java @@ -0,0 +1,45 @@ +/* + * Copyright 2018-2025 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.cloud.config.server.environment.vault; + +import org.junit.jupiter.api.Test; + +import org.springframework.vault.authentication.ClientAuthentication; +import org.springframework.vault.support.VaultToken; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * @author Ryan Baxter + */ +class StatelessSessionManagerTests { + + @Test + public void verifyClientAuthenticationIsCalledEveryTime() { + ClientAuthentication clientAuthentication = mock(ClientAuthentication.class); + when(clientAuthentication.login()).thenReturn(VaultToken.of("mytoken"), VaultToken.of("anothertoken")); + StatelessSessionManager sessionManager = new StatelessSessionManager(clientAuthentication); + assertThat(sessionManager.getSessionToken().getToken()).isEqualTo("mytoken"); + assertThat(sessionManager.getSessionToken().getToken()).isEqualTo("anothertoken"); + verify(clientAuthentication, times(2)).login(); + } + +} diff --git a/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/environment/vault/VaultIntegrationTests.java b/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/environment/vault/VaultIntegrationTests.java new file mode 100644 index 00000000..366e1639 --- /dev/null +++ b/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/environment/vault/VaultIntegrationTests.java @@ -0,0 +1,145 @@ +/* + * Copyright 2018-2025 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.cloud.config.server.environment.vault; + +import java.io.IOException; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.BindMode; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.vault.VaultContainer; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.cloud.config.environment.Environment; +import org.springframework.cloud.config.server.test.TestConfigServerApplication; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.TestSocketUtils; +import org.springframework.vault.authentication.LifecycleAwareSessionManager; +import org.springframework.vault.authentication.SessionManager; +import org.springframework.web.client.RestTemplate; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Ryan Baxter + */ +@Testcontainers +public class VaultIntegrationTests { + + private static final String VAULT_TOKEN = "myroot"; + + private static final String ROLE_ID = "testroleid"; + + private static final String SECRET_ID = "testsecretid"; + + private static final int configServerPort = TestSocketUtils.findAvailableTcpPort(); + + @Container + public static VaultContainer vaultContainer = new VaultContainer<>("hashicorp/vault:1.13.3") + .withVaultToken(VAULT_TOKEN) + .withClasspathResourceMapping("vault/vault_test_policy.txt", "/tmp/vault_test_policy.txt", BindMode.READ_ONLY); + + @BeforeAll + public static void before() throws IOException, InterruptedException { + execInVault("vault policy write full_access /tmp/vault_test_policy.txt".split(" ")); + execInVault("vault auth enable approle".split(" ")); + execInVault(("vault write /auth/approle/role/example-app policies=full_access role_id=" + ROLE_ID).split(" ")); + execInVault(("vault write /auth/approle/role/example-app/custom-secret-id secret_id=" + SECRET_ID).split(" ")); + execInVault("vault kv put secret/application foo=bar baz=bam".split(" ")); + execInVault("vault kv put secret/myapp foo=myappsbar".split(" ")); + } + + private static void execInVault(String... command) throws IOException, InterruptedException { + org.testcontainers.containers.Container.ExecResult execResult = vaultContainer.execInContainer(command); + assertThat(execResult.getExitCode()).isZero(); + assertThat(execResult.getStderr()).isEmpty(); + } + + @Test + public void useStatelessLifecycleManager() { + try (ConfigurableApplicationContext server = SpringApplication.run( + new Class[] { TestConfigServerApplication.class }, + + new String[] { "--spring.config.name=server", "--spring.profiles.active=vault", + "--server.port=" + configServerPort, + "--spring.cloud.config.server.vault.port=" + vaultContainer.getFirstMappedPort(), + "--spring.cloud.config.server.vault.kv-version=2", + "--logging.level.org.springframework.cloud.config.server.environment=DEBUG", + "--debug=true" })) { + RestTemplate rest = new RestTemplateBuilder().build(); + String configServerUrl = "http://localhost:" + configServerPort; + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Config-Token", VAULT_TOKEN); + HttpEntity entity = new HttpEntity<>(headers); + ResponseEntity env = rest.exchange(configServerUrl + "/myapp/default", + org.springframework.http.HttpMethod.GET, entity, Environment.class); + assertThat(server.getBean(SessionManager.class)).isInstanceOf(StatelessSessionManager.class); + assertThat(env.getBody().getPropertySources().get(0).getSource().get("foo")).isEqualTo("myappsbar"); + } + } + + @Test + public void useLifecycleManagerWithAuthentication() { + try (ConfigurableApplicationContext server = SpringApplication + .run(new Class[] { TestConfigServerApplication.class }, new String[] { "--spring.config.name=server", + "--spring.profiles.active=vault", "--server.port=" + configServerPort, + "--spring.cloud.config.server.vault.port=" + vaultContainer.getFirstMappedPort(), + "--spring.cloud.config.server.vault.kv-version=2", + "--spring.cloud.config.server.vault.authentication=APPROLE", + "--spring.cloud.config.server.vault.app-role.role-id=" + ROLE_ID, + "--spring.cloud.config.server.vault.app-role.secret-id=" + SECRET_ID, + "--logging.level.org.springframework.cloud.config.server.environment=DEBUG", "--debug=true" })) { + RestTemplate rest = new RestTemplateBuilder().build(); + String configServerUrl = "http://localhost:" + configServerPort; + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Config-Token", VAULT_TOKEN); + HttpEntity entity = new HttpEntity<>(headers); + ResponseEntity env = rest.exchange(configServerUrl + "/myapp/default", + org.springframework.http.HttpMethod.GET, entity, Environment.class); + assertThat(server.getBean(SessionManager.class)).isInstanceOf(LifecycleAwareSessionManager.class); + assertThat(env.getBody().getPropertySources().get(0).getSource().get("foo")).isEqualTo("myappsbar"); + } + } + + @Test + public void useLifecycleManagerWithToken() { + try (ConfigurableApplicationContext server = SpringApplication + .run(new Class[] { TestConfigServerApplication.class }, new String[] { "--spring.config.name=server", + "--spring.profiles.active=vault", "--server.port=" + configServerPort, + "--spring.cloud.config.server.vault.port=" + vaultContainer.getFirstMappedPort(), + "--spring.cloud.config.server.vault.kv-version=2", + "--spring.cloud.config.server.vault.token=" + VAULT_TOKEN, + "--logging.level.org.springframework.cloud.config.server.environment=DEBUG", "--debug=true" })) { + RestTemplate rest = new RestTemplateBuilder().build(); + String configServerUrl = "http://localhost:" + configServerPort; + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Config-Token", VAULT_TOKEN); + HttpEntity entity = new HttpEntity<>(headers); + ResponseEntity env = rest.exchange(configServerUrl + "/myapp/default", + org.springframework.http.HttpMethod.GET, entity, Environment.class); + assertThat(server.getBean(SessionManager.class)).isInstanceOf(LifecycleAwareSessionManager.class); + assertThat(env.getBody().getPropertySources().get(0).getSource().get("foo")).isEqualTo("myappsbar"); + } + } + +} diff --git a/spring-cloud-config-server/src/test/resources/vault/vault_test_policy.txt b/spring-cloud-config-server/src/test/resources/vault/vault_test_policy.txt new file mode 100644 index 00000000..283d40f7 --- /dev/null +++ b/spring-cloud-config-server/src/test/resources/vault/vault_test_policy.txt @@ -0,0 +1,16 @@ +path "secret/metadata/" { +capabilities = ["list"] +} +path "secret/data/application,*" { +capabilities = ["read", "list"] +} +path "secret/data/application*" { +capabilities = ["read", "list"] +} +path "secret/data/myapp*" { +capabilities = ["read", "list"] +} +path "secret/data/myapp,*" { +capabilities = ["read", "list"] +} +