From bc3ff1d47d71a318d9b4291a7ac64d87ef68470a Mon Sep 17 00:00:00 2001 From: Younghwan Jang Date: Wed, 23 Sep 2020 23:20:58 -0700 Subject: [PATCH] Adding support of KV Patch operation Closes gh-585 Original pull request gh-587 --- .../vault/core/SecretNotFoundException.java | 29 +++++++++++++++ .../vault/core/VaultKeyValue1Template.java | 6 ++++ .../vault/core/VaultKeyValue2Template.java | 36 +++++++++++++++++++ .../vault/core/VaultKeyValueAccessor.java | 4 +++ .../vault/core/VaultKeyValueOperations.java | 10 ++++++ ...alueTemplateVersionedIntegrationTests.java | 34 ++++++++++++++++++ 6 files changed, 119 insertions(+) create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/core/SecretNotFoundException.java diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/SecretNotFoundException.java b/spring-vault-core/src/main/java/org/springframework/vault/core/SecretNotFoundException.java new file mode 100644 index 00000000..9034cd5f --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/SecretNotFoundException.java @@ -0,0 +1,29 @@ +package org.springframework.vault.core; + +import org.springframework.vault.VaultException; + +/** + * An exception which is used in case that no secret is found from Vault server. + * + * @author Younghwan Jang + * @since 2.3 + */ +public class SecretNotFoundException extends VaultException { + /** + * Create a {@code SecretNotFoundException} with the specified detail message. + * @param msg the detail message. + */ + public SecretNotFoundException(String msg) { + super(msg); + } + + /** + * Create a {@code SecretNotFoundException} with the specified detail message and nested + * exception. + * @param msg the detail message. + * @param cause the nested exception. + */ + public SecretNotFoundException(String msg, Throwable cause) { + super(msg, cause); + } +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValue1Template.java b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValue1Template.java index e6fd22b2..0a202127 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValue1Template.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValue1Template.java @@ -24,6 +24,7 @@ import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.vault.support.VaultResponse; import org.springframework.vault.support.VaultResponseSupport; +import org.springframework.web.reactive.function.client.WebClientResponseException; /** * Default implementation of {@link VaultKeyValueOperations} for the Key/Value backend @@ -106,6 +107,11 @@ class VaultKeyValue1Template extends VaultKeyValueAccessor implements VaultKeyVa doWrite(createDataPath(path), body); } + @Override + public boolean patch(String path, Map kv) { + throw new IllegalStateException("Patch operation is available only in KV secret engine V2"); + } + @Override public KeyValueBackend getApiVersion() { return KeyValueBackend.KV_1; diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValue2Template.java b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValue2Template.java index d4448e45..329d6f8b 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValue2Template.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValue2Template.java @@ -16,10 +16,12 @@ package org.springframework.vault.core; import java.util.Collections; +import java.util.HashMap; import java.util.Map; import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.vault.VaultException; import org.springframework.vault.support.VaultResponse; import org.springframework.vault.support.VaultResponseSupport; @@ -89,4 +91,38 @@ class VaultKeyValue2Template extends VaultKeyValue2Accessor implements VaultKeyV doWrite(createDataPath(path), Collections.singletonMap("data", body)); } + /** + * Performs a KV Patch operation. + * @param path must not be {@literal null} or empty. + * @param kv New key value map to be updated + * @since 2.3 + */ + @Override + public boolean patch(String path, Map kv) { + Assert.hasText(path, "Path must not be empty"); + + // To do patch operation, we need to do a read operation first + VaultResponse readResponse = get(path); + if (null == readResponse) { + throw new VaultException("VaultResponse must not be null"); + } else if (null == readResponse.getData()) { + throw new SecretNotFoundException("No data found at %s; patch only works on existing data"); + } else if (null == readResponse.getMetadata()) { + throw new VaultException("Metadata must not be null"); + } + Map data = readResponse.getData(); + Map metadata = readResponse.getMetadata(); + kv.forEach(data::put); + Map body = new HashMap<>(); + body.put("data", data); + body.put("options", Collections.singletonMap("cas", metadata.get("version"))); + + VaultResponse writeResponse = doWrite(createDataPath(path), body); + if (null == writeResponse) { + return false; + } + Map writeResponseData = writeResponse.getData(); + return null != writeResponseData; + } + } diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValueAccessor.java b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValueAccessor.java index dc24c372..e0a9b74f 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValueAccessor.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValueAccessor.java @@ -16,10 +16,12 @@ package org.springframework.vault.core; import java.io.IOException; +import java.util.Map; import java.util.Optional; import java.util.function.BiFunction; import java.util.function.Function; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -107,6 +109,8 @@ abstract class VaultKeyValueAccessor implements VaultKeyValueOperationsSupport { if (response != null) { JsonNode jsonNode = getJsonNode(response); + JsonNode jsonMeta = response.getRequiredData().at("/metadata"); + response.setMetadata(mapper.convertValue(jsonMeta, new TypeReference>() {})); return mappingFunction.apply(response, deserialize(jsonNode, deserializeAs)); } diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValueOperations.java b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValueOperations.java index 6aefa861..2286e77d 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValueOperations.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValueOperations.java @@ -19,6 +19,8 @@ import org.springframework.lang.Nullable; import org.springframework.vault.support.VaultResponse; import org.springframework.vault.support.VaultResponseSupport; +import java.util.Map; + /** * Interface that specifies a basic set of Vault operations using Vault's Key/Value secret * backend. Paths used in this operations interface are relative and outgoing requests @@ -59,4 +61,12 @@ public interface VaultKeyValueOperations extends VaultKeyValueOperationsSupport */ void put(String path, Object body); + /** + * Updates the secret at {@code path} without removing the existing secrets. + * @param path must not be {@literal null}. + * @param kv must not be {@literal null}. + * @return true if the patch operation is successful, false otherwise. + */ + boolean patch(String path, Map kv); + } diff --git a/spring-vault-core/src/test/java/org/springframework/vault/core/VaultKeyValueTemplateVersionedIntegrationTests.java b/spring-vault-core/src/test/java/org/springframework/vault/core/VaultKeyValueTemplateVersionedIntegrationTests.java index 20b7a87a..3472f90c 100644 --- a/spring-vault-core/src/test/java/org/springframework/vault/core/VaultKeyValueTemplateVersionedIntegrationTests.java +++ b/spring-vault-core/src/test/java/org/springframework/vault/core/VaultKeyValueTemplateVersionedIntegrationTests.java @@ -15,11 +15,22 @@ */ package org.springframework.vault.core; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.vault.core.VaultKeyValueOperationsSupport.KeyValueBackend; +import org.springframework.vault.support.VaultResponse; + +import java.util.Collections; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Integration tests for {@link VaultKeyValue2Template} using the versioned Key/Value (k/v @@ -35,4 +46,27 @@ class VaultKeyValueTemplateVersionedIntegrationTests extends AbstractVaultKeyVal super("versioned", KeyValueBackend.versioned()); } + @Test + void shouldPatchSecret() { + final String oldKey = "key"; + final String newKey = "newKey"; + Map secret = Collections.singletonMap(oldKey, "value"); + + String key = UUID.randomUUID().toString(); + + this.kvOperations.put(key, secret); + + Map newSecret = Collections.singletonMap(newKey, "newValue"); + + assertTrue(this.kvOperations.patch(key, newSecret)); + + assertThat(this.kvOperations.list("/")).contains(key); + VaultResponse vaultResponse = this.kvOperations.get(key); + assertNotNull(vaultResponse); + Map data = vaultResponse.getData(); + assertNotNull(data); + assertThat(data).containsKey(oldKey); + assertThat(data).containsKey(newKey); + } + }