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 5b7f3963e414 CAMEL-26255: camel-hashicorp-vault - cloud without a
namespace uses the engine, createSecret takes JSON text and needs a body, and
the secret version must be a number (#29100)
5b7f3963e414 is described below
commit 5b7f3963e414781c1f0fbe5250322fa2a0fdd1e7
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Nov 3 18:03:54 2026 +0100
CAMEL-26255: camel-hashicorp-vault - cloud without a namespace uses the
engine, createSecret takes JSON text and needs a body, and the secret version
must be a number (#29100)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../camel/catalog/components/hashicorp-vault.json | 2 +-
.../catalog/docs/hashicorp-vault-component.adoc | 9 +-
components/camel-hashicorp-vault/pom.xml | 6 +
.../component/hashicorp/vault/hashicorp-vault.json | 2 +-
.../src/main/docs/hashicorp-vault-component.adoc | 9 +-
.../hashicorp/vault/HashicorpVaultEndpoint.java | 10 +-
.../hashicorp/vault/HashicorpVaultProducer.java | 96 +++++++---
.../vault/HashicorpVaultPropertiesFunction.java | 17 +-
.../vault/HashicorpVaultProducerTest.java | 203 +++++++++++++++++++++
.../HashicorpVaultPropertiesFunctionTest.java | 21 +++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 16 ++
11 files changed, 346 insertions(+), 45 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/hashicorp-vault.json
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/hashicorp-vault.json
index e07af73a135e..0e3542c41d55 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/hashicorp-vault.json
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/hashicorp-vault.json
@@ -34,7 +34,7 @@
"CamelHashicorpVaultSecretVersion": { "index": 2, "kind": "header",
"displayName": "", "group": "producer", "label": "producer", "required": false,
"javaType": "String", "deprecated": false, "deprecationNote": "", "autowired":
false, "secret": false, "description": "Set the desired secret version as
header.", "constantName":
"org.apache.camel.component.hashicorp.vault.HashicorpVaultConstants#SECRET_VERSION"
}
},
"bodyTypes": {
- "producerIn": { "carriers": [ "map", "pojo" ], "byOption": [ { "option":
"operation", "value": "getSecret", "carriers": [ "any" ] }, { "option":
"operation", "value": "deleteSecret", "carriers": [ "any" ] }, { "option":
"operation", "value": "listSecrets", "carriers": [ "any" ] } ], "description":
"The fields of the secret for createSecret, the default operation: a Map of the
keys and values, or a POJO written as JSON. Text, bytes, a stream, a file or a
List fail, so unmarshal JSON f [...]
+ "producerIn": { "carriers": [ "map", "pojo", "text", "bytes", "stream",
"file" ], "byOption": [ { "option": "operation", "value": "getSecret",
"carriers": [ "any" ] }, { "option": "operation", "value": "deleteSecret",
"carriers": [ "any" ] }, { "option": "operation", "value": "listSecrets",
"carriers": [ "any" ] } ], "description": "The fields of the secret for
createSecret, the default operation: a Map of the keys and values, a POJO
written as JSON, or text, bytes, a stream or a fil [...]
"producerOut": { "carriers": [ "unchanged" ], "byOption": [ { "option":
"operation", "value": "getSecret", "carriers": [ "map", "null" ] }, { "option":
"operation", "value": "listSecrets", "carriers": [ "list" ], "javaType":
"java.util.List<java.lang.String>" } ], "description": "getSecret gives a Map
with the fields of the secret under data and its version under metadata, or no
body when the secret does not exist or is deleted. listSecrets gives a List of
the names of the secrets in [...]
},
"properties": {
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 98721af5462b..8e8352014961 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
@@ -50,7 +50,8 @@ The component can interact with HashiCorp Vault, which may be
deployed either as
When using a HashiCorp Vault Cloud instance, in addition to the standard
parameters such as `host`, `port`, `scheme`, and `token`, you must configure
the following additional parameters:
* `cloud`:: A boolean flag that must be explicitly set to `true` to indicate
that the HashiCorp Vault instance is hosted in the cloud.
-* `namespace`:: The namespace of your secrets engine.
+* `namespace`:: The namespace of your secrets engine. The operations work on
`<namespace>/<secretsEngine>`; without a
+namespace they work on the secrets engine itself, as on-premise.
== Examples
@@ -64,7 +65,9 @@ and `CamelHashicorpVaultSecretPath` headers override them per
message.
==== Creating a secret
-The message body is the map of key/value pairs to store as the secret:
+The message body is the map of key/value pairs to store as the secret. A POJO
is written as JSON, and text, bytes,
+a stream or a file must hold a JSON object of the fields. A message without a
body fails, instead of writing a new,
+empty version of the secret:
[tabs]
====
@@ -105,7 +108,7 @@ YAML::
The message body becomes a Map with the fields of the secret under `data` and
its version details under
`metadata`, or no body when the secret does not exist; the
`CamelHashicorpVaultSecretVersion` header reads a given
-version instead of the latest:
+version (a number) instead of the latest:
[tabs]
====
diff --git a/components/camel-hashicorp-vault/pom.xml
b/components/camel-hashicorp-vault/pom.xml
index 074ad574aef0..322de65749f6 100644
--- a/components/camel-hashicorp-vault/pom.xml
+++ b/components/camel-hashicorp-vault/pom.xml
@@ -60,6 +60,12 @@
<artifactId>camel-test-junit6</artifactId>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-junit-jupiter</artifactId>
+ <version>${mockito-version}</version>
+ <scope>test</scope>
+ </dependency>
<dependency>
<groupId>org.apache.camel</groupId>
<artifactId>camel-test-infra-hashicorp-vault</artifactId>
diff --git
a/components/camel-hashicorp-vault/src/generated/resources/META-INF/org/apache/camel/component/hashicorp/vault/hashicorp-vault.json
b/components/camel-hashicorp-vault/src/generated/resources/META-INF/org/apache/camel/component/hashicorp/vault/hashicorp-vault.json
index e07af73a135e..0e3542c41d55 100644
---
a/components/camel-hashicorp-vault/src/generated/resources/META-INF/org/apache/camel/component/hashicorp/vault/hashicorp-vault.json
+++
b/components/camel-hashicorp-vault/src/generated/resources/META-INF/org/apache/camel/component/hashicorp/vault/hashicorp-vault.json
@@ -34,7 +34,7 @@
"CamelHashicorpVaultSecretVersion": { "index": 2, "kind": "header",
"displayName": "", "group": "producer", "label": "producer", "required": false,
"javaType": "String", "deprecated": false, "deprecationNote": "", "autowired":
false, "secret": false, "description": "Set the desired secret version as
header.", "constantName":
"org.apache.camel.component.hashicorp.vault.HashicorpVaultConstants#SECRET_VERSION"
}
},
"bodyTypes": {
- "producerIn": { "carriers": [ "map", "pojo" ], "byOption": [ { "option":
"operation", "value": "getSecret", "carriers": [ "any" ] }, { "option":
"operation", "value": "deleteSecret", "carriers": [ "any" ] }, { "option":
"operation", "value": "listSecrets", "carriers": [ "any" ] } ], "description":
"The fields of the secret for createSecret, the default operation: a Map of the
keys and values, or a POJO written as JSON. Text, bytes, a stream, a file or a
List fail, so unmarshal JSON f [...]
+ "producerIn": { "carriers": [ "map", "pojo", "text", "bytes", "stream",
"file" ], "byOption": [ { "option": "operation", "value": "getSecret",
"carriers": [ "any" ] }, { "option": "operation", "value": "deleteSecret",
"carriers": [ "any" ] }, { "option": "operation", "value": "listSecrets",
"carriers": [ "any" ] } ], "description": "The fields of the secret for
createSecret, the default operation: a Map of the keys and values, a POJO
written as JSON, or text, bytes, a stream or a fil [...]
"producerOut": { "carriers": [ "unchanged" ], "byOption": [ { "option":
"operation", "value": "getSecret", "carriers": [ "map", "null" ] }, { "option":
"operation", "value": "listSecrets", "carriers": [ "list" ], "javaType":
"java.util.List<java.lang.String>" } ], "description": "getSecret gives a Map
with the fields of the secret under data and its version under metadata, or no
body when the secret does not exist or is deleted. listSecrets gives a List of
the names of the secrets in [...]
},
"properties": {
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 98721af5462b..8e8352014961 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
@@ -50,7 +50,8 @@ The component can interact with HashiCorp Vault, which may be
deployed either as
When using a HashiCorp Vault Cloud instance, in addition to the standard
parameters such as `host`, `port`, `scheme`, and `token`, you must configure
the following additional parameters:
* `cloud`:: A boolean flag that must be explicitly set to `true` to indicate
that the HashiCorp Vault instance is hosted in the cloud.
-* `namespace`:: The namespace of your secrets engine.
+* `namespace`:: The namespace of your secrets engine. The operations work on
`<namespace>/<secretsEngine>`; without a
+namespace they work on the secrets engine itself, as on-premise.
== Examples
@@ -64,7 +65,9 @@ and `CamelHashicorpVaultSecretPath` headers override them per
message.
==== Creating a secret
-The message body is the map of key/value pairs to store as the secret:
+The message body is the map of key/value pairs to store as the secret. A POJO
is written as JSON, and text, bytes,
+a stream or a file must hold a JSON object of the fields. A message without a
body fails, instead of writing a new,
+empty version of the secret:
[tabs]
====
@@ -105,7 +108,7 @@ YAML::
The message body becomes a Map with the fields of the secret under `data` and
its version details under
`metadata`, or no body when the secret does not exist; the
`CamelHashicorpVaultSecretVersion` header reads a given
-version instead of the latest:
+version (a number) instead of the latest:
[tabs]
====
diff --git
a/components/camel-hashicorp-vault/src/main/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultEndpoint.java
b/components/camel-hashicorp-vault/src/main/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultEndpoint.java
index bcdb20601729..d26acd6b5af8 100644
---
a/components/camel-hashicorp-vault/src/main/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultEndpoint.java
+++
b/components/camel-hashicorp-vault/src/main/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultEndpoint.java
@@ -43,16 +43,16 @@ import org.springframework.vault.core.VaultTemplate;
@Metadata(annotations = {
"vault=hashicorp-vault",
})
-@BodyTypes(producerIn = @BodyType(value = { Carrier.MAP, Carrier.POJO },
+@BodyTypes(producerIn = @BodyType(value = {
+ Carrier.MAP, Carrier.POJO, Carrier.TEXT, Carrier.BYTES,
Carrier.STREAM, Carrier.FILE },
byOption = {
@BodyType.When(option = "operation",
value = "getSecret", carriers = Carrier.ANY),
@BodyType.When(option = "operation",
value = "deleteSecret", carriers = Carrier.ANY),
@BodyType.When(option = "operation",
value = "listSecrets", carriers = Carrier.ANY) },
description = "The fields of the secret for
createSecret, the default operation: a Map of the"
- + " keys and values, or a POJO
written as JSON. Text, bytes, a stream, a file or a"
- + " List fail, so unmarshal
JSON first, and no body writes an empty version. The"
- + " other operations take the
secret from the secretPath option or the"
- + "
CamelHashicorpVaultSecretPath header"),
+ + " keys and values, a POJO
written as JSON, or text, bytes, a stream or a file"
+ + " with a JSON object. A List
or no body fails. The other operations take the"
+ + " secret from the secretPath
option or the CamelHashicorpVaultSecretPath header"),
producerOut = @BodyType(value = Carrier.UNCHANGED,
byOption = {
@BodyType.When(option =
"operation", value = "getSecret",
diff --git
a/components/camel-hashicorp-vault/src/main/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultProducer.java
b/components/camel-hashicorp-vault/src/main/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultProducer.java
index 89b1cf1883e5..be33017979d4 100644
---
a/components/camel-hashicorp-vault/src/main/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultProducer.java
+++
b/components/camel-hashicorp-vault/src/main/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultProducer.java
@@ -16,12 +16,21 @@
*/
package org.apache.camel.component.hashicorp.vault;
-import java.util.ArrayList;
+import java.io.File;
+import java.io.InputStream;
+import java.io.Reader;
+import java.nio.file.Path;
import java.util.List;
+import java.util.Map;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.camel.Endpoint;
import org.apache.camel.Exchange;
+import org.apache.camel.InvalidPayloadException;
import org.apache.camel.Message;
+import org.apache.camel.StreamCache;
+import org.apache.camel.WrappedFile;
import org.apache.camel.support.DefaultProducer;
import org.apache.camel.util.ObjectHelper;
import org.springframework.vault.core.VaultKeyValueOperations;
@@ -29,6 +38,9 @@ import
org.springframework.vault.core.VaultKeyValueOperationsSupport;
import org.springframework.vault.support.VaultResponse;
public class HashicorpVaultProducer extends DefaultProducer {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
public HashicorpVaultProducer(final Endpoint endpoint) {
super(endpoint);
}
@@ -54,12 +66,13 @@ public class HashicorpVaultProducer extends DefaultProducer
{
}
}
- private void createSecret(Exchange exchange) {
+ private void createSecret(Exchange exchange) throws
InvalidPayloadException {
String secretPath = getSecretPath(exchange);
+ Object secret = getSecretBody(exchange);
VaultKeyValueOperations keyValue
- =
getEndpoint().getVaultTemplate().opsForKeyValue(getEndpoint().getConfiguration().getSecretsEngine(),
+ =
getEndpoint().getVaultTemplate().opsForKeyValue(getEnginePath(),
VaultKeyValueOperationsSupport.KeyValueBackend.versioned());
- keyValue.put(secretPath, exchange.getMessage().getBody());
+ keyValue.put(secretPath, secret);
}
private void getSecret(Exchange exchange) {
@@ -68,17 +81,9 @@ public class HashicorpVaultProducer extends DefaultProducer {
if
(ObjectHelper.isNotEmpty(exchange.getMessage().getHeader(HashicorpVaultConstants.SECRET_VERSION)))
{
secretVersion =
exchange.getMessage().getHeader(HashicorpVaultConstants.SECRET_VERSION,
String.class);
}
- String completePath = "";
- if (!getEndpoint().getConfiguration().isCloud()) {
- completePath = getEndpoint().getConfiguration().getSecretsEngine()
+ "/" + "data" + "/" + secretPath;
- } else {
- if
(ObjectHelper.isNotEmpty(getEndpoint().getConfiguration().getNamespace())) {
- completePath = getEndpoint().getConfiguration().getNamespace()
+ "/"
- +
getEndpoint().getConfiguration().getSecretsEngine() + "/" + "data" + "/" +
secretPath;
- }
- }
+ String completePath = getEnginePath() + "/" + "data" + "/" +
secretPath;
if (ObjectHelper.isNotEmpty(secretVersion)) {
- completePath = completePath + "?version=" + secretVersion;
+ completePath = completePath + "?version=" +
parseSecretVersion(secretVersion);
}
VaultResponse rawSecret =
getEndpoint().getVaultTemplate().read(completePath);
if (rawSecret != null) {
@@ -91,24 +96,65 @@ public class HashicorpVaultProducer extends DefaultProducer
{
private void deleteSecret(Exchange exchange) {
String secretPath = getSecretPath(exchange);
VaultKeyValueOperations keyValue
- =
getEndpoint().getVaultTemplate().opsForKeyValue(getEndpoint().getConfiguration().getSecretsEngine(),
+ =
getEndpoint().getVaultTemplate().opsForKeyValue(getEnginePath(),
VaultKeyValueOperationsSupport.KeyValueBackend.versioned());
keyValue.delete(secretPath);
}
private void listSecrets(Exchange exchange) {
- List<String> secretsList = new ArrayList<>();
- if (!getEndpoint().getConfiguration().isCloud()) {
- secretsList = getEndpoint().getVaultTemplate()
- .list(getEndpoint().getConfiguration().getSecretsEngine()
+ "/" + "metadata" + "/");
- } else {
- if
(ObjectHelper.isNotEmpty(getEndpoint().getConfiguration().getNamespace())) {
- secretsList = getEndpoint().getVaultTemplate()
- .list(getEndpoint().getConfiguration().getNamespace()
+ "/"
- +
getEndpoint().getConfiguration().getSecretsEngine() + "/" + "metadata" + "/");
+ List<String> secretsList =
getEndpoint().getVaultTemplate().list(getEnginePath() + "/" + "metadata" + "/");
+ exchange.getMessage().setBody(secretsList);
+ }
+
+ /**
+ * The path of the secrets engine: in the namespace when running on
HashiCorp Cloud with a namespace, else the
+ * engine itself (as the secret refresh task does).
+ */
+ private String getEnginePath() {
+ String engine = getConfiguration().getSecretsEngine();
+ if (getConfiguration().isCloud() &&
ObjectHelper.isNotEmpty(getConfiguration().getNamespace())) {
+ return getConfiguration().getNamespace() + "/" + engine;
+ }
+ return engine;
+ }
+
+ /**
+ * The fields of the secret to write: a Map or a POJO as is, and text,
bytes, a stream or a file as a JSON object.
+ */
+ private static Object getSecretBody(Exchange exchange) throws
InvalidPayloadException {
+ // no body would write a new, empty version of the secret
+ Object body = exchange.getMessage().getMandatoryBody();
+ if (body instanceof CharSequence || body instanceof byte[] || body
instanceof InputStream
+ || body instanceof Reader || body instanceof StreamCache ||
body instanceof WrappedFile
+ || body instanceof File || body instanceof Path) {
+ String json = exchange.getMessage().getMandatoryBody(String.class);
+ Map<?, ?> fields;
+ try {
+ fields = MAPPER.readValue(json, Map.class);
+ } catch (JsonProcessingException e) {
+ // the cause is left out on purpose, as its message can quote
the text of the secret
+ fields = null;
}
+ if (fields == null) {
+ throw new IllegalArgumentException(
+ "The body of createSecret must be a Map, a POJO or a
JSON object with the fields of the secret");
+ }
+ return fields;
}
- exchange.getMessage().setBody(secretsList);
+ return body;
+ }
+
+ private static int parseSecretVersion(String secretVersion) {
+ try {
+ int version = Integer.parseInt(secretVersion.trim());
+ if (version >= 0) {
+ return version;
+ }
+ } catch (NumberFormatException e) {
+ // not a number
+ }
+ throw new IllegalArgumentException(
+ "The " + HashicorpVaultConstants.SECRET_VERSION + " header
must be a version number, was: " + secretVersion);
}
@Override
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 04082f515402..6e583b36aae9 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
@@ -106,6 +106,12 @@ public class HashicorpVaultPropertiesFunction extends
ServiceSupport implements
this.client = client;
}
+ HashicorpVaultPropertiesFunction(VaultTemplate client, boolean cloud,
String namespace) {
+ this(client);
+ this.cloud = cloud;
+ this.namespace = namespace;
+ }
+
@Override
protected void doStart() throws Exception {
super.doStart();
@@ -238,13 +244,10 @@ public class HashicorpVaultPropertiesFunction extends
ServiceSupport implements
String returnValue = null;
try {
- String completePath = "";
- if (!cloud) {
- completePath = engine + "/" + "data" + "/" + key;
- } else {
- if (ObjectHelper.isNotEmpty(namespace)) {
- completePath = namespace + "/" + engine + "/" + "data" +
"/" + key;
- }
+ String completePath = engine + "/" + "data" + "/" + key;
+ if (cloud && ObjectHelper.isNotEmpty(namespace)) {
+ // without a namespace the engine itself, as the secret
refresh task does
+ completePath = namespace + "/" + completePath;
}
if (ObjectHelper.isNotEmpty(version)) {
completePath = completePath + "?version=" + version;
diff --git
a/components/camel-hashicorp-vault/src/test/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultProducerTest.java
b/components/camel-hashicorp-vault/src/test/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultProducerTest.java
new file mode 100644
index 000000000000..01f57be92eeb
--- /dev/null
+++
b/components/camel-hashicorp-vault/src/test/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultProducerTest.java
@@ -0,0 +1,203 @@
+/*
+ * 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.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.InvalidPayloadException;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.vault.core.VaultKeyValueOperations;
+import
org.springframework.vault.core.VaultKeyValueOperationsSupport.KeyValueBackend;
+import org.springframework.vault.core.VaultTemplate;
+import org.springframework.vault.support.VaultResponse;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * The producer operations against a mocked {@link VaultTemplate}: the paths
it reads, lists and writes, and the body it
+ * writes.
+ */
+class HashicorpVaultProducerTest extends CamelTestSupport {
+
+ private final VaultTemplate vault = mock(VaultTemplate.class);
+ private final VaultKeyValueOperations keyValue =
mock(VaultKeyValueOperations.class);
+
+ @BeforeEach
+ void bindVault() {
+ context.getRegistry().bind("vault", vault);
+ when(vault.opsForKeyValue(anyString(),
any(KeyValueBackend.class))).thenReturn(keyValue);
+ VaultResponse response = new VaultResponse();
+ response.setData(Map.of("data", Map.of("id", "123")));
+ when(vault.read(anyString())).thenReturn(response);
+ when(vault.list(anyString())).thenReturn(List.of("hello"));
+ }
+
+ @Test
+ void getSecretOnCloudWithoutNamespace() {
+ Exchange exchange = send("getSecret&cloud=true", null);
+
+ assertNull(exchange.getException());
+ verify(vault).read("secret/data/hello");
+ assertEquals(Map.of("data", Map.of("id", "123")),
exchange.getMessage().getBody());
+ }
+
+ @Test
+ void getSecretOnCloudWithNamespace() {
+ Exchange exchange = send("getSecret&cloud=true&namespace=admin", null);
+
+ assertNull(exchange.getException());
+ verify(vault).read("admin/secret/data/hello");
+ }
+
+ @Test
+ void getSecretOnPremiseIgnoresNamespace() {
+ Exchange exchange = send("getSecret&namespace=admin", null);
+
+ assertNull(exchange.getException());
+ verify(vault).read("secret/data/hello");
+ }
+
+ @Test
+ void listSecretsOnCloudWithoutNamespace() {
+ Exchange exchange = send("listSecrets&cloud=true", null);
+
+ assertNull(exchange.getException());
+ verify(vault).list("secret/metadata/");
+ assertEquals(List.of("hello"), exchange.getMessage().getBody());
+ }
+
+ @Test
+ void listSecretsOnCloudWithNamespace() {
+ Exchange exchange = send("listSecrets&cloud=true&namespace=admin",
null);
+
+ assertNull(exchange.getException());
+ verify(vault).list("admin/secret/metadata/");
+ }
+
+ @Test
+ void createAndDeleteSecretOnCloudWithNamespace() {
+ assertNull(send("createSecret&cloud=true&namespace=admin",
Map.of("id", "123")).getException());
+ assertNull(send("deleteSecret&cloud=true&namespace=admin",
null).getException());
+
+ // the same engine path as getSecret and listSecrets read
+ verify(vault, times(2)).opsForKeyValue("admin/secret",
KeyValueBackend.versioned());
+ verify(keyValue).put("hello", Map.of("id", "123"));
+ verify(keyValue).delete("hello");
+ }
+
+ @Test
+ void getSecretWithVersion() {
+ Exchange exchange =
template.request("hashicorp-vault:secret?vaultTemplate=#vault&secretPath=hello"
+ + "&operation=getSecret",
+ e ->
e.getMessage().setHeader(HashicorpVaultConstants.SECRET_VERSION, "2"));
+
+ assertNull(exchange.getException());
+ verify(vault).read("secret/data/hello?version=2");
+ }
+
+ @Test
+ void getSecretWithInvalidVersion() {
+ Exchange exchange =
template.request("hashicorp-vault:secret?vaultTemplate=#vault&secretPath=hello"
+ + "&operation=getSecret",
+ e ->
e.getMessage().setHeader(HashicorpVaultConstants.SECRET_VERSION,
"2&list=true"));
+
+ IllegalArgumentException cause =
assertInstanceOf(IllegalArgumentException.class, exchange.getException());
+
assertTrue(cause.getMessage().contains(HashicorpVaultConstants.SECRET_VERSION),
cause.getMessage());
+ verify(vault, never()).read(anyString());
+ }
+
+ @Test
+ void createSecretFromMap() {
+ assertNull(send("createSecret", Map.of("id", "123")).getException());
+
+ verify(keyValue).put("hello", Map.of("id", "123"));
+ }
+
+ @Test
+ void createSecretFromJsonText() {
+ assertNull(send("createSecret", "{\"id\":\"123\"}").getException());
+
+ verify(keyValue).put("hello", Map.of("id", "123"));
+ }
+
+ @Test
+ void createSecretFromJsonBytes() {
+ assertNull(send("createSecret",
"{\"id\":\"123\"}".getBytes(StandardCharsets.UTF_8)).getException());
+
+ verify(keyValue).put("hello", Map.of("id", "123"));
+ }
+
+ @Test
+ void createSecretFromJsonStream() {
+ Exchange exchange = send("createSecret",
+ new
ByteArrayInputStream("{\"id\":\"123\"}".getBytes(StandardCharsets.UTF_8)));
+
+ assertNull(exchange.getException());
+ verify(keyValue).put("hello", Map.of("id", "123"));
+ }
+
+ @Test
+ void createSecretFromTextThatIsNotAJsonObject() {
+ Exchange exchange = send("createSecret", "my-password");
+
+ IllegalArgumentException cause =
assertInstanceOf(IllegalArgumentException.class, exchange.getException());
+ // the text may be the secret itself, so it is not in the message
+ assertFalse(cause.getMessage().contains("my-password"),
cause.getMessage());
+ assertNull(cause.getCause());
+ verify(keyValue, never()).put(anyString(), any());
+ }
+
+ @Test
+ void createSecretFromJsonArray() {
+ Exchange exchange = send("createSecret", "[\"123\"]");
+
+ assertInstanceOf(IllegalArgumentException.class,
exchange.getException());
+ verify(keyValue, never()).put(anyString(), any());
+ }
+
+ @Test
+ void createSecretWithoutBody() {
+ Exchange exchange = send("createSecret", null);
+
+ // no new, empty version of the secret
+ assertInstanceOf(InvalidPayloadException.class,
exchange.getException());
+ verify(keyValue, never()).put(anyString(), any());
+ }
+
+ private Exchange send(String operationAndOptions, Object body) {
+ return
template.request("hashicorp-vault:secret?vaultTemplate=#vault&secretPath=hello&operation="
+ + operationAndOptions,
+ e -> e.getMessage().setBody(body));
+ }
+}
diff --git
a/components/camel-hashicorp-vault/src/test/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultPropertiesFunctionTest.java
b/components/camel-hashicorp-vault/src/test/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultPropertiesFunctionTest.java
index 7e1efd682734..1a8e4f282c58 100644
---
a/components/camel-hashicorp-vault/src/test/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultPropertiesFunctionTest.java
+++
b/components/camel-hashicorp-vault/src/test/java/org/apache/camel/component/hashicorp/vault/HashicorpVaultPropertiesFunctionTest.java
@@ -107,6 +107,27 @@ class HashicorpVaultPropertiesFunctionTest {
assertTrue(mismatches.isEmpty(), "lookups read from the wrong engine:
" + mismatches);
}
+ @Test
+ void lookupOnCloudWithoutNamespaceReadsTheEngine() {
+ FakeVaultTemplate vault = new FakeVaultTemplate(
+ Map.of("secret/data/api", Map.of("password", "pw")));
+ HashicorpVaultPropertiesFunction function = new
HashicorpVaultPropertiesFunction(vault, true, null);
+
+ assertEquals("pw", function.apply("secret:api#password"));
+ assertEquals("pw", function.apply("api#password"));
+ assertEquals(List.of("secret/data/api", "secret/data/api"),
vault.reads);
+ }
+
+ @Test
+ void lookupOnCloudWithNamespaceReadsInTheNamespace() {
+ FakeVaultTemplate vault = new FakeVaultTemplate(
+ Map.of("admin/secret/data/api?version=2", Map.of("password",
"pw")));
+ HashicorpVaultPropertiesFunction function = new
HashicorpVaultPropertiesFunction(vault, true, "admin");
+
+ assertEquals("pw", function.apply("secret:api#password@2"));
+ assertEquals(List.of("admin/secret/data/api?version=2"), vault.reads);
+ }
+
@Test
void missingSecretWithFieldReturnsNull() {
HashicorpVaultPropertiesFunction function = new
HashicorpVaultPropertiesFunction(new FakeVaultTemplate(Map.of()));
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index cd8e0d953707..cef13898d65b 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -944,6 +944,22 @@ of the called method, and when that is not possible it
sends nothing and ends th
A route must set the response message of the method as the body. A reply
without a body still sends an empty response
message.
+=== camel-hashicorp-vault - cloud without a namespace, the createSecret body,
and the secret version
+
+With `cloud=true` and no `namespace`, the `hashicorp-vault` producer now works
on the secrets engine itself, as the
+secret refresh task already did. Before, `getSecret` failed with `Path must
not be empty` and `listSecrets` returned an
+empty list. A `hashicorp:` property with `camel.vault.hashicorp.cloud=true`
and no namespace now also reads the secret
+from the engine itself. With `cloud=true` and a `namespace`, `createSecret`
and `deleteSecret` now work on the secret
+in the namespace (`<namespace>/<secretsEngine>`), as `getSecret` and
`listSecrets` do. Before, they worked on the
+engine outside the namespace.
+
+`createSecret` now writes a body of text, bytes, a stream or a file that holds
a JSON object as the fields of the
+secret; before, such a body failed. A body that is not a JSON object, and a
message without a body, now fail the
+exchange. Before, a message without a body wrote a new, empty version of the
secret.
+
+The `CamelHashicorpVaultSecretVersion` header of `getSecret` must be a version
number; any other value now fails the
+exchange with an `IllegalArgumentException`.
+
=== camel-hivemq - a body that does not convert to a byte array fails the
exchange
The `hivemq` producer published a message body that has no conversion to
`byte[]`, such as a `Map`, a POJO or a