From 2510a536055287abf0b06bf396116ee9cb2ca0ff Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Fri, 13 Nov 2026 11:03:13 +0100 Subject: [PATCH] CAMEL-26919: camel-hashicorp-vault-starter - the early resolution logs in with the camel.vault.hashicorp options The early resolution of hashicorp: properties built its own VaultTemplate with camel.vault.hashicorp.token, so the logins of CAMEL-25675 (AppRole, Kubernetes, JWT, cert, cloud) and the TLS options did not apply to it. - SpringBootHashicorpVaultPropertiesParser binds camel.vault.hashicorp to HashicorpVaultConfiguration with the Spring Boot Binder (relaxed names, such as camel.vault.hashicorp.role-id), and creates the client with HashicorpVaultClient.create(HashicorpVaultClientSettings.fromConfiguration(...)), which needs no CamelContext. The messages for a missing host, port, scheme or token are unchanged; an incomplete login fails with a RuntimeCamelException naming the camel.vault.hashicorp options. - AbstractEarlyResolutionPropertiesParser calls a new release(PropertiesFunction) hook once the properties are resolved, also when they could not be. It does nothing by default; the hashicorp parser closes its client there, which stops the token renewal and revokes the token of the login. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Andrea Cosentino --- ...ingBootHashicorpVaultPropertiesParser.java | 81 +++++++--- .../EarlyResolvedPropertiesAppRoleTest.java | 142 ++++++++++++++++++ ...ootHashicorpVaultPropertiesParserTest.java | 36 +++++ ...stractEarlyResolutionPropertiesParser.java | 17 ++- ...ctEarlyResolutionPropertiesParserTest.java | 28 ++++ 5 files changed, 280 insertions(+), 24 deletions(-) create mode 100644 components-starter/camel-hashicorp-vault-starter/src/test/java/org/apache/camel/component/hashicorp/vault/springboot/EarlyResolvedPropertiesAppRoleTest.java diff --git a/components-starter/camel-hashicorp-vault-starter/src/main/java/org/apache/camel/component/hashicorp/vault/springboot/SpringBootHashicorpVaultPropertiesParser.java b/components-starter/camel-hashicorp-vault-starter/src/main/java/org/apache/camel/component/hashicorp/vault/springboot/SpringBootHashicorpVaultPropertiesParser.java index 1a90708674d0..5ba04fc3fadc 100644 --- a/components-starter/camel-hashicorp-vault-starter/src/main/java/org/apache/camel/component/hashicorp/vault/springboot/SpringBootHashicorpVaultPropertiesParser.java +++ b/components-starter/camel-hashicorp-vault-starter/src/main/java/org/apache/camel/component/hashicorp/vault/springboot/SpringBootHashicorpVaultPropertiesParser.java @@ -17,17 +17,25 @@ package org.apache.camel.component.hashicorp.vault.springboot; import org.apache.camel.RuntimeCamelException; +import org.apache.camel.component.hashicorp.vault.HashicorpVaultClient; +import org.apache.camel.component.hashicorp.vault.HashicorpVaultClientSettings; import org.apache.camel.component.hashicorp.vault.HashicorpVaultPropertiesFunction; import org.apache.camel.spi.PropertiesFunction; import org.apache.camel.spring.boot.AbstractEarlyResolutionPropertiesParser; import org.apache.camel.util.ObjectHelper; +import org.apache.camel.vault.HashicorpVaultConfiguration; +import org.springframework.boot.context.properties.bind.Binder; import org.springframework.core.env.ConfigurableEnvironment; -import org.springframework.vault.authentication.TokenAuthentication; -import org.springframework.vault.client.VaultEndpoint; -import org.springframework.vault.core.VaultTemplate; +/** + * Resolves the {@code {{hashicorp:...}}} placeholders of the Spring Boot properties before the CamelContext exists. It + * logs in to HashiCorp Vault with the {@code camel.vault.hashicorp} options, as the properties function of the + * CamelContext does, and revokes the token of its login once the properties are resolved. + */ public class SpringBootHashicorpVaultPropertiesParser extends AbstractEarlyResolutionPropertiesParser { + private static final String PREFIX = "camel.vault.hashicorp"; + @Override protected String getEarlyResolutionProperty() { return "camel.component.hashicorp-vault.early-resolve-properties"; @@ -40,35 +48,62 @@ protected String getOverridePropertySourceName() { @Override protected PropertiesFunction createPropertiesFunction(ConfigurableEnvironment environment) { - String token = required(environment, "camel.vault.hashicorp.token", "Hashicorp Vault token is required"); - String host = required(environment, "camel.vault.hashicorp.host", "Hashicorp Vault host is required"); - String portValue = required(environment, "camel.vault.hashicorp.port", "Hashicorp Vault port is required"); - String scheme = required(environment, "camel.vault.hashicorp.scheme", "Hashicorp Vault scheme is required"); - - int port; + // bound as Spring Boot binds the options of the CamelContext, such as camel.vault.hashicorp.role-id + HashicorpVaultConfiguration configuration = Binder.get(environment) + .bind(PREFIX, HashicorpVaultConfiguration.class) + .orElseGet(HashicorpVaultConfiguration::new); + if (isTokenLogin(configuration)) { + required(configuration.getToken(), "token", "Hashicorp Vault token is required"); + } + required(configuration.getHost(), "host", "Hashicorp Vault host is required"); + required(configuration.getPort(), "port", "Hashicorp Vault port is required"); + required(configuration.getScheme(), "scheme", "Hashicorp Vault scheme is required"); try { - port = Integer.parseInt(portValue); + Integer.parseInt(configuration.getPort()); } catch (NumberFormatException e) { throw new RuntimeCamelException( - "camel.vault.hashicorp.port must be a number but was: " + portValue, e); + PREFIX + ".port must be a number but was: " + configuration.getPort(), e); } - VaultEndpoint vaultEndpoint = new VaultEndpoint(); - vaultEndpoint.setHost(host); - vaultEndpoint.setPort(port); - vaultEndpoint.setScheme(scheme); + HashicorpVaultClient client; + try { + client = HashicorpVaultClient.create(HashicorpVaultClientSettings.fromConfiguration(configuration)); + } catch (IllegalArgumentException e) { + throw new RuntimeCamelException( + "Cannot create the Hashicorp Vault client of the " + PREFIX + " options: " + e.getMessage(), e); + } + return new EarlyResolutionFunction(client); + } - VaultTemplate client = new VaultTemplate( - vaultEndpoint, - new TokenAuthentication(token)); - return new HashicorpVaultPropertiesFunction(client); + @Override + protected void release(PropertiesFunction propertiesFunction) { + if (propertiesFunction instanceof EarlyResolutionFunction function) { + // stops renewing the token, and revokes the token of the login (but not a token given for the token login) + function.client.close(); + } + } + + private static boolean isTokenLogin(HashicorpVaultConfiguration configuration) { + String authentication = configuration.getAuthentication(); + return ObjectHelper.isEmpty(authentication) || "token".equalsIgnoreCase(authentication.trim()); } - private static String required(ConfigurableEnvironment environment, String key, String message) { - String value = environment.getProperty(key); + private static void required(String value, String option, String message) { if (ObjectHelper.isEmpty(value)) { - throw new RuntimeCamelException(message + " (set " + key + ")"); + throw new RuntimeCamelException(message + " (set " + PREFIX + "." + option + ")"); + } + } + + /** + * The properties function of the early resolution, with the client it reads the secrets with. + */ + private static final class EarlyResolutionFunction extends HashicorpVaultPropertiesFunction { + + private final HashicorpVaultClient client; + + private EarlyResolutionFunction(HashicorpVaultClient client) { + super(client.getVaultTemplate()); + this.client = client; } - return value; } } diff --git a/components-starter/camel-hashicorp-vault-starter/src/test/java/org/apache/camel/component/hashicorp/vault/springboot/EarlyResolvedPropertiesAppRoleTest.java b/components-starter/camel-hashicorp-vault-starter/src/test/java/org/apache/camel/component/hashicorp/vault/springboot/EarlyResolvedPropertiesAppRoleTest.java new file mode 100644 index 000000000000..184bb68b0ebc --- /dev/null +++ b/components-starter/camel-hashicorp-vault-starter/src/test/java/org/apache/camel/component/hashicorp/vault/springboot/EarlyResolvedPropertiesAppRoleTest.java @@ -0,0 +1,142 @@ +/* + * 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.camel.component.hashicorp.vault.springboot; + +import org.apache.camel.spring.boot.CamelAutoConfiguration; +import org.apache.camel.test.infra.hashicorp.vault.services.HashicorpServiceFactory; +import org.apache.camel.test.infra.hashicorp.vault.services.HashicorpVaultService; +import org.apache.camel.test.spring.junit6.CamelSpringBootTest; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.AutoConfigureBefore; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.vault.authentication.TokenAuthentication; +import org.springframework.vault.client.VaultEndpoint; +import org.springframework.vault.core.VaultKeyValueOperations; +import org.springframework.vault.core.VaultKeyValueOperationsSupport; +import org.springframework.vault.core.VaultTemplate; +import org.springframework.vault.support.VaultMount; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The early resolution logs in with the AppRole login of the {@code camel.vault.hashicorp} options, and revokes the + * token of its login once the properties are resolved. + */ +@CamelSpringBootTest +@DirtiesContext +@SpringBootApplication +@SpringBootTest( + classes = { EarlyResolvedPropertiesAppRoleTest.TestConfiguration.class }, + properties = { + "camel.component.hashicorp-vault.early-resolve-properties=true", + "early.resolved.property.approle={{hashicorp:secret:approle#string}}" + }) +class EarlyResolvedPropertiesAppRoleTest { + + @RegisterExtension + static HashicorpVaultService service = HashicorpServiceFactory.createService(); + + // the system properties are seen by the early resolution, and the JVM is shared with the other tests + private static final List OPTIONS = List.of( + "camel.vault.hashicorp.host", "camel.vault.hashicorp.port", "camel.vault.hashicorp.scheme", + "camel.vault.hashicorp.token", "camel.vault.hashicorp.authentication", "camel.vault.hashicorp.role-id", + "camel.vault.hashicorp.secret-id"); + + @BeforeAll + static void setup() { + VaultTemplate root = root(); + VaultKeyValueOperations secrets + = root.opsForKeyValue("secret", VaultKeyValueOperationsSupport.KeyValueBackend.versioned()); + secrets.put("approle", Map.of("string", "from-approle")); + // read by application.properties + secrets.put("database/password", Map.of("string", "pazzword")); + + root.write("sys/policies/acl/early-resolution", + Map.of("policy", "path \"secret/data/*\" { capabilities = [\"read\"] }")); + if (!root.opsForSys().getAuthMounts().containsKey("approle/")) { + root.opsForSys().authMount("approle", VaultMount.create("approle")); + } + root.write("auth/approle/role/camel", Map.of("token_policies", "early-resolution")); + String roleId = (String) root.read("auth/approle/role/camel/role-id").getRequiredData().get("role_id"); + String secretId = (String) root.write("auth/approle/role/camel/secret-id", Map.of()) + .getRequiredData().get("secret_id"); + + // no token: the AppRole login gets one + OPTIONS.forEach(System::clearProperty); + System.setProperty("camel.vault.hashicorp.host", service.host()); + System.setProperty("camel.vault.hashicorp.port", String.valueOf(service.port())); + System.setProperty("camel.vault.hashicorp.scheme", "http"); + // the kebab-case names of Spring Boot + System.setProperty("camel.vault.hashicorp.authentication", "approle"); + System.setProperty("camel.vault.hashicorp.role-id", roleId); + System.setProperty("camel.vault.hashicorp.secret-id", secretId); + } + + @AfterAll + static void clearOptions() { + OPTIONS.forEach(System::clearProperty); + } + + @Value("${early.resolved.property}") + private String earlyResolvedProperty; + + @Value("${early.resolved.property.approle}") + private String earlyResolvedPropertyAppRole; + + @Test + void resolvesThePropertiesWithTheAppRoleLoginAndRevokesItsToken() { + assertEquals("pazzword", earlyResolvedProperty); + assertEquals("from-approle", earlyResolvedPropertyAppRole); + + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> assertTrue(appRoleTokens().isEmpty(), + "the token of the AppRole login must be revoked once the properties are resolved")); + } + + private static VaultTemplate root() { + VaultEndpoint endpoint = VaultEndpoint.create(service.host(), service.port()); + endpoint.setScheme("http"); + return new VaultTemplate(endpoint, new TokenAuthentication(service.token())); + } + + // the accessors of the tokens that the AppRole login gave and Vault has not revoked + private static List appRoleTokens() { + VaultTemplate root = root(); + return root.list("auth/token/accessors").stream() + .filter(accessor -> "auth/approle/login".equals( + root.write("auth/token/lookup-accessor", Map.of("accessor", accessor)) + .getRequiredData().get("path"))) + .toList(); + } + + @Configuration + @AutoConfigureBefore(CamelAutoConfiguration.class) + static class TestConfiguration { + } +} diff --git a/components-starter/camel-hashicorp-vault-starter/src/test/java/org/apache/camel/component/hashicorp/vault/springboot/SpringBootHashicorpVaultPropertiesParserTest.java b/components-starter/camel-hashicorp-vault-starter/src/test/java/org/apache/camel/component/hashicorp/vault/springboot/SpringBootHashicorpVaultPropertiesParserTest.java index c6c79905ad6b..672ff28abd46 100644 --- a/components-starter/camel-hashicorp-vault-starter/src/test/java/org/apache/camel/component/hashicorp/vault/springboot/SpringBootHashicorpVaultPropertiesParserTest.java +++ b/components-starter/camel-hashicorp-vault-starter/src/test/java/org/apache/camel/component/hashicorp/vault/springboot/SpringBootHashicorpVaultPropertiesParserTest.java @@ -17,6 +17,7 @@ package org.apache.camel.component.hashicorp.vault.springboot; import org.apache.camel.RuntimeCamelException; +import org.apache.camel.spi.PropertiesFunction; import org.junit.jupiter.api.Test; import org.springframework.core.env.MapPropertySource; import org.springframework.core.env.StandardEnvironment; @@ -117,4 +118,39 @@ public void nonNumericPortFailsAsACamelExceptionNamingTheProperty() { assertInstanceOf(NumberFormatException.class, thrown.getCause(), "the cause must be preserved so the original NumberFormatException is not lost"); } + + @Test + void anAppRoleLoginNeedsNoToken() { + // the kebab-case names of Spring Boot, as for the options of the CamelContext + PropertiesFunction function = parser.createPropertiesFunction(environmentWith(Map.of( + "camel.vault.hashicorp.host", "127.0.0.1", + "camel.vault.hashicorp.port", "8200", + "camel.vault.hashicorp.scheme", "http", + "camel.vault.hashicorp.authentication", "approle", + "camel.vault.hashicorp.role-id", "a-role-id", + "camel.vault.hashicorp.secret-id", "a-secret-id"))); + try { + assertEquals("hashicorp", function.getName()); + } finally { + // the client logs in at its first request, so there is no token to revoke + parser.release(function); + } + } + + @Test + void anIncompleteLoginFailsAsACamelExceptionNamingTheOptions() { + RuntimeCamelException thrown = assertThrows(RuntimeCamelException.class, + () -> parser.createPropertiesFunction(environmentWith(Map.of( + "camel.vault.hashicorp.host", "127.0.0.1", + "camel.vault.hashicorp.port", "8200", + "camel.vault.hashicorp.scheme", "http", + "camel.vault.hashicorp.authentication", "approle", + "camel.vault.hashicorp.role-id", "a-role-id")))); + + assertTrue(thrown.getMessage().contains("secret ID"), + "the failure must name what the login misses, was: " + thrown.getMessage()); + assertTrue(thrown.getMessage().contains("camel.vault.hashicorp"), + "the failure must name the options, was: " + thrown.getMessage()); + assertInstanceOf(IllegalArgumentException.class, thrown.getCause()); + } } diff --git a/core/camel-spring-boot/src/main/java/org/apache/camel/spring/boot/AbstractEarlyResolutionPropertiesParser.java b/core/camel-spring-boot/src/main/java/org/apache/camel/spring/boot/AbstractEarlyResolutionPropertiesParser.java index 3e2c81168fc9..63f972ae3e71 100644 --- a/core/camel-spring-boot/src/main/java/org/apache/camel/spring/boot/AbstractEarlyResolutionPropertiesParser.java +++ b/core/camel-spring-boot/src/main/java/org/apache/camel/spring/boot/AbstractEarlyResolutionPropertiesParser.java @@ -75,6 +75,13 @@ public abstract class AbstractEarlyResolutionPropertiesParser */ protected abstract PropertiesFunction createPropertiesFunction(ConfigurableEnvironment environment); + /** + * Releases what the properties function holds, such as the login of its client, once the properties are resolved, + * also when they could not be. Does nothing by default. + */ + protected void release(PropertiesFunction propertiesFunction) { + } + /** * Wording used in diagnostics to name where a value was being resolved from. */ @@ -89,12 +96,20 @@ public final void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) return; } + PropertiesFunction propertiesFunction = createPropertiesFunction(environment); + try { + resolve(environment, propertiesFunction); + } finally { + release(propertiesFunction); + } + } + + private void resolve(ConfigurableEnvironment environment, PropertiesFunction propertiesFunction) { // an unresolved placeholder would otherwise stay in the property value and become the effective // secret, so resolution failures abort startup unless the operator opts back into the old behaviour final boolean ignoreResolutionFailures = Boolean.parseBoolean(environment.getProperty(IGNORE_RESOLUTION_FAILURES)); - PropertiesFunction propertiesFunction = createPropertiesFunction(environment); LOG.debug("Early resolving properties using the {} function", propertiesFunction.getName()); final String prefix = "{{" + propertiesFunction.getName() + ":"; diff --git a/core/camel-spring-boot/src/test/java/org/apache/camel/spring/boot/AbstractEarlyResolutionPropertiesParserTest.java b/core/camel-spring-boot/src/test/java/org/apache/camel/spring/boot/AbstractEarlyResolutionPropertiesParserTest.java index 8ecf326f2d0a..8d49f64fbed4 100644 --- a/core/camel-spring-boot/src/test/java/org/apache/camel/spring/boot/AbstractEarlyResolutionPropertiesParserTest.java +++ b/core/camel-spring-boot/src/test/java/org/apache/camel/spring/boot/AbstractEarlyResolutionPropertiesParserTest.java @@ -91,6 +91,7 @@ public String apply(String remainder) { private static final class TestParser extends AbstractEarlyResolutionPropertiesParser { private final StubPropertiesFunction function; private int factoryCalls; + private int releaseCalls; TestParser(StubPropertiesFunction function) { this.function = function; @@ -111,6 +112,12 @@ protected PropertiesFunction createPropertiesFunction(ConfigurableEnvironment en factoryCalls++; return function; } + + @Override + protected void release(PropertiesFunction propertiesFunction) { + assertEquals(function, propertiesFunction); + releaseCalls++; + } } private static ApplicationEnvironmentPreparedEvent eventFor(ConfigurableEnvironment environment) { @@ -144,6 +151,27 @@ public void guardDisabledDoesNotBuildTheClient() { "no override property source may be added when early resolution is disabled"); } + @Test + void releasesThePropertiesFunctionOnceResolved() { + RecordingEnvironment environment = environmentWith(Map.of(GUARD, "true", "my.secret", "{{test:db}}")); + TestParser parser = new TestParser(new StubPropertiesFunction(Map.of("db", "pazzword"), Set.of())); + + parser.onApplicationEvent(eventFor(environment)); + + assertEquals("pazzword", environment.getProperty("my.secret")); + assertEquals(1, parser.releaseCalls, "the function is released once the properties are resolved"); + } + + @Test + void releasesThePropertiesFunctionAlsoWhenResolutionFails() { + RecordingEnvironment environment = environmentWith(Map.of(GUARD, "true", "my.secret", "{{test:missing}}")); + TestParser parser = new TestParser(new StubPropertiesFunction(Map.of(), Set.of("missing"))); + + assertThrows(RuntimeCamelException.class, () -> parser.onApplicationEvent(eventFor(environment))); + + assertEquals(1, parser.releaseCalls, "the function is released also when startup is aborted"); + } + @Test public void guardEnabledBuildsTheClientAndAddsTheOverrideSource() { RecordingEnvironment environment = environmentWith(Map.of(GUARD, "true"));