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 <[email protected]>
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 <[email protected]>
---
.../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+