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 index 9034cd5f..3f09928a 100644 --- 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 @@ -1,3 +1,18 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.vault.core; import org.springframework.vault.VaultException; @@ -6,24 +21,40 @@ import org.springframework.vault.VaultException; * An exception which is used in case that no secret is found from Vault server. * * @author Younghwan Jang + * @author Mark Paluch * @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); - } + private final String path; + + /** + * Create a {@code SecretNotFoundException} with the specified detail message. + * @param msg the detail message. + * @param path the canonical data path. + */ + public SecretNotFoundException(String msg, String path) { + super(msg); + this.path = path; + } + + /** + * Create a {@code SecretNotFoundException} with the specified detail message and + * nested exception. + * @param msg the detail message. + * @param cause the nested exception. + * @param path the canonical data path. + */ + public SecretNotFoundException(String msg, Throwable cause, String path) { + super(msg, cause); + this.path = path; + } + + /** + * @return the path to the requested secret. + */ + public String getPath() { + return this.path; + } + } 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 0a202127..ee50a37c 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,13 +24,13 @@ 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 * version 1. * * @author Mark Paluch + * @author Younghwan Jang * @since 2.1 * @see KeyValueBackend#KV_1 */ @@ -99,6 +99,11 @@ class VaultKeyValue1Template extends VaultKeyValueAccessor implements VaultKeyVa }); } + @Override + public boolean patch(String path, Map patch) { + throw new IllegalStateException("K/V engine mount must be version 2 for patch support"); + } + @Override public void put(String path, Object body) { @@ -107,11 +112,6 @@ 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/VaultKeyValue2Accessor.java b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValue2Accessor.java index bf8ca678..3152a21d 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValue2Accessor.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValue2Accessor.java @@ -33,7 +33,7 @@ import org.springframework.vault.support.VaultResponseSupport; */ abstract class VaultKeyValue2Accessor extends VaultKeyValueAccessor { - private final String path; + final String path; /** * Create a new {@link VaultKeyValue2Accessor} given {@link VaultOperations} and the 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 329d6f8b..c5656155 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 @@ -17,6 +17,7 @@ package org.springframework.vault.core; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; import org.springframework.lang.Nullable; @@ -30,6 +31,7 @@ import org.springframework.vault.support.VaultResponseSupport; * version 2. * * @author Mark Paluch + * @author Younghwan Jang * @since 2.1 */ class VaultKeyValue2Template extends VaultKeyValue2Accessor implements VaultKeyValueOperations { @@ -69,7 +71,7 @@ class VaultKeyValue2Template extends VaultKeyValue2Accessor implements VaultKeyV @Nullable @Override - @SuppressWarnings("unchecked") + @SuppressWarnings({ "unchecked", "rawtypes" }) public VaultResponseSupport get(String path, Class responseType) { Assert.hasText(path, "Path must not be empty"); @@ -83,6 +85,47 @@ class VaultKeyValue2Template extends VaultKeyValue2Accessor implements VaultKeyV }); } + @Override + public boolean patch(String path, Map patch) { + + Assert.hasText(path, "Path must not be empty"); + Assert.notNull(patch, "Patch body must not be null"); + + // To do patch operation, we need to do a read operation first + VaultResponse readResponse = get(path); + if (readResponse == null || readResponse.getData() == null) { + throw new SecretNotFoundException( + String.format("No data found at %s; patch only works on existing data", createDataPath(path)), + String.format("%s/%s", this.path, path)); + } + + if (readResponse.getMetadata() == null) { + throw new VaultException("Metadata must not be null"); + } + + Map metadata = readResponse.getMetadata(); + Map data = new LinkedHashMap<>(readResponse.getRequiredData()); + data.putAll(patch); + + Map body = new HashMap<>(); + body.put("data", data); + body.put("options", Collections.singletonMap("cas", metadata.get("version"))); + + try { + doWrite(createDataPath(path), body); + return true; + } + catch (VaultException e) { + + if (e.getMessage() != null && (e.getMessage().contains("check-and-set") + || e.getMessage().contains("did not match the current version"))) { + return false; + } + + throw e; + } + } + @Override public void put(String path, Object body) { @@ -91,38 +134,4 @@ 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 e0a9b74f..d4833644 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 @@ -110,7 +110,8 @@ abstract class VaultKeyValueAccessor implements VaultKeyValueOperationsSupport { JsonNode jsonNode = getJsonNode(response); JsonNode jsonMeta = response.getRequiredData().at("/metadata"); - response.setMetadata(mapper.convertValue(jsonMeta, new TypeReference>() {})); + response.setMetadata(this.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 2286e77d..670a52d6 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 @@ -15,12 +15,12 @@ */ package org.springframework.vault.core; +import java.util.Map; + 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 @@ -31,6 +31,7 @@ import java.util.Map; * {@link VaultVersionedKeyValueOperations} in such cases instead. * * @author Mark Paluch + * @author Younghwan Jang * @since 2.1 * @see VaultVersionedKeyValueOperations * @see VaultKeyValueOperationsSupport.KeyValueBackend @@ -54,6 +55,16 @@ public interface VaultKeyValueOperations extends VaultKeyValueOperationsSupport @Nullable VaultResponseSupport get(String path, Class responseType); + /** + * Update the secret at {@code path} without removing the existing secrets. Requires a + * Key-Value version 2 mount to ensure an atomic update. + * @param path must not be {@literal null}. + * @param patch must not be {@literal null}. + * @return {@code true} if the patch operation is successful, {@code false} otherwise. + * @since 2.3 + */ + boolean patch(String path, Map patch); + /** * Write the secret at {@code path}. * @param path must not be {@literal null}. @@ -61,12 +72,4 @@ 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 3472f90c..6abe02cb 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,7 +15,10 @@ */ package org.springframework.vault.core; -import org.junit.jupiter.api.Assertions; +import java.util.Collections; +import java.util.Map; +import java.util.UUID; + import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -24,19 +27,15 @@ 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; +import static org.assertj.core.api.Fail.fail; /** * Integration tests for {@link VaultKeyValue2Template} using the versioned Key/Value (k/v * version 2) backend. * * @author Mark Paluch + * @author Younghwan Jang */ @ExtendWith(SpringExtension.class) @ContextConfiguration(classes = VaultIntegrationTestConfiguration.class) @@ -48,8 +47,10 @@ class VaultKeyValueTemplateVersionedIntegrationTests extends AbstractVaultKeyVal @Test void shouldPatchSecret() { - final String oldKey = "key"; - final String newKey = "newKey"; + + String oldKey = "key"; + String newKey = "newKey"; + Map secret = Collections.singletonMap(oldKey, "value"); String key = UUID.randomUUID().toString(); @@ -58,15 +59,26 @@ class VaultKeyValueTemplateVersionedIntegrationTests extends AbstractVaultKeyVal Map newSecret = Collections.singletonMap(newKey, "newValue"); - assertTrue(this.kvOperations.patch(key, newSecret)); - + assertThat(this.kvOperations.patch(key, newSecret)).isTrue(); 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); + + Map data = vaultResponse.getRequiredData(); + assertThat(data).containsKey(oldKey).containsKey(newKey); + } + + @Test + void patchShouldFailWithSecretNotFoundException() { + + try { + this.kvOperations.patch("unknown", Collections.singletonMap("foo", "newValue")); + fail("missing SecretNotFoundException"); + } + catch (SecretNotFoundException e) { + assertThat(e).hasMessageContaining("versioned/data/unknown"); + assertThat(e.getPath()).isEqualTo("versioned/unknown"); + } } }