You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by da...@apache.org on 2026/10/11 07:02:36 UTC
(camel) branch main updated: CAMEL-25518: camel-hashicorp-vault - Secret refresh detects updates in any engine and before the first check (#27669)
This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new fa153a8c9511 CAMEL-25518: camel-hashicorp-vault - Secret refresh detects updates in any engine and before the first check (#27669)
fa153a8c9511 is described below
commit fa153a8c95111ddaa31f8d8572d853919152c000
Author: allthingssecurity <49...@users.noreply.github.com>
AuthorDate: Sun Oct 11 12:32:28 2026 +0530
CAMEL-25518: camel-hashicorp-vault - Secret refresh detects updates in any engine and before the first check (#27669)
Co-Authored-By: Claude Opus 5.5 <no...@anthropic.com>
---
.../catalog/docs/hashicorp-vault-component.adoc | 1 +
.../src/main/docs/hashicorp-vault-component.adoc | 1 +
.../vault/HashicorpVaultPropertiesFunction.java | 55 ++++-
.../vault/HashicorpVaultReloadTriggerTask.java | 8 +
.../vault/HashicorpVaultReloadTriggerTaskTest.java | 232 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 8 +
6 files changed, 299 insertions(+), 6 deletions(-)
diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/hashicorp-vault-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/hashicorp-vault-component.adoc
index 8f6f56c53380..d8b2e2ad3091 100644
--- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/hashicorp-vault-component.adoc
+++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/hashicorp-vault-component.adoc
@@ -651,6 +651,7 @@ The secret names should use the following format:
- You can use patterns like `database*` to match multiple secrets
Note that `camel.vault.hashicorp.secrets` is not mandatory: if not specified the task responsible for checking updates events will take into account all the properties with a `hashicorp:` prefix.
+Such a secret is tracked in the engine of the property, in the format above (`hashicorp:myengine:mysecret` is tracked as `myengine:mysecret`, `hashicorp:secret:mysecret` as `mysecret`), and its first check compares the version in Vault with the version that was resolved, so an update made before the first check also triggers a reload.
==== How the Refresh Mechanism Works
diff --git a/components/camel-hashicorp-vault/src/main/docs/hashicorp-vault-component.adoc b/components/camel-hashicorp-vault/src/main/docs/hashicorp-vault-component.adoc
index 8f6f56c53380..d8b2e2ad3091 100644
--- a/components/camel-hashicorp-vault/src/main/docs/hashicorp-vault-component.adoc
+++ b/components/camel-hashicorp-vault/src/main/docs/hashicorp-vault-component.adoc
@@ -651,6 +651,7 @@ The secret names should use the following format:
- You can use patterns like `database*` to match multiple secrets
Note that `camel.vault.hashicorp.secrets` is not mandatory: if not specified the task responsible for checking updates events will take into account all the properties with a `hashicorp:` prefix.
+Such a secret is tracked in the engine of the property, in the format above (`hashicorp:myengine:mysecret` is tracked as `myengine:mysecret`, `hashicorp:secret:mysecret` as `mysecret`), and its first check compares the version in Vault with the version that was resolved, so an update made before the first check also triggers a reload.
==== How the Refresh Mechanism Works
diff --git a/components/camel-hashicorp-vault/src/main/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultPropertiesFunction.java b/components/camel-hashicorp-vault/src/main/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultPropertiesFunction.java
index bb195d3ac1cf..a1909d805768 100644
--- a/components/camel-hashicorp-vault/src/main/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultPropertiesFunction.java
+++ b/components/camel-hashicorp-vault/src/main/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultPropertiesFunction.java
@@ -16,9 +16,10 @@
*/
package org.apache.camel.component.hashicorp.vault;
-import java.util.HashSet;
+import java.util.Collections;
import java.util.Map;
import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
import org.apache.camel.CamelContext;
import org.apache.camel.CamelContextAware;
@@ -28,6 +29,8 @@ import org.apache.camel.support.service.ServiceSupport;
import org.apache.camel.util.ObjectHelper;
import org.apache.camel.util.StringHelper;
import org.apache.camel.vault.HashicorpVaultConfiguration;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import org.springframework.vault.authentication.TokenAuthentication;
import org.springframework.vault.client.VaultEndpoint;
import org.springframework.vault.core.VaultTemplate;
@@ -72,6 +75,8 @@ import org.springframework.vault.support.VaultResponse;
@org.apache.camel.spi.annotations.PropertiesFunction("hashicorp")
public class HashicorpVaultPropertiesFunction extends ServiceSupport implements PropertiesFunction, CamelContextAware {
+ private static final Logger LOG = LoggerFactory.getLogger(HashicorpVaultPropertiesFunction.class);
+
private static final String CAMEL_HASHICORP_VAULT_TOKEN_ENV = "CAMEL_HASHICORP_VAULT_TOKEN";
private static final String CAMEL_HASHICORP_VAULT_HOST_ENV = "CAMEL_HASHICORP_VAULT_HOST";
private static final String CAMEL_HASHICORP_VAULT_PORT_ENV
@@ -84,7 +89,8 @@ public class HashicorpVaultPropertiesFunction extends ServiceSupport implements
= "CAMEL_HASHICORP_VAULT_NAMESPACE";
private CamelContext camelContext;
private VaultTemplate client;
- private final Set<String> secrets = new HashSet<>();
+ private final Set<String> secrets = ConcurrentHashMap.newKeySet();
+ private final Map<String, Integer> secretVersions = new ConcurrentHashMap<>();
private String engine;
private String namespace;
@@ -216,8 +222,9 @@ public class HashicorpVaultPropertiesFunction extends ServiceSupport implements
}
private String getSecretFromSource(String key, String subkey, String defaultValue, String version) {
- // capture name of secret
- secrets.add(key);
+ // capture name of secret in the form the refresh task reads the metadata of
+ String secretName = trackedSecretName(engine, key);
+ secrets.add(secretName);
String returnValue = null;
try {
@@ -235,6 +242,9 @@ public class HashicorpVaultPropertiesFunction extends ServiceSupport implements
VaultResponse rawSecret = client.read(completePath);
if (ObjectHelper.isNotEmpty(rawSecret)) {
returnValue = rawSecret.getData().get("data").toString();
+ if (ObjectHelper.isEmpty(version)) {
+ captureVersion(secretName, rawSecret);
+ }
}
if (ObjectHelper.isNotEmpty(subkey)) {
Object field = ((Map) rawSecret.getData().get("data")).get(subkey);
@@ -257,6 +267,28 @@ public class HashicorpVaultPropertiesFunction extends ServiceSupport implements
return returnValue;
}
+ /**
+ * The name of a secret as the refresh task tracks it: engine:secret, or only the secret for the default secret
+ * engine. A lookup without engine is tracked as a secret of the default secret engine, which is also how the
+ * refresh task reads a name without engine.
+ */
+ private static String trackedSecretName(String secretEngine, String key) {
+ return secretEngine == null || "secret".equals(secretEngine) ? key : secretEngine + ":" + key;
+ }
+
+ private void captureVersion(String secretName, VaultResponse rawSecret) {
+ if (rawSecret.getData().get("metadata") instanceof Map<?, ?> metadata && metadata.get("version") != null) {
+ try {
+ // keep the oldest version resolved: a value resolved earlier may still be in use, so the refresh
+ // task must compare with that one (at worst one extra reload)
+ secretVersions.merge(secretName, Integer.valueOf(metadata.get("version").toString()), Math::min);
+ } catch (NumberFormatException e) {
+ LOG.debug("Could not parse secret version for {}: {}", secretName, metadata.get("version"), e);
+ // the refresh task then uses the version it reads first
+ }
+ }
+ }
+
@Override
public void setCamelContext(CamelContext camelContext) {
this.camelContext = camelContext;
@@ -268,9 +300,20 @@ public class HashicorpVaultPropertiesFunction extends ServiceSupport implements
}
/**
- * Ids of the secrets in use
+ * Ids of the secrets in use, as engine:secret, or only the secret for the default secret engine
*/
public Set<String> getSecrets() {
- return secrets;
+ return Collections.unmodifiableSet(secrets);
+ }
+
+ /**
+ * The oldest version of the secret (named as in {@link #getSecrets()}) that was resolved, or null if not known
+ * (such as when a specific version was requested).
+ *
+ * @param secretName the name of the secret, as engine:secret, or only the secret for the default secret engine
+ * @return the oldest version of the secret that was resolved, or null if not known
+ */
+ public Integer getSecretVersion(String secretName) {
+ return secretVersions.get(secretName);
}
}
diff --git a/components/camel-hashicorp-vault/src/main/java/org/apache/camel/component/hashicorp/vault/vault/HashicorpVaultReloadTriggerTask.java b/components/camel-hashicorp-vault/src/main/java/org/apache/camel/component/hashicorp/vault/vault/HashicorpVaultReloadTriggerTask.java
index da4be636d73c..ad0f7291419f 100644
--- a/components/camel-hashicorp-vault/src/main/java/org/apache/camel/component/hashicorp/vault/vault/HashicorpVaultReloadTriggerTask.java
+++ b/components/camel-hashicorp-vault/src/main/java/org/apache/camel/component/hashicorp/vault/vault/HashicorpVaultReloadTriggerTask.java
@@ -209,6 +209,14 @@ public class HashicorpVaultReloadTriggerTask extends ServiceSupport implements C
Integer currentVersion = Integer.valueOf(currentVersionObj.toString());
Integer lastKnownVersion = versionsMap.get(secretName);
+ if (lastKnownVersion == null && propertiesFunction != null) {
+ // First time seeing this secret: start from the version the properties
+ // function resolved, as the secret may have been updated since then
+ lastKnownVersion = propertiesFunction.getSecretVersion(secretName);
+ if (lastKnownVersion != null) {
+ versionsMap.put(secretName, lastKnownVersion);
+ }
+ }
if (lastKnownVersion == null) {
// First time seeing this secret, just record the version
versionsMap.put(secretName, currentVersion);
diff --git a/components/camel-hashicorp-vault/src/test/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultReloadTriggerTaskTest.java b/components/camel-hashicorp-vault/src/test/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultReloadTriggerTaskTest.java
new file mode 100644
index 000000000000..d8385f86d624
--- /dev/null
+++ b/components/camel-hashicorp-vault/src/test/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultReloadTriggerTaskTest.java
@@ -0,0 +1,232 @@
+/*
+ * 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;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.apache.camel.CamelContext;
+import org.apache.camel.component.hashicorp.vault.vault.HashicorpVaultReloadTriggerTask;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.apache.camel.vault.HashicorpVaultConfiguration;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Secret refresh against a minimal fake of the HashiCorp Vault KV v2 HTTP API.
+ */
+class HashicorpVaultReloadTriggerTaskTest extends CamelTestSupport {
+
+ private static HttpServer server;
+ // "<engine>/<key>" -> current version
+ private static final Map<String, Integer> VERSIONS = new ConcurrentHashMap<>();
+ private static final List<String> METADATA_READS = new CopyOnWriteArrayList<>();
+
+ private HashicorpVaultReloadTriggerTask task;
+
+ @BeforeAll
+ static void startFakeVault() throws IOException {
+ server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
+ server.createContext("/v1/", HashicorpVaultReloadTriggerTaskTest::handle);
+ server.start();
+ }
+
+ @AfterAll
+ static void stopFakeVault() {
+ server.stop(0);
+ }
+
+ @AfterEach
+ void stopTask() {
+ if (task != null) {
+ task.stop();
+ }
+ VERSIONS.clear();
+ METADATA_READS.clear();
+ }
+
+ private static void handle(HttpExchange http) throws IOException {
+ // /v1/<engine>/<data|metadata>/<key>[?version=<n>]
+ String[] parts = http.getRequestURI().getPath().substring("/v1/".length()).split("/", 3);
+ String body = null;
+ if (parts.length == 3) {
+ Integer version = VERSIONS.get(parts[0] + "/" + parts[2]);
+ String query = http.getRequestURI().getQuery();
+ if ("metadata".equals(parts[1])) {
+ METADATA_READS.add(parts[0] + "/metadata/" + parts[2]);
+ if (version != null) {
+ body = "{\"data\":{\"current_version\":" + version + "}}";
+ }
+ } else if ("data".equals(parts[1]) && version != null) {
+ if (query != null && query.startsWith("version=")) {
+ // a specific version was requested
+ version = Integer.valueOf(query.substring("version=".length()));
+ }
+ body = "{\"data\":{\"data\":{\"password\":\"pw-" + version + "\"},\"metadata\":{\"version\":" + version
+ + "}}}";
+ }
+ }
+ int status = body != null ? 200 : 404;
+ byte[] bytes = (body != null ? body : "{\"errors\":[]}").getBytes(StandardCharsets.UTF_8);
+ http.getResponseHeaders().add("Content-Type", "application/json");
+ http.sendResponseHeaders(status, bytes.length);
+ try (OutputStream os = http.getResponseBody()) {
+ os.write(bytes);
+ }
+ }
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext ctx = super.createCamelContext();
+ HashicorpVaultConfiguration hashicorp = new HashicorpVaultConfiguration();
+ hashicorp.setToken("test-token");
+ hashicorp.setHost("localhost");
+ hashicorp.setPort(String.valueOf(server.getAddress().getPort()));
+ hashicorp.setScheme("http");
+ ctx.getVaultConfiguration().setHashicorpVaultConfiguration(hashicorp);
+ return ctx;
+ }
+
+ private void startTask() {
+ task = new HashicorpVaultReloadTriggerTask();
+ task.setCamelContext(context);
+ task.start();
+ }
+
+ @Test
+ void rotationInNonDefaultEngineIsDetected() {
+ VERSIONS.put("kv/db", 1);
+ assertEquals("pw-1", context.resolvePropertyPlaceholders("{{hashicorp:kv:db#password}}"));
+
+ startTask();
+ task.run();
+ VERSIONS.put("kv/db", 2);
+ task.run();
+
+ assertTrue(METADATA_READS.contains("kv/metadata/db"),
+ "the refresh task must read the metadata of the engine the secret was resolved from, but read "
+ + METADATA_READS);
+ assertFalse(task.getUpdates().isEmpty(),
+ "the new version of the secret kv:db was not detected, updates: " + task.getUpdates());
+ }
+
+ @Test
+ void rotationBeforeFirstCheckIsDetected() {
+ VERSIONS.put("secret/api", 1);
+ assertEquals("pw-1", context.resolvePropertyPlaceholders("{{hashicorp:secret:api#password}}"));
+
+ startTask();
+ // the secret is rotated after it was resolved at startup but before the first check
+ VERSIONS.put("secret/api", 2);
+ task.run();
+
+ assertFalse(task.getUpdates().isEmpty(),
+ "the application resolved version 1 but version 2 was not detected, updates: " + task.getUpdates());
+ }
+
+ @Test
+ void rotationBetweenTwoLookupsBeforeFirstCheckIsDetected() {
+ VERSIONS.put("secret/api", 1);
+ assertEquals("pw-1", context.resolvePropertyPlaceholders("{{hashicorp:secret:api#password}}"));
+ // the secret is rotated, and resolved again (as a dynamic endpoint does) before the first check
+ VERSIONS.put("secret/api", 2);
+ assertEquals("pw-2", context.resolvePropertyPlaceholders("{{hashicorp:secret:api#password}}"));
+
+ HashicorpVaultPropertiesFunction function
+ = (HashicorpVaultPropertiesFunction) context.getPropertiesComponent().getPropertiesFunction("hashicorp");
+ assertEquals(1, function.getSecretVersion("api"), "the oldest resolved version is the one to compare with");
+
+ startTask();
+ task.run();
+
+ assertFalse(task.getUpdates().isEmpty(),
+ "version 1 is still in use by what was resolved first, but version 2 was not detected, updates: "
+ + task.getUpdates());
+ }
+
+ @Test
+ void secretResolvedAtASpecificVersionIsNotComparedWithThatVersion() {
+ VERSIONS.put("secret/api", 2);
+ // the application asks for version 1, so the version it resolved says nothing about updates of the secret
+ assertEquals("pw-1", context.resolvePropertyPlaceholders("{{hashicorp:secret:api#password@1}}"));
+
+ startTask();
+ task.run();
+
+ assertTrue(task.getUpdates().isEmpty(), "the first check compared with the requested version, updates: "
+ + task.getUpdates());
+ }
+
+ @Test
+ void secretsAreTrackedWithTheirEngine() {
+ VERSIONS.put("kv/db", 1);
+ VERSIONS.put("secret/api", 1);
+ context.resolvePropertyPlaceholders("{{hashicorp:kv:db#password}}");
+ context.resolvePropertyPlaceholders("{{hashicorp:secret:api#password}}");
+
+ HashicorpVaultPropertiesFunction function
+ = (HashicorpVaultPropertiesFunction) context.getPropertiesComponent().getPropertiesFunction("hashicorp");
+ // the format of camel.vault.hashicorp.secrets: a secret of the default secret engine has no engine prefix
+ assertEquals(Set.of("kv:db", "api"), function.getSecrets());
+ }
+
+ @Test
+ void secretWithoutEngineIsTrackedAsDefaultEngine() {
+ // a fresh function has no engine yet, so a plain hashicorp:api lookup runs with a null engine
+ HashicorpVaultPropertiesFunction function = new HashicorpVaultPropertiesFunction();
+ function.setCamelContext(context);
+ function.start();
+ try {
+ function.apply("api");
+
+ // tracked like a secret of the default secret engine, which is how the refresh task reads a name
+ // without engine, and not as "null:api"
+ assertEquals(Set.of("api"), function.getSecrets());
+ } finally {
+ function.stop();
+ }
+ }
+
+ @Test
+ void unchangedSecretDoesNotTriggerReload() {
+ VERSIONS.put("kv/db", 1);
+ VERSIONS.put("secret/api", 3);
+ context.resolvePropertyPlaceholders("{{hashicorp:kv:db#password}}");
+ context.resolvePropertyPlaceholders("{{hashicorp:secret:api#password}}");
+
+ startTask();
+ task.run();
+ task.run();
+
+ assertTrue(task.getUpdates().isEmpty(), "no secret changed, updates: " + task.getUpdates());
+ }
+}
diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index f1dc62dde5dc..1023d0b9a751 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -906,6 +906,14 @@ in the Camel CLI) is now bound with the name from the annotation, or else the si
`@BindToRegistry` class. Before, the bean was bound with the fully qualified class name and the annotation value was
ignored, so a class in a package, or with a name in the annotation, was not found by the expected name.
+=== camel-hashicorp-vault - secret refresh
+
+The secret refresh task now checks a secret used as `hashicorp:myengine:mysecret` in its own engine, and names it
+`myengine:mysecret` in `HashicorpVaultPropertiesFunction.getSecrets()` and in the updates of the task (secrets of the
+`secret` engine keep their names). Before, it checked `mysecret` in the `secret` engine, so an update of a secret in
+any other engine never triggered a reload. The first check now also compares the version in Vault with the version the
+property was resolved with, so an update made between the startup and the first check triggers a reload.
+
=== camel-djl (Breaking change)
DJL library dependency has been upgraded to 0.37+