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+

Reply via email to