This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-26255-hashicorp-vault-cloud-path in repository https://gitbox.apache.org/repos/asf/camel.git
commit 7414fd8a7d3033b7bf3fb5877cd534bc3947f564 Author: Claus Ibsen <[email protected]> AuthorDate: Tue Nov 3 15:29:25 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 With cloud=true and no namespace, getSecret read the path "" and listSecrets returned an empty list; they now use the engine itself, as the refresh task did, and so does the hashicorp: properties function. With a namespace, createSecret and deleteSecret now also work in the namespace. createSecret converts text, bytes, a stream or a file with a JSON object to the fields of the secret, and fails on a message without a body instead of writing an empty version. The CamelHashicorpVaultSecretVersion header must be a version number, and is no longer appended to the read path as is. 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 7c82ebd18930..bb805abdf0cd 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 @@ -928,6 +928,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
