From c303f55312be230c2b91bb4fd82f8b5badc5f631 Mon Sep 17 00:00:00 2001 From: "Timothy R. Weiand" <29555299+tweiand@users.noreply.github.com> Date: Mon, 17 Apr 2023 12:20:31 -0700 Subject: [PATCH] `ReactiveVaultTemplate` for the key-value backend version 2. Closes gh-576 Original pull request: gh-807 --- .../vault/client/VaultResponses.java | 63 +++++ .../vault/core/ReactiveKeyValueHelper.java | 52 ++++ .../core/ReactiveVaultKeyValue1Template.java | 97 +++++++ .../core/ReactiveVaultKeyValue2Accessor.java | 86 ++++++ .../core/ReactiveVaultKeyValue2Template.java | 127 +++++++++ .../core/ReactiveVaultKeyValueAccessor.java | 130 +++++++++ ...activeVaultKeyValueMetadataOperations.java | 45 ++++ ...ReactiveVaultKeyValueMetadataTemplate.java | 74 +++++ .../core/ReactiveVaultKeyValueOperations.java | 58 ++++ ...eactiveVaultKeyValueOperationsSupport.java | 58 ++++ .../vault/core/ReactiveVaultOperations.java | 24 ++ .../vault/core/ReactiveVaultTemplate.java | 102 ++++--- ...ctiveVaultVersionedKeyValueOperations.java | 105 ++++++++ ...eactiveVaultVersionedKeyValueTemplate.java | 181 +++++++++++++ .../vault/core/VaultKeyValue1Template.java | 10 +- .../vault/core/VaultKeyValue2Template.java | 18 +- .../core/VaultKeyValueMetadataTemplate.java | 58 +--- .../vault/core/VaultKeyValueUtilities.java | 98 +++++++ .../core/VaultVersionedKeyValueTemplate.java | 38 +-- .../support/VaultResponseDataVersion2.java | 34 +++ .../vault/support/VaultResponseSupport.java | 11 + .../vault/support/VaultResponseVersion2.java | 21 ++ ...VaultKeyValueTemplateIntegrationTests.java | 147 ++++++++++ ...ValueMetadataTemplateIntegrationTests.java | 158 +++++++++++ ...VaultKeyValueTemplateIntegrationTests.java | 52 ++++ ...alueTemplateVersionedIntegrationTests.java | 92 +++++++ ...ionedKeyValueTemplateIntegrationTests.java | 252 ++++++++++++++++++ 27 files changed, 2032 insertions(+), 159 deletions(-) create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveKeyValueHelper.java create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValue1Template.java create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValue2Accessor.java create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValue2Template.java create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValueAccessor.java create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValueMetadataOperations.java create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValueMetadataTemplate.java create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValueOperations.java create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValueOperationsSupport.java create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultVersionedKeyValueOperations.java create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultVersionedKeyValueTemplate.java create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValueUtilities.java create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/support/VaultResponseDataVersion2.java create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/support/VaultResponseVersion2.java create mode 100644 spring-vault-core/src/test/java/org/springframework/vault/core/AbstractReactiveVaultKeyValueTemplateIntegrationTests.java create mode 100644 spring-vault-core/src/test/java/org/springframework/vault/core/ReactiveVaultKeyValueMetadataTemplateIntegrationTests.java create mode 100644 spring-vault-core/src/test/java/org/springframework/vault/core/ReactiveVaultKeyValueTemplateIntegrationTests.java create mode 100644 spring-vault-core/src/test/java/org/springframework/vault/core/ReactiveVaultKeyValueTemplateVersionedIntegrationTests.java create mode 100644 spring-vault-core/src/test/java/org/springframework/vault/core/ReactiveVaultVersionedKeyValueTemplateIntegrationTests.java diff --git a/spring-vault-core/src/main/java/org/springframework/vault/client/VaultResponses.java b/spring-vault-core/src/main/java/org/springframework/vault/client/VaultResponses.java index 2e762b9a..a2e75213 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/client/VaultResponses.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/client/VaultResponses.java @@ -33,6 +33,7 @@ import org.springframework.http.converter.json.MappingJackson2HttpMessageConvert import org.springframework.util.Assert; import org.springframework.util.StringUtils; import org.springframework.vault.VaultException; +import org.springframework.vault.support.VaultResponseDataVersion2; import org.springframework.vault.support.VaultResponseSupport; import org.springframework.web.client.HttpStatusCodeException; @@ -134,6 +135,68 @@ public abstract class VaultResponses { }; } + public static ParameterizedTypeReference> getTypeReference( + final ParameterizedTypeReference responseType) { + + Assert.notNull(responseType, "Response type must not be null"); + + final Type supportType = new ParameterizedType() { + + @Override + public Type[] getActualTypeArguments() { + return new Type[] { responseType.getType() }; + } + + @Override + public Type getRawType() { + return VaultResponseSupport.class; + } + + @Override + public Type getOwnerType() { + return VaultResponseSupport.class; + } + }; + + return new ParameterizedTypeReference>() { + @Override + public Type getType() { + return supportType; + } + }; + } + + public static ParameterizedTypeReference> getDataTypeReference( + final Class responseType) { + + Assert.notNull(responseType, "Response type must not be null"); + + final Type supportType = new ParameterizedType() { + + @Override + public Type[] getActualTypeArguments() { + return new Type[] { responseType }; + } + + @Override + public Type getRawType() { + return VaultResponseDataVersion2.class; + } + + @Override + public Type getOwnerType() { + return VaultResponseDataVersion2.class; + } + }; + + return new ParameterizedTypeReference>() { + @Override + public Type getType() { + return supportType; + } + }; + } + /** * Obtain the error message from a JSON response. * @param json must not be {@literal null}. diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveKeyValueHelper.java b/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveKeyValueHelper.java new file mode 100644 index 00000000..c3ca7ffd --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveKeyValueHelper.java @@ -0,0 +1,52 @@ +/* + * Copyright 2018-2022 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 java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.vault.support.VaultResponseSupport; +import reactor.core.publisher.Mono; + +/** + * Helper for wrapping imperative operations. + * + * @author Timothy R. Weiand + * @since 3.1 + */ +class ReactiveKeyValueHelper { + + ReactiveKeyValueHelper() { + } + + static Mono getRequiredData(VaultResponseSupport support) { + return Mono.fromCallable(support::getRequiredData); + } + + static Map makeMetadata(final Map metadata, final Map requiredData, + Map patch) { + Map data = new LinkedHashMap<>(requiredData); + data.putAll(patch); + + Map body = new HashMap<>(); + body.put("data", data); + body.put("options", Collections.singletonMap("cas", metadata.get("version"))); + + return body; + } + +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValue1Template.java b/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValue1Template.java new file mode 100644 index 00000000..579a2825 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValue1Template.java @@ -0,0 +1,97 @@ +/* + * Copyright 2018-2022 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 java.util.Map; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.util.Assert; +import org.springframework.vault.core.VaultKeyValueOperationsSupport.KeyValueBackend; +import org.springframework.vault.support.VaultResponse; +import org.springframework.vault.support.VaultResponseSupport; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Default implementation of {@link ReactiveVaultKeyValueOperations} for the Key/Value + * backend version 1. + * + * @author Timothy R. Weiand + * @since 3.1 + */ +class ReactiveVaultKeyValue1Template extends ReactiveVaultKeyValueAccessor implements ReactiveVaultKeyValueOperations { + + /** + * Create a new {@link ReactiveVaultKeyValue1Template} given + * {@link ReactiveVaultOperations} and the mount {@code path}. + * @param vaultOperations must not be {@literal null}. + * @param path must not be empty or {@literal null}. + */ + public ReactiveVaultKeyValue1Template(ReactiveVaultOperations vaultOperations, String path) { + + super(vaultOperations, path); + } + + @Override + public Flux list(String path) { + return reactiveVaultOperations.list(createDataPath(path)); + } + + @Override + @SuppressWarnings("unchecked") + public Mono get(String path) { + ParameterizedTypeReference> ref = new ParameterizedTypeReference<>() { + }; + + return doRead(path, ref).onErrorResume(WebClientResponseException.NotFound.class, e -> Mono.empty()) + .map(response -> { + VaultResponse vaultResponse = new VaultResponse(); + VaultResponseSupport.updateWithoutData(vaultResponse, response); + vaultResponse.setData(response.getData()); + return vaultResponse; + }); + } + + @Override + public Mono> get(String path, Class responseType) { + + return doRead(path, responseType).onErrorResume(WebClientResponseException.NotFound.class, e -> Mono.empty()); + } + + @Override + public Mono patch(String path, Map patch) { + throw new IllegalStateException("K/V engine mount must be version 2 for patch support"); + } + + @Override + public Mono put(String path, Object body) { + + Assert.hasText(path, "Path must not be empty"); + + return doWrite(createDataPath(path), body).then(); + } + + @Override + public KeyValueBackend getApiVersion() { + return KeyValueBackend.KV_1; + } + + @Override + String createDataPath(String path) { + return String.format("%s/%s", this.path, path); + } + +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValue2Accessor.java b/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValue2Accessor.java new file mode 100644 index 00000000..32f884fd --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValue2Accessor.java @@ -0,0 +1,86 @@ +/* + * Copyright 2018-2022 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 java.util.List; +import java.util.Map; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.vault.client.VaultResponses; +import org.springframework.vault.core.VaultKeyValueOperationsSupport.KeyValueBackend; +import org.springframework.vault.support.VaultResponseSupport; +import reactor.core.publisher.Flux; + +/** + * Support class to build accessor methods for the Vault key-value backend version 2. + * + * @author Timothy R. Weiand + * @since 3.1 + * @see KeyValueBackend#KV_2 + */ +abstract class ReactiveVaultKeyValue2Accessor extends ReactiveVaultKeyValueAccessor { + + final String path; + + /** + * Create a new {@link ReactiveVaultKeyValue2Accessor} given {@link VaultOperations} + * and the mount {@code path}. + * @param reactiveVaultOperations must not be {@literal null}. + * @param path must not be empty or {@literal null}. + */ + ReactiveVaultKeyValue2Accessor(ReactiveVaultOperations reactiveVaultOperations, String path) { + + super(reactiveVaultOperations, path); + + this.path = path; + } + + @Override + @SuppressWarnings("unchecked") + public Flux list(String path) { + + String pathToUse = path.equals("/") ? "" : path.endsWith("/") ? path : (path + "/"); + + // TODO: to test - null returns empty + ParameterizedTypeReference>> type = VaultResponses + .getTypeReference(new ParameterizedTypeReference<>() { + }); + + return doReadRaw(String.format("%s?list=true", createBackendPath("metadata", pathToUse)), type, false) + .flatMap(ReactiveKeyValueHelper::getRequiredData) + .flatMapMany(response -> { + final List list = (List) response.get("keys"); + if (null == list) { + return Flux.empty(); + } + return Flux.fromIterable(list); + + }); + } + + @Override + public KeyValueBackend getApiVersion() { + return KeyValueBackend.KV_2; + } + + String createDataPath(String path) { + return createBackendPath("data", path); + } + + String createBackendPath(String segment, String path) { + return String.format("%s/%s/%s", this.path, segment, path); + } + +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValue2Template.java b/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValue2Template.java new file mode 100644 index 00000000..44a4fe82 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValue2Template.java @@ -0,0 +1,127 @@ +/* + * Copyright 2018-2022 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 java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.util.Assert; +import org.springframework.vault.VaultException; +import org.springframework.vault.client.VaultResponses; +import org.springframework.vault.support.VaultResponse; +import org.springframework.vault.support.VaultResponseDataVersion2; +import org.springframework.vault.support.VaultResponseSupport; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import reactor.core.publisher.Mono; + +/** + * Default implementation of {@link VaultKeyValueOperations} for the key-value backend + * version 2. + * + * @author Timothy R. Weiand + * @since 3.1 + */ +class ReactiveVaultKeyValue2Template extends ReactiveVaultKeyValue2Accessor implements ReactiveVaultKeyValueOperations { + + /** + * Create a new {@link ReactiveVaultKeyValue2Template} given {@link VaultOperations} + * and the mount {@code path}. + * @param vaultOperations must not be {@literal null}. + * @param path must not be empty or {@literal null}. + */ + public ReactiveVaultKeyValue2Template(ReactiveVaultOperations vaultOperations, String path) { + super(vaultOperations, path); + } + + @Override + @SuppressWarnings("unchecked") + public Mono get(String path) { + ParameterizedTypeReference>> ref = new ParameterizedTypeReference<>() { + }; + + return doRead(path, ref).onErrorResume(WebClientResponseException.NotFound.class, e -> Mono.empty()) + .map(response -> { + VaultResponse vaultResponse = new VaultResponse(); + VaultResponseSupport.updateWithoutData(vaultResponse, response); + VaultResponseDataVersion2> data = response.getData(); + if (null != data) { + vaultResponse.setData(data.getData()); + vaultResponse.setMetadata(data.getMetadata()); + } + return vaultResponse; + }); + } + + @Override + public Mono> get(String path, Class responseType) { + ParameterizedTypeReference>> ref = VaultResponses + .getTypeReference(VaultResponses.getDataTypeReference(responseType)); + return doReadRaw(createDataPath(path), ref, false) + .onErrorResume(WebClientResponseException.NotFound.class, e -> Mono.empty()) + .map(response -> { + VaultResponseSupport vaultResponse = new VaultResponseSupport<>(); + VaultResponseSupport.updateWithoutData(vaultResponse, response); + VaultResponseDataVersion2 data = response.getData(); + if (null != data) { + vaultResponse.setData(data.getData()); + vaultResponse.setMetadata(data.getMetadata()); + } + return vaultResponse; + }); + } + + @Override + public Mono patch(String path, Map patch) { + Assert.notNull(patch, "Patch body must not be null"); + return get(path) + .onErrorResume(WebClientResponseException.NotFound.class, + e -> Mono.error(new SecretNotFoundException(String + .format("No data found at %s; patch only works on existing data", createDataPath(path)), + String.format("%s/%s", this.path, path)))) + .switchIfEmpty(Mono.error(new SecretNotFoundException( + String.format("No data found at %s; patch only works on existing data", createDataPath(path)), + String.format("%s/%s", this.path, path)))) + .flatMap(readResponse -> { + if (null == readResponse.getData()) { + return Mono.error(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) { + return Mono.error(new VaultException("Metadata must not be null")); + } + + Map body = ReactiveKeyValueHelper.makeMetadata(readResponse.getMetadata(), + readResponse.getRequiredData(), patch); + return doWrite(createDataPath(path), body).thenReturn(true).onErrorResume(VaultException.class, e -> { + if (e.getMessage() != null && (e.getMessage().contains("check-and-set") + || e.getMessage().contains("did not match the current version"))) { + return Mono.just(Boolean.FALSE); + } + return Mono.error(e); + }); + }); + } + + @Override + public Mono put(String path, Object body) { + return doWrite(createDataPath(path), Collections.singletonMap("data", body)).then(); + } + +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValueAccessor.java b/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValueAccessor.java new file mode 100644 index 00000000..3c75a8b0 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValueAccessor.java @@ -0,0 +1,130 @@ +/* + * Copyright 2018-2022 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 static org.springframework.vault.core.ReactiveVaultTemplate.mapResponse; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.vault.client.VaultResponses; +import org.springframework.vault.support.VaultResponse; +import org.springframework.vault.support.VaultResponseSupport; +import org.springframework.web.reactive.function.client.WebClientResponseException.NotFound; +import reactor.core.publisher.Mono; + +/** + * Base class for {@link ReactiveVaultVersionedKeyValueTemplate} and + * {@link ReactiveVaultKeyValue2Template} and other Vault KV-accessing helpers, defining + * common + *

+ * Not intended to be used directly. See {@link ReactiveVaultVersionedKeyValueTemplate} + * and {@link ReactiveVaultKeyValue2Template}. + * + * @author Timothy R. Weiand + * @since 3.1 + */ +abstract class ReactiveVaultKeyValueAccessor implements ReactiveVaultKeyValueOperationsSupport { + + protected final ReactiveVaultOperations reactiveVaultOperations; + + protected final String path; + + /** + * Create a new {@link ReactiveVaultKeyValueAccessor} given + * {@link ReactiveVaultOperations} and the mount {@code path}. + * @param reactiveVaultOperations must not be {@literal null}. + * @param path must not be empty or {@literal null}. + */ + ReactiveVaultKeyValueAccessor(ReactiveVaultOperations reactiveVaultOperations, String path) { + + Assert.notNull(reactiveVaultOperations, "ReactiveVaultOperations must not be null"); + Assert.hasText(path, "Path must not be empty"); + + this.reactiveVaultOperations = reactiveVaultOperations; + this.path = path; + } + + @Override + public Mono delete(String path) { + + Assert.hasText(path, "Path must not be empty"); + String dataPath = createDataPath(path); + + return reactiveVaultOperations + .doWithSession(webClient -> webClient.delete() + .uri(dataPath) + .exchangeToMono(mapResponse(String.class, path, HttpMethod.DELETE))) + .then(); + } + + Mono> doRead(String path, Class deserializeAs) { + ParameterizedTypeReference> ref = VaultResponses.getTypeReference(deserializeAs); + return doReadRaw(createDataPath(path), ref, false); + } + + Mono> doRead(String path, ParameterizedTypeReference deserializeAs) { + ParameterizedTypeReference> ref = VaultResponses.getTypeReference(deserializeAs); + return doReadRaw(createDataPath(path), ref, true); + } + + /** + * Read a secret from the passed path and returns an object of type referenced by + * rawRef. If the path was not found return either Mono.error(NotFound) or + * Mono.empty() based on emitNotFound. + *

+ * Returns Mono.error(WebClientResponseException.NotFound) if the secret is not found. + * @param path URI for request + * @param rawRef Type reference of return object + * @param return type for converting response data + * @param emitNotFound allow returning of Mono.error(NotFound) when true, Mono.empty() + * otherwise + * @return object with type referenced by rawRef + */ + protected Mono doReadRaw(String path, ParameterizedTypeReference rawRef, boolean emitNotFound) { + Assert.hasText(path, "Path must not be empty"); + Assert.notNull(rawRef, "Response type must not be null"); + + return reactiveVaultOperations + .doWithSession( + webClient -> webClient.get().uri(path).exchangeToMono(mapResponse(rawRef, path, HttpMethod.GET))) + .onErrorResume(NotFound.class, t -> { + if (emitNotFound) { + return Mono.error(t); + } + return Mono.empty(); + }); + } + + /** + * Write the {@code body} to the given Vault {@code path}. + * @param path must not be {@literal null} or empty. + * @param body to be written. + * @return the response of this write action. + */ + Mono doWrite(String path, @Nullable Object body) { + Assert.hasText(path, "Path must not be empty"); + return reactiveVaultOperations.write(path, body); + } + + /** + * @param path must not be {@literal null} or empty. + * @return backend path representing the data path. + */ + abstract String createDataPath(String path); + +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValueMetadataOperations.java b/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValueMetadataOperations.java new file mode 100644 index 00000000..5586060b --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValueMetadataOperations.java @@ -0,0 +1,45 @@ +package org.springframework.vault.core; + +import org.springframework.vault.support.VaultMetadataRequest; +import org.springframework.vault.support.VaultMetadataResponse; +import reactor.core.publisher.Mono; + +/** + * Interface that specifies a basic set of Vault operations using Vault's versioned + * Key/Value (kv version 2) secret backend. Paths used in this operations interface are + * relative and outgoing requests prepend paths with the according operation-specific + * prefix. + *

+ * Clients using versioned Key/Value must be aware they are reading from a versioned + * backend as the versioned Key/Value API (kv version 2) is different from the unversioned + * Key/Value API (kv version 1). + * + * @author Timothy R. Weiand + * @since 3.1 + * @see ReactiveVaultKeyValueOperations + * @see VaultKeyValue2Template + */ +public interface ReactiveVaultKeyValueMetadataOperations { + + /** + * Retrieve the metadata and versions for the secret at the specified path. + * @param path the secret path, must not be {@literal null} or empty. + * @return {@link VaultMetadataResponse} + */ + Mono get(String path); + + /** + * Update the secret metadata, or creates new metadata if not present. + * @param path the secret path, must not be {@literal null} or empty. + * @param body {@link VaultMetadataRequest} + */ + Mono put(String path, VaultMetadataRequest body); + + /** + * Permanently delete the key metadata and all version data for the specified key. All + * version history will be removed. + * @param path the secret path, must not be {@literal null} or empty. + */ + Mono delete(String path); + +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValueMetadataTemplate.java b/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValueMetadataTemplate.java new file mode 100644 index 00000000..8c2f450c --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValueMetadataTemplate.java @@ -0,0 +1,74 @@ +/* + * Copyright 2020-2022 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 java.util.Map; +import org.springframework.util.Assert; +import org.springframework.vault.support.VaultMetadataRequest; +import org.springframework.vault.support.VaultMetadataResponse; +import reactor.core.publisher.Mono; + +/** + * Default implementation of {@link ReactiveVaultKeyValueMetadataOperations}. + * + * @author Timothy R. Weiand + * @since 3.1 + */ +class ReactiveVaultKeyValueMetadataTemplate implements ReactiveVaultKeyValueMetadataOperations { + + private final ReactiveVaultOperations vaultOperations; + + private final String basePath; + + ReactiveVaultKeyValueMetadataTemplate(ReactiveVaultOperations vaultOperations, String basePath) { + + Assert.notNull(vaultOperations, "VaultOperations must not be null"); + + this.vaultOperations = vaultOperations; + this.basePath = basePath; + } + + @Override + public Mono get(String path) { + + return vaultOperations.read(getMetadataPath(path), Map.class).flatMap(response -> { + Map data = response.getData(); + if (null == data) { + return Mono.empty(); + } + + return Mono.just(data); + }).map(VaultKeyValueUtilities::fromMap); + } + + @Override + public Mono put(String path, VaultMetadataRequest body) { + Assert.notNull(body, "Body must not be null"); + + return vaultOperations.write(getMetadataPath(path), body).then(); + } + + @Override + public Mono delete(String path) { + return vaultOperations.delete(getMetadataPath(path)); + } + + private String getMetadataPath(String path) { + Assert.hasText(path, "Path must not be empty"); + return basePath + "/metadata/" + path; + } + +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValueOperations.java b/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValueOperations.java new file mode 100644 index 00000000..68fdabb9 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValueOperations.java @@ -0,0 +1,58 @@ +package org.springframework.vault.core; + +import java.util.Map; +import org.springframework.vault.core.VaultKeyValueOperationsSupport.KeyValueBackend; +import org.springframework.vault.support.VaultResponse; +import org.springframework.vault.support.VaultResponseSupport; +import reactor.core.publisher.Mono; + +/** + * 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 + * prepend paths with the according operation-specific prefix. + *

+ * This API supports both, versioned and unversioned key-value backends. Versioned usage + * is limited as updates requiring compare-and-set (CAS) are not possible. Use + * {@link ReactiveVaultVersionedKeyValueOperations} in such cases instead. + * + * TODO: Update JavaDocs + * + * @author Timothy R. Weiand + * @since 3.1 + * @see ReactiveVaultVersionedKeyValueOperations + * @see KeyValueBackend + */ +public interface ReactiveVaultKeyValueOperations extends ReactiveVaultKeyValueOperationsSupport { + + /** + * Read the secret at {@code path}. + * @param path must not be {@literal null}. + * @return the data. May be {@literal null} if the path does not exist. + */ + Mono get(String path); + + /** + * Read the secret at {@code path}. + * @param path must not be {@literal null}. + * @param responseType must not be {@literal null}. + * @return the data. May be {@literal null} if the path does not exist. + */ + Mono> 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. TODO: Throw error if false? + * @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. + */ + Mono patch(String path, Map patch); + + /** + * Write the secret at {@code path}. + * @param path must not be {@literal null}. + * @param body must not be {@literal null}. + */ + Mono put(String path, Object body); + +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValueOperationsSupport.java b/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValueOperationsSupport.java new file mode 100644 index 00000000..c0dc1688 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultKeyValueOperationsSupport.java @@ -0,0 +1,58 @@ +/* + * Copyright 2017-2022 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.core.VaultKeyValueOperationsSupport.KeyValueBackend; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * 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 + * prepend paths with the according operation-specific prefix. + *

+ * + * @author Timothy R. Weiand + * @since 3.1 + */ +public interface ReactiveVaultKeyValueOperationsSupport { + + /** + * Enumerate keys from a Vault path. + * @param path must not be {@literal null}. + * @return the data. May be {@literal null} if the path does not exist. + */ + Flux list(String path); + + /** + * Read the secret at {@code path}. + * @param path must not be {@literal null}. + * @return the data. May be {@literal Mono.empty()} if the path does not exist. + */ + Mono get(String path); + + /** + * Delete the secret at {@code path}. + * @param path must not be {@literal null}. + */ + Mono delete(String path); + + /** + * @return the used API version. + */ + KeyValueBackend getApiVersion(); + +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultOperations.java b/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultOperations.java index ffceb314..5cdf763c 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultOperations.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultOperations.java @@ -39,6 +39,7 @@ import java.util.function.Function; * * @author Mark Paluch * @author James Luke + * @author Timothy R. Weiand * @since 2.0 * @see #doWithSession(Function) * @see #doWithVault(Function) @@ -70,6 +71,29 @@ public interface ReactiveVaultOperations { */ ReactiveVaultSysOperations opsForSys(); + /* + * Return {@link VaultKeyValueOperations}. + * + * @param path the mount path, must not be empty or {@literal null}. + * + * @param apiVersion API version to use, must not be {@literal null}. + * + * @return the operations interface to interact with the Vault Key/Value backend. + * + * @since 3.1 + */ + ReactiveVaultKeyValueOperations opsForKeyValue(String path, + VaultKeyValueOperationsSupport.KeyValueBackend apiVersion); + + /** + * Return {@link ReactiveVaultVersionedKeyValueOperations}. + * @param path the mount path + * @return the operations interface to interact with the versioned Vault Key/Value + * (version 2) backend. + * @since 3.1 + */ + ReactiveVaultVersionedKeyValueOperations opsForVersionedKeyValue(String path); + /** * Read from a Vault path. Reading data using this method is suitable for API * calls/secret backends that do not require a request body. diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultTemplate.java b/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultTemplate.java index 3ad44a11..b34457c5 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultTemplate.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultTemplate.java @@ -30,6 +30,7 @@ import org.springframework.vault.client.VaultEndpointProvider; import org.springframework.vault.client.VaultHttpHeaders; import org.springframework.vault.client.VaultResponses; import org.springframework.vault.client.WebClientBuilder; +import org.springframework.vault.core.VaultKeyValueOperationsSupport.KeyValueBackend; import org.springframework.vault.support.VaultResponse; import org.springframework.vault.support.VaultResponseSupport; import org.springframework.vault.support.VaultToken; @@ -41,6 +42,7 @@ import org.springframework.web.reactive.function.client.ExchangeFilterFunction; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClient.RequestBodySpec; import org.springframework.web.reactive.function.client.WebClientException; +import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -57,6 +59,7 @@ import static org.springframework.web.reactive.function.client.ExchangeFilterFun * @author Mark Paluch * @author Raoof Mohammed * @author James Luke + * @author Timothy R. Weiand * @see SessionManager * @since 2.0 */ @@ -171,6 +174,35 @@ public class ReactiveVaultTemplate implements ReactiveVaultOperations { this.sessionClient = webClientBuilder.build().mutate().filter(getSessionFilter()).build(); } + public static Function> mapResponse(Class bodyType, String path, HttpMethod method) { + return response -> isSuccess(response) ? response.bodyToMono(bodyType) : mapOtherwise(response, path, method); + } + + public static Function> mapResponse(ParameterizedTypeReference typeReference, + String path, HttpMethod method) { + + return response -> isSuccess(response) ? response.body(BodyExtractors.toMono(typeReference)) + : mapOtherwise(response, path, method); + } + + private static boolean isSuccess(ClientResponse response) { + return response.statusCode().is2xxSuccessful(); + } + + private static Mono mapOtherwise(ClientResponse response, String path, HttpMethod method) { + + if (HttpStatusUtil.isNotFound(response.statusCode()) && method == HttpMethod.GET) { + return response.createError(); + } + + return response.bodyToMono(String.class).flatMap(body -> { + + String error = VaultResponses.getError(body); + + return Mono.error(VaultResponses.buildException(response.statusCode(), path, error)); + }); + } + /** * Create a {@link WebClient} to be used by {@link ReactiveVaultTemplate} for Vault * communication given {@link VaultEndpointProvider} and {@link ClientHttpConnector}. @@ -242,12 +274,26 @@ public class ReactiveVaultTemplate implements ReactiveVaultOperations { return new ReactiveVaultTransitTemplate(this, path); } + @Override + public ReactiveVaultKeyValueOperations opsForKeyValue(String path, KeyValueBackend apiVersion) { + return switch (apiVersion) { + case KV_1 -> new ReactiveVaultKeyValue1Template(this, path); + case KV_2 -> new ReactiveVaultKeyValue2Template(this, path); + }; + } + + @Override + public ReactiveVaultVersionedKeyValueOperations opsForVersionedKeyValue(String path) { + return new ReactiveVaultVersionedKeyValueTemplate(this, path); + } + @Override public Mono read(String path) { Assert.hasText(path, "Path must not be empty"); - return doRead(path, VaultResponse.class); + return doRead(path, VaultResponse.class).onErrorResume(WebClientResponseException.NotFound.class, + e -> Mono.empty()); } @Override @@ -257,7 +303,10 @@ public class ReactiveVaultTemplate implements ReactiveVaultOperations { ParameterizedTypeReference> ref = VaultResponses.getTypeReference(responseType); - return webClient.get().uri(path).exchangeToMono(mapResponse(ref, path, HttpMethod.GET)); + return webClient.get() + .uri(path) + .exchangeToMono(mapResponse(ref, path, HttpMethod.GET)) + .onErrorResume(WebClientResponseException.NotFound.class, e -> Mono.empty()); }); } @@ -267,10 +316,9 @@ public class ReactiveVaultTemplate implements ReactiveVaultOperations { Assert.hasText(path, "Path must not be empty"); - Mono read = doRead(String.format("%s?list=true", path.endsWith("/") ? path : (path + "/")), - VaultListResponse.class); - - return read.filter(response -> response.getData() != null && response.getData().containsKey("keys")) + return doRead(String.format("%s?list=true", path.endsWith("/") ? path : (path + "/")), VaultListResponse.class) + .onErrorResume(WebClientResponseException.NotFound.class, e -> Mono.empty()) + .filter(response -> response.getData() != null && response.getData().containsKey("keys")) .flatMapIterable(response -> (List) response.getRequiredData().get("keys")); } @@ -308,7 +356,7 @@ public class ReactiveVaultTemplate implements ReactiveVaultOperations { Assert.notNull(clientCallback, "Client callback must not be null"); try { - return (T) clientCallback.apply(this.statelessClient); + return clientCallback.apply(this.statelessClient); } catch (HttpStatusCodeException e) { throw VaultResponses.buildException(e); @@ -322,7 +370,7 @@ public class ReactiveVaultTemplate implements ReactiveVaultOperations { Assert.notNull(sessionCallback, "Session callback must not be null"); try { - return (T) sessionCallback.apply(this.sessionClient); + return sessionCallback.apply(this.sessionClient); } catch (HttpStatusCodeException e) { throw VaultResponses.buildException(e); @@ -336,40 +384,6 @@ public class ReactiveVaultTemplate implements ReactiveVaultOperations { .exchangeToMono(mapResponse(responseType, path, HttpMethod.GET))); } - private static Function> mapResponse(Class bodyType, String path, - HttpMethod method) { - return response -> isSuccess(response) ? response.bodyToMono(bodyType) : mapOtherwise(response, path, method); - } - - private static Function> mapResponse(ParameterizedTypeReference typeReference, - String path, HttpMethod method) { - - return response -> isSuccess(response) ? response.body(BodyExtractors.toMono(typeReference)) - : mapOtherwise(response, path, method); - } - - private static boolean isSuccess(ClientResponse response) { - return response.statusCode().is2xxSuccessful(); - } - - private static Mono mapOtherwise(ClientResponse response, String path, HttpMethod method) { - - if (HttpStatusUtil.isNotFound(response.statusCode()) && method == HttpMethod.GET) { - return response.releaseBody().then(Mono.empty()); - } - - return response.bodyToMono(String.class).flatMap(body -> { - - String error = VaultResponses.getError(body); - - return Mono.error(VaultResponses.buildException(response.statusCode(), path, error)); - }); - } - - private static class VaultListResponse extends VaultResponseSupport> { - - } - private enum NoTokenSupplier implements VaultTokenSupplier { INSTANCE; @@ -381,4 +395,8 @@ public class ReactiveVaultTemplate implements ReactiveVaultOperations { } + private static class VaultListResponse extends VaultResponseSupport> { + + } + } diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultVersionedKeyValueOperations.java b/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultVersionedKeyValueOperations.java new file mode 100644 index 00000000..89f51210 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultVersionedKeyValueOperations.java @@ -0,0 +1,105 @@ +package org.springframework.vault.core; + +import java.util.Map; +import org.springframework.lang.Nullable; +import org.springframework.vault.support.Versioned; +import org.springframework.vault.support.Versioned.Metadata; +import org.springframework.vault.support.Versioned.Version; +import reactor.core.publisher.Mono; + +/** + * Interface that specifies a basic set of Vault operations using Vault's versioned + * Key/Value (kv version 2) secret backend. Paths used in this operations interface are + * relative and outgoing requests prepend paths with the according operation-specific + * prefix. + *

+ * Clients using versioned Key/Value must be aware they are reading from a versioned + * backend as the versioned Key/Value API (kv version 2) is different from the unversioned + * Key/Value API (kv version 1). TODO: Update JavaDocs + * + * @author Timothy R. Weiand + * @since 3.1 + * @see ReactiveVaultKeyValueOperations + */ +public interface ReactiveVaultVersionedKeyValueOperations extends ReactiveVaultKeyValueOperationsSupport { + + /** + * Read the most recent secret at {@code path}. + * @param path must not be {@literal null}. + * @return the data. May be {@literal null} if the path does not exist. + */ + @SuppressWarnings("unchecked") + default Mono>> get(String path) { + return get(path, Version.unversioned()); + } + + /** + * Read the requested {@link Version} of the secret at {@code path}. + * @param path must not be {@literal null}. + * @param version must not be {@literal null}. + * @return the data. May be {@literal null} if the path does not exist. + */ + Mono> get(String path, Version version); + + /** + * Read the most recent secret at {@code path} and deserialize the secret to the given + * {@link Class responseType}. + * @param path must not be {@literal null}. + * @param responseType must not be {@literal null}. + * @return the data. May be {@literal null} if the path does not exist. + */ + default Mono> get(String path, Class responseType) { + return get(path, Version.unversioned(), responseType); + } + + /** + * Read the requested {@link Version} of the secret at {@code path} and deserialize + * the secret to the given {@link Class responseType}. + * @param path must not be {@literal null}. + * @param version must not be {@literal null}. + * @param responseType must not be {@literal null}. + * @return the data. May be {@literal null} if the path does not exist. + */ + Mono> get(String path, Version version, Class responseType); + + /** + * Write the {@link Versioned versioned secret} at {@code path}. {@code body} may be + * either plain secrets (e.g. map) or {@link Versioned} objects. Using + * {@link Versioned} will apply versioning for Compare-and-Set (CAS). + * @param path must not be {@literal null}. + * @param body must not be {@literal null}. + * @return the resulting {@link Metadata}. + */ + Mono put(String path, Object body); + + /** + * Delete one or more {@link Version versions} of the secret at {@code path}. + * @param path must not be {@literal null}. + * @param versionsToDelete must not be {@literal null} or empty. + */ + Mono delete(String path, Version... versionsToDelete); + + /** + * Undelete (restore) one or more {@link Version versions} of the secret at + * {@code path}. + * @param path must not be {@literal null}. + * @param versionsToDelete must not be {@literal null} or empty. + */ + Mono undelete(String path, Version... versionsToDelete); + + /** + * Permanently remove the specified {@link Version versions} of the secret at + * {@code path}. + * @param path must not be {@literal null}. + * @param versionsToDelete must not be {@literal null} or empty. + */ + Mono destroy(String path, Version... versionsToDelete); + + /** + * Return {@link ReactiveVaultKeyValueMetadataOperations} + * @return the operations interface to interact with the Vault Key/Value metadata + * backend + */ + ReactiveVaultKeyValueMetadataOperations opsForKeyValueMetadata(); + +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultVersionedKeyValueTemplate.java b/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultVersionedKeyValueTemplate.java new file mode 100644 index 00000000..fe2a2b29 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultVersionedKeyValueTemplate.java @@ -0,0 +1,181 @@ +/* + * Copyright 2018-2022 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 java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAccessor; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.vault.client.VaultResponses; +import org.springframework.vault.support.VaultResponseSupport; +import org.springframework.vault.support.Versioned; +import org.springframework.vault.support.Versioned.Metadata; +import org.springframework.vault.support.Versioned.Metadata.MetadataBuilder; +import org.springframework.vault.support.Versioned.Version; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import reactor.core.publisher.Mono; + +/** + * Default implementation of {@link ReactiveVaultVersionedKeyValueOperations}. + * + * @author Timothy R. Weiand + * @since 3.1 + */ +public class ReactiveVaultVersionedKeyValueTemplate extends ReactiveVaultKeyValue2Accessor + implements ReactiveVaultVersionedKeyValueOperations { + + /** + * Create a new {@link ReactiveVaultVersionedKeyValueTemplate} given + * {@link ReactiveVaultOperations} and the mount {@code path}. + * @param reactiveVaultOperations must not be {@literal null}. + * @param path must not be empty or {@literal null}. + */ + public ReactiveVaultVersionedKeyValueTemplate(ReactiveVaultOperations reactiveVaultOperations, String path) { + + super(reactiveVaultOperations, path); + } + + private static List toVersionList(Version[] versionsToDelete) { + return Arrays.stream(versionsToDelete) + .filter(Version::isVersioned) + .map(Version::getVersion) + .collect(Collectors.toList()); + } + + @Override + @SuppressWarnings("unchecked") + public Mono>> get(String path, Version version) { + + Assert.hasText(path, "Path must not be empty"); + Assert.notNull(version, "Version must not be null"); + + return doRead(path, version, Map.class) + .map(m -> Versioned.create((Map) m.getData(), m.getMetadata())); + } + + @Override + public Mono> get(String path, Version version, Class responseType) { + + Assert.hasText(path, "Path must not be empty"); + Assert.notNull(version, "Version must not be null"); + Assert.notNull(responseType, "Response type must not be null"); + + return doRead(path, version, responseType); + } + + private Mono> doRead(String path, Version version, Class responseType) { + String secretPath = version.isVersioned() + ? String.format("%s?version=%d", createDataPath(path), version.getVersion()) : createDataPath(path); + + ParameterizedTypeReference>> ref = VaultResponses + .getTypeReference(VaultResponses.getTypeReference(responseType)); + + return doReadRaw(secretPath, ref, true).onErrorResume(WebClientResponseException.NotFound.class, e -> { + if (e.getResponseBodyAsString().contains("deletion_time")) { + return Mono.justOrEmpty(e.getResponseBodyAs(ref)); + } + return Mono.error(VaultResponses.buildException(e.getStatusCode(), path, "Unexpected error during read")); + }).flatMap(ReactiveKeyValueHelper::getRequiredData).flatMap(responseSupport -> { + Map metadataMap = responseSupport.getMetadata(); + if (null == metadataMap) { + return Mono.just(Versioned.create(responseSupport.getData(), version)); + } + Metadata metadata = VaultKeyValueUtilities.getMetadata(responseSupport.getMetadata()); + return Mono.just(Versioned.create(responseSupport.getData(), metadata)); + }); + } + + @Override + public Mono put(String path, Object body) { + + Assert.hasText(path, "Path must not be empty"); + + LinkedHashMap data = new LinkedHashMap<>(); + LinkedHashMap requestOptions = new LinkedHashMap<>(); + + if (body instanceof Versioned versioned) { + + data.put("data", versioned.getData()); + data.put("options", requestOptions); + + requestOptions.put("cas", versioned.getVersion().getVersion()); + } + else { + data.put("data", body); + } + + return doWrite(createDataPath(path), data).flatMap(ReactiveKeyValueHelper::getRequiredData) + .map(VaultKeyValueUtilities::getMetadata) + .switchIfEmpty(Mono.error(new IllegalStateException( + "VaultVersionedKeyValueOperations cannot be used with a Key-Value version 1 mount"))); + } + + @Override + public Mono delete(String path, Version... versionsToDelete) { + + Assert.hasText(path, "Path must not be empty"); + Assert.noNullElements(versionsToDelete, "Versions must not be null"); + + if (versionsToDelete.length == 0) { + return delete(path); + } + + List versions = toVersionList(versionsToDelete); + + return doWrite(createBackendPath("delete", path), Collections.singletonMap("versions", versions)).then().log(); + } + + @Override + public Mono undelete(String path, Version... versionsToDelete) { + + Assert.hasText(path, "Path must not be empty"); + Assert.noNullElements(versionsToDelete, "Versions must not be null"); + + List versions = toVersionList(versionsToDelete); + + return doWrite(createBackendPath("undelete", path), Collections.singletonMap("versions", versions)).then(); + } + + @Override + public Mono destroy(String path, Version... versionsToDelete) { + + Assert.hasText(path, "Path must not be empty"); + Assert.noNullElements(versionsToDelete, "Versions must not be null"); + + List versions = toVersionList(versionsToDelete); + + return doWrite(createBackendPath("destroy", path), Collections.singletonMap("versions", versions)).then(); + } + + @Override + public ReactiveVaultKeyValueMetadataOperations opsForKeyValueMetadata() { + return new ReactiveVaultKeyValueMetadataTemplate(reactiveVaultOperations, path); + } + + private static class VersionedResponse extends VaultResponseSupport> { + + } + +} 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 efee1d1c..37c22592 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 @@ -67,16 +67,8 @@ class VaultKeyValue1Template extends VaultKeyValueAccessor implements VaultKeyVa Assert.hasText(path, "Path must not be empty"); return doRead(path, Map.class, (response, data) -> { - VaultResponse vaultResponse = new VaultResponse(); - vaultResponse.setRenewable(response.isRenewable()); - vaultResponse.setAuth(response.getAuth()); - vaultResponse.setLeaseDuration(response.getLeaseDuration()); - vaultResponse.setLeaseId(response.getLeaseId()); - vaultResponse.setMetadata(response.getMetadata()); - vaultResponse.setRequestId(response.getRequestId()); - vaultResponse.setWarnings(response.getWarnings()); - vaultResponse.setWrapInfo(response.getWrapInfo()); + VaultResponse.updateWithoutData(vaultResponse, response); vaultResponse.setData(data); return vaultResponse; 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 b7368e9f..77342dec 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 @@ -55,14 +55,7 @@ class VaultKeyValue2Template extends VaultKeyValue2Accessor implements VaultKeyV return doRead(path, Map.class, (response, data) -> { VaultResponse vaultResponse = new VaultResponse(); - vaultResponse.setRenewable(response.isRenewable()); - vaultResponse.setAuth(response.getAuth()); - vaultResponse.setLeaseDuration(response.getLeaseDuration()); - vaultResponse.setLeaseId(response.getLeaseId()); - vaultResponse.setMetadata(response.getMetadata()); - vaultResponse.setRequestId(response.getRequestId()); - vaultResponse.setWarnings(response.getWarnings()); - vaultResponse.setWrapInfo(response.getWrapInfo()); + VaultResponse.updateWithoutData(vaultResponse, response); vaultResponse.setData(data); return vaultResponse; @@ -103,13 +96,8 @@ class VaultKeyValue2Template extends VaultKeyValue2Accessor implements VaultKeyV 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"))); + Map body = ReactiveKeyValueHelper.makeMetadata(readResponse.getMetadata(), + readResponse.getRequiredData(), patch); try { doWrite(createDataPath(path), body); diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValueMetadataTemplate.java b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValueMetadataTemplate.java index f5caee03..98c18709 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValueMetadataTemplate.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValueMetadataTemplate.java @@ -15,21 +15,12 @@ */ package org.springframework.vault.core; -import java.time.Duration; -import java.time.Instant; -import java.time.format.DateTimeFormatter; -import java.util.List; import java.util.Map; -import java.util.stream.Collectors; -import org.springframework.lang.Nullable; import org.springframework.util.Assert; -import org.springframework.util.StringUtils; -import org.springframework.vault.support.DurationParser; import org.springframework.vault.support.VaultMetadataRequest; import org.springframework.vault.support.VaultMetadataResponse; import org.springframework.vault.support.VaultResponseSupport; -import org.springframework.vault.support.Versioned; /** * Default implementation of {@link VaultKeyValueMetadataOperations}. @@ -59,7 +50,7 @@ class VaultKeyValueMetadataTemplate implements VaultKeyValueMetadataOperations { VaultResponseSupport response = this.vaultOperations.read(getPath(path), Map.class); - return response != null ? fromMap(response.getRequiredData()) : null; + return response != null ? VaultKeyValueUtilities.fromMap(response.getRequiredData()) : null; } @Override @@ -85,51 +76,4 @@ class VaultKeyValueMetadataTemplate implements VaultKeyValueMetadataOperations { return this.basePath + "/metadata/" + path; } - @SuppressWarnings({ "ConstantConditions", "unchecked", "rawtypes" }) - private static VaultMetadataResponse fromMap(Map metadataResponse) { - - Duration duration = DurationParser.parseDuration((String) metadataResponse.get("delete_version_after")); - - return VaultMetadataResponse.builder() - .casRequired(Boolean.parseBoolean(String.valueOf(metadataResponse.get("cas_required")))) - .createdTime(toInstant((String) metadataResponse.get("created_time"))) - .currentVersion(Integer.parseInt(String.valueOf(metadataResponse.get("current_version")))) - .deleteVersionAfter(duration) - .maxVersions(Integer.parseInt(String.valueOf(metadataResponse.get("max_versions")))) - .oldestVersion(Integer.parseInt(String.valueOf(metadataResponse.get("oldest_version")))) - .updatedTime(toInstant((String) metadataResponse.get("updated_time"))) - .versions(buildVersions((Map) metadataResponse.get("versions"))) - .customMetadata((Map) metadataResponse.get("custom_metadata")) - .build(); - } - - private static List buildVersions(Map> versions) { - - return versions.entrySet() - .stream() - .map(entry -> buildVersion(entry.getKey(), entry.getValue())) - .collect(Collectors.toList()); - } - - private static Versioned.Metadata buildVersion(String version, Map versionData) { - - Instant createdTime = toInstant((String) versionData.get("created_time")); - Instant deletionTime = toInstant((String) versionData.get("deletion_time")); - boolean destroyed = (Boolean) versionData.get("destroyed"); - Versioned.Version kvVersion = Versioned.Version.from(Integer.parseInt(version)); - Versioned.Metadata.MetadataBuilder builder = Versioned.Metadata.builder() - .createdAt(createdTime) - .deletedAt(deletionTime) - .destroyed(destroyed) - .version(kvVersion) - .customMetadata((Map) versionData.get("custom_metadata")); - - return builder.build(); - } - - @Nullable - private static Instant toInstant(String date) { - return StringUtils.hasText(date) ? Instant.from(DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(date)) : null; - } - } diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValueUtilities.java b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValueUtilities.java new file mode 100644 index 00000000..3c0ca6ab --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValueUtilities.java @@ -0,0 +1,98 @@ +package org.springframework.vault.core; + +import java.time.Duration; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAccessor; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.springframework.lang.Nullable; +import org.springframework.util.StringUtils; +import org.springframework.vault.support.DurationParser; +import org.springframework.vault.support.VaultMetadataResponse; +import org.springframework.vault.support.Versioned; +import org.springframework.vault.support.Versioned.Metadata; +import org.springframework.vault.support.Versioned.Metadata.MetadataBuilder; +import org.springframework.vault.support.Versioned.Version; + +class VaultKeyValueUtilities { + + static Metadata getMetadata(Map responseMetadata) { + + MetadataBuilder builder = Metadata.builder(); + TemporalAccessor created_time = getDate(responseMetadata, "created_time"); + TemporalAccessor deletion_time = getDate(responseMetadata, "deletion_time"); + + builder.createdAt(Instant.from(created_time)); + + if (deletion_time != null) { + builder.deletedAt(Instant.from(deletion_time)); + } + + if (Boolean.TRUE.equals(responseMetadata.get("destroyed"))) { + builder.destroyed(); + } + + Integer version = (Integer) responseMetadata.get("version"); + builder.version(Version.from(version)); + + return builder.build(); + } + + @Nullable + private static TemporalAccessor getDate(Map responseMetadata, String key) { + + String date = (String) responseMetadata.getOrDefault(key, ""); + if (StringUtils.hasText(date)) { + return DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(date); + } + return null; + } + + @SuppressWarnings({ "ConstantConditions", "unchecked", "rawtypes" }) + static VaultMetadataResponse fromMap(Map metadataResponse) { + + Duration duration = DurationParser.parseDuration((String) metadataResponse.get("delete_version_after")); + + return VaultMetadataResponse.builder() + .casRequired(Boolean.parseBoolean(String.valueOf(metadataResponse.get("cas_required")))) + .createdTime(toInstant((String) metadataResponse.get("created_time"))) + .currentVersion(Integer.parseInt(String.valueOf(metadataResponse.get("current_version")))) + .deleteVersionAfter(duration) + .maxVersions(Integer.parseInt(String.valueOf(metadataResponse.get("max_versions")))) + .oldestVersion(Integer.parseInt(String.valueOf(metadataResponse.get("oldest_version")))) + .updatedTime(toInstant((String) metadataResponse.get("updated_time"))) + .versions(buildVersions((Map) metadataResponse.get("versions"))) + .build(); + } + + @Nullable + static Instant toInstant(String date) { + return StringUtils.hasText(date) ? Instant.from(DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(date)) : null; + } + + private static List buildVersions(Map> versions) { + + return versions.entrySet() + .stream() + .map(entry -> buildVersion(entry.getKey(), entry.getValue())) + .collect(Collectors.toList()); + } + + private static Versioned.Metadata buildVersion(String version, Map versionData) { + + Instant createdTime = toInstant((String) versionData.get("created_time")); + Instant deletionTime = toInstant((String) versionData.get("deletion_time")); + boolean destroyed = (Boolean) versionData.get("destroyed"); + Versioned.Version kvVersion = Versioned.Version.from(Integer.parseInt(version)); + + return Versioned.Metadata.builder() + .createdAt(createdTime) + .deletedAt(deletionTime) + .destroyed(destroyed) + .version(kvVersion) + .build(); + } + +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultVersionedKeyValueTemplate.java b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultVersionedKeyValueTemplate.java index e5980ac4..5e52ba19 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultVersionedKeyValueTemplate.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultVersionedKeyValueTemplate.java @@ -122,7 +122,7 @@ public class VaultVersionedKeyValueTemplate extends VaultKeyValue2Accessor imple } VaultResponseSupport data = response.getRequiredData(); - Metadata metadata = getMetadata(data.getMetadata()); + Metadata metadata = VaultKeyValueUtilities.getMetadata(data.getMetadata()); T body = deserialize(data.getRequiredData(), responseType); @@ -157,41 +157,7 @@ public class VaultVersionedKeyValueTemplate extends VaultKeyValue2Accessor imple "VaultVersionedKeyValueOperations cannot be used with a Key-Value version 1 mount"); } - return getMetadata(response.getRequiredData()); - } - - @SuppressWarnings("unchecked") - private static Metadata getMetadata(Map responseMetadata) { - - MetadataBuilder builder = Metadata.builder(); - TemporalAccessor created_time = getDate(responseMetadata, "created_time"); - TemporalAccessor deletion_time = getDate(responseMetadata, "deletion_time"); - - builder.createdAt(Instant.from(created_time)); - - if (deletion_time != null) { - builder.deletedAt(Instant.from(deletion_time)); - } - - if (Boolean.TRUE.equals(responseMetadata.get("destroyed"))) { - builder.destroyed(); - } - - Integer version = (Integer) responseMetadata.get("version"); - builder.version(Version.from(version)) - .customMetadata((Map) responseMetadata.get("custom_metadata")); - - return builder.build(); - } - - @Nullable - private static TemporalAccessor getDate(Map responseMetadata, String key) { - - String date = (String) responseMetadata.getOrDefault(key, ""); - if (StringUtils.hasText(date)) { - return DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(date); - } - return null; + return VaultKeyValueUtilities.getMetadata(response.getRequiredData()); } @Override diff --git a/spring-vault-core/src/main/java/org/springframework/vault/support/VaultResponseDataVersion2.java b/spring-vault-core/src/main/java/org/springframework/vault/support/VaultResponseDataVersion2.java new file mode 100644 index 00000000..e6af7088 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/support/VaultResponseDataVersion2.java @@ -0,0 +1,34 @@ +package org.springframework.vault.support; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.Map; +import org.springframework.lang.Nullable; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class VaultResponseDataVersion2 { + + @Nullable + private T data; + + @Nullable + private Map metadata; + + @Nullable + public T getData() { + return data; + } + + public void setData(T data) { + this.data = data; + } + + @Nullable + public Map getMetadata() { + return metadata; + } + + public void setMetadata(Map metadata) { + this.metadata = metadata; + } + +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/support/VaultResponseSupport.java b/spring-vault-core/src/main/java/org/springframework/vault/support/VaultResponseSupport.java index 22023d0e..d896974d 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/support/VaultResponseSupport.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/support/VaultResponseSupport.java @@ -63,6 +63,17 @@ public class VaultResponseSupport { @Nullable private List warnings; + public static void updateWithoutData(final VaultResponseSupport dst, final VaultResponseSupport src) { + dst.auth = src.auth; + dst.metadata = src.metadata; + dst.wrapInfo = src.wrapInfo; + dst.leaseDuration = src.leaseDuration; + dst.leaseId = src.leaseId; + dst.requestId = src.requestId; + dst.renewable = src.renewable; + dst.warnings = src.warnings; + } + /** * @return authentication payload. */ diff --git a/spring-vault-core/src/main/java/org/springframework/vault/support/VaultResponseVersion2.java b/spring-vault-core/src/main/java/org/springframework/vault/support/VaultResponseVersion2.java new file mode 100644 index 00000000..1bbc2947 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/support/VaultResponseVersion2.java @@ -0,0 +1,21 @@ +/* + * Copyright 2016-2022 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.support; + +public class VaultResponseVersion2 extends VaultResponseSupport> { + +} diff --git a/spring-vault-core/src/test/java/org/springframework/vault/core/AbstractReactiveVaultKeyValueTemplateIntegrationTests.java b/spring-vault-core/src/test/java/org/springframework/vault/core/AbstractReactiveVaultKeyValueTemplateIntegrationTests.java new file mode 100644 index 00000000..7a7a10bb --- /dev/null +++ b/spring-vault-core/src/test/java/org/springframework/vault/core/AbstractReactiveVaultKeyValueTemplateIntegrationTests.java @@ -0,0 +1,147 @@ +/* + * Copyright 2018-2022 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 static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.vault.core.VaultKeyValueOperationsSupport.KeyValueBackend; +import org.springframework.vault.domain.Person; +import org.springframework.vault.util.IntegrationTestSupport; +import org.springframework.vault.util.RequiresVaultVersion; +import org.springframework.vault.util.VaultInitializer; +import reactor.test.StepVerifier; + +/** + * Integration tests for {@link ReactiveVaultKeyValue2Template}. + * + * @author Timothy R. Weiand + */ +@RequiresVaultVersion(VaultInitializer.VERSIONING_INTRODUCED_WITH_VALUE) +abstract class AbstractReactiveVaultKeyValueTemplateIntegrationTests extends IntegrationTestSupport { + + private final String path; + + private final KeyValueBackend apiVersion; + + @Autowired + ReactiveVaultOperations vaultOperations; + + ReactiveVaultKeyValueOperations kvOperations; + + AbstractReactiveVaultKeyValueTemplateIntegrationTests(String path, KeyValueBackend apiVersion) { + this.path = path; + this.apiVersion = apiVersion; + } + + @BeforeEach + void before() { + kvOperations = vaultOperations.opsForKeyValue(path, apiVersion); + } + + @Test + void shouldReportExpectedApiVersion() { + assertThat(kvOperations.getApiVersion()).isEqualTo(apiVersion); + } + + @Test + void shouldCreateSecret() { + + Map secret = Collections.singletonMap("key", "value"); + + String key = UUID.randomUUID().toString(); + + kvOperations.put(key, secret).as(StepVerifier::create).verifyComplete(); + + kvOperations.list("/") + .as(StepVerifier::create) + .recordWith(ArrayList::new) + .thenConsumeWhile(x -> true) + .expectRecordedMatches(elements -> elements.contains(key)) + .verifyComplete(); + } + + @Test + void shouldReadSecret() { + + Map secret = Collections.singletonMap("key", "value"); + + String key = UUID.randomUUID().toString(); + + kvOperations.put(key, secret).as(StepVerifier::create).verifyComplete(); + + kvOperations.get(key) + .flatMap(ReactiveKeyValueHelper::getRequiredData) + .as(StepVerifier::create) + .assertNext(n -> assertThat(n).containsEntry("key", "value")) + .verifyComplete(); + } + + @Test + void shouldReadAbsentSecret() { + + kvOperations.get("absent").as(StepVerifier::create).verifyComplete(); + + kvOperations.get("absent", Person.class).as(StepVerifier::create).verifyComplete(); + } + + @Test + void shouldReadComplexSecret() { + var person = new Person(); + person.setFirstname("Walter"); + person.setLastname("Heisenberg"); + person.setPassword("some-password"); + + kvOperations.put("my-secret", person).as(StepVerifier::create).verifyComplete(); + + kvOperations.get("my-secret") + .flatMap(ReactiveKeyValueHelper::getRequiredData) + .as(StepVerifier::create) + .assertNext(m -> { + assertThat(m).containsAllEntriesOf( + Map.of("firstname", "Walter", "lastname", "Heisenberg", "password", "some-password")); + assertThat(m).containsEntry("id", null); + }) + .verifyComplete(); + + kvOperations.get("my-secret", Person.class) + .flatMap(ReactiveKeyValueHelper::getRequiredData) + .as(StepVerifier::create) + .assertNext(p -> assertThat(p).isEqualTo(person)) + .verifyComplete(); + } + + @Test + void shouldDeleteSecret() { + + Map secret = Collections.singletonMap("key", "value"); + + String key = UUID.randomUUID().toString(); + + kvOperations.put(key, secret).as(StepVerifier::create).verifyComplete(); + + kvOperations.delete(key).as(StepVerifier::create).verifyComplete(); + + kvOperations.get(key).as(StepVerifier::create).verifyComplete(); + } + +} diff --git a/spring-vault-core/src/test/java/org/springframework/vault/core/ReactiveVaultKeyValueMetadataTemplateIntegrationTests.java b/spring-vault-core/src/test/java/org/springframework/vault/core/ReactiveVaultKeyValueMetadataTemplateIntegrationTests.java new file mode 100644 index 00000000..5d45d1f7 --- /dev/null +++ b/spring-vault-core/src/test/java/org/springframework/vault/core/ReactiveVaultKeyValueMetadataTemplateIntegrationTests.java @@ -0,0 +1,158 @@ +/* + * Copyright 2020-2022 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 static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +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.support.VaultMetadataRequest; +import org.springframework.vault.util.Version; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Integration tests for {@link VaultKeyValueMetadataOperations}. + * + * @author Timothy R. Weiand + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = VaultIntegrationTestConfiguration.class) +class ReactiveVaultKeyValueMetadataTemplateIntegrationTests + extends AbstractReactiveVaultKeyValueTemplateIntegrationTests { + + private static final String SECRET_NAME = "regular-test"; + + private static final String CAS_SECRET_NAME = "cas-test"; + + private ReactiveVaultKeyValueMetadataOperations vaultKeyValueMetadataOperations; + + ReactiveVaultKeyValueMetadataTemplateIntegrationTests() { + super("versioned", VaultKeyValueOperationsSupport.KeyValueBackend.versioned()); + } + + @BeforeEach + void setup() { + + vaultKeyValueMetadataOperations = vaultOperations.opsForVersionedKeyValue("versioned").opsForKeyValueMetadata(); + + for (var key : List.of(SECRET_NAME, CAS_SECRET_NAME)) { + vaultKeyValueMetadataOperations.delete(key) + .onErrorResume(e -> Mono.empty()) + .as(StepVerifier::create) + .verifyComplete(); + } + + var secret = new HashMap<>(); + secret.put("key", "value"); + + kvOperations.put(SECRET_NAME, secret).as(StepVerifier::create).verifyComplete(); + } + + @Test + void shouldReadMetadataForANewKVEntry() { + + vaultKeyValueMetadataOperations.get(SECRET_NAME).as(StepVerifier::create).assertNext(metadataResponse -> { + assertThat(metadataResponse.getMaxVersions()).isEqualTo(0); + assertThat(metadataResponse.getCurrentVersion()).isEqualTo(1); + assertThat(metadataResponse.getVersions()).hasSize(1); + assertThat(metadataResponse.isCasRequired()).isFalse(); + assertThat(metadataResponse.getCreatedTime().isBefore(Instant.now())).isTrue(); + assertThat(metadataResponse.getUpdatedTime().isBefore(Instant.now())).isTrue(); + + var version1 = metadataResponse.getVersions().get(0); + + if (prepare().getVersion().isGreaterThanOrEqualTo(Version.parse("1.2.0"))) { + + assertThat(metadataResponse.getDeleteVersionAfter()).isEqualTo(Duration.ZERO); + + assertThat(version1.getDeletedAt()).isNull(); + assertThat(version1.getCreatedAt()).isBefore(Instant.now()); + } + + assertThat(version1.getVersion().getVersion()).isEqualTo(1); + + }).verifyComplete(); + } + + @Test + void shouldUpdateMetadataVersions() { + + Map secret = Map.of("newkey", "newvalue"); + kvOperations.put(SECRET_NAME, secret).as(StepVerifier::create).verifyComplete(); + + vaultKeyValueMetadataOperations.get(SECRET_NAME).as(StepVerifier::create).assertNext(metadataResponse -> { + assertThat(metadataResponse.getCurrentVersion()).isEqualTo(2); + assertThat(metadataResponse.getVersions()).hasSize(2); + }).verifyComplete(); + + } + + @Test + void shouldUpdateKVMetadata() { + + var secret = Map.of("key", "value"); + + kvOperations.put(CAS_SECRET_NAME, secret).as(StepVerifier::create).verifyComplete(); + + Duration duration = Duration.ofMinutes(30).plusHours(6).plusSeconds(30); + VaultMetadataRequest request = VaultMetadataRequest.builder() + .casRequired(true) + .deleteVersionAfter(duration) + .maxVersions(20) + .build(); + + vaultKeyValueMetadataOperations.put(CAS_SECRET_NAME, request).as(StepVerifier::create).verifyComplete(); + + final var version = prepare().getVersion(); + + vaultKeyValueMetadataOperations.get(CAS_SECRET_NAME) + .as(StepVerifier::create) + .assertNext(metadataResponseAfterUpdate -> { + assertThat(metadataResponseAfterUpdate.isCasRequired()).isEqualTo(request.isCasRequired()); + assertThat(metadataResponseAfterUpdate.getMaxVersions()).isEqualTo(request.getMaxVersions()); + + if (version.isGreaterThanOrEqualTo(Version.parse("1.2.0"))) { + assertThat(metadataResponseAfterUpdate.getDeleteVersionAfter()).isEqualTo(duration); + } + }) + .verifyComplete(); + } + + @Test + void shouldDeleteMetadata() { + + kvOperations.delete(SECRET_NAME).as(StepVerifier::create).verifyComplete(); + + vaultKeyValueMetadataOperations.get(SECRET_NAME).as(StepVerifier::create).assertNext(metadataResponse -> { + var version1 = metadataResponse.getVersions().get(0); + assertThat(version1.getDeletedAt()).isBefore(Instant.now()); + }).verifyComplete(); + + vaultKeyValueMetadataOperations.delete(SECRET_NAME).as(StepVerifier::create).verifyComplete(); + + kvOperations.get(SECRET_NAME).map(r -> r).as(StepVerifier::create).verifyComplete(); + } + +} diff --git a/spring-vault-core/src/test/java/org/springframework/vault/core/ReactiveVaultKeyValueTemplateIntegrationTests.java b/spring-vault-core/src/test/java/org/springframework/vault/core/ReactiveVaultKeyValueTemplateIntegrationTests.java new file mode 100644 index 00000000..8eba792b --- /dev/null +++ b/spring-vault-core/src/test/java/org/springframework/vault/core/ReactiveVaultKeyValueTemplateIntegrationTests.java @@ -0,0 +1,52 @@ +/* + * Copyright 2018-2022 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 static org.assertj.core.api.Assertions.assertThat; + +import java.util.HashMap; +import java.util.Map; +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 reactor.test.StepVerifier; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = VaultIntegrationTestConfiguration.class) +class ReactiveVaultKeyValueTemplateIntegrationTests extends AbstractReactiveVaultKeyValueTemplateIntegrationTests { + + ReactiveVaultKeyValueTemplateIntegrationTests() { + super("secret", KeyValueBackend.unversioned()); + } + + @Test + void shouldReadSecretWithTtl() { + + Map secret = new HashMap<>(); + secret.put("key", "value"); + secret.put("ttl", "5"); + + kvOperations.put("my-secret", secret).as(StepVerifier::create).verifyComplete(); + + kvOperations.get("my-secret").as(StepVerifier::create).consumeNextWith(response -> { + assertThat(response.getRequiredData()).containsEntry("key", "value"); + assertThat(response.getLeaseDuration()).isEqualTo(5L); + }).verifyComplete(); + } + +} diff --git a/spring-vault-core/src/test/java/org/springframework/vault/core/ReactiveVaultKeyValueTemplateVersionedIntegrationTests.java b/spring-vault-core/src/test/java/org/springframework/vault/core/ReactiveVaultKeyValueTemplateVersionedIntegrationTests.java new file mode 100644 index 00000000..3a54e819 --- /dev/null +++ b/spring-vault-core/src/test/java/org/springframework/vault/core/ReactiveVaultKeyValueTemplateVersionedIntegrationTests.java @@ -0,0 +1,92 @@ +/* + * Copyright 2018-2022 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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Fail.fail; + +import java.util.Collections; +import java.util.UUID; +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 reactor.test.StepVerifier; + +/** + * Integration tests for {@link ReactiveVaultKeyValue2Template} using the versioned + * Key/Value (k/v version 2) backend. + * + * @author Timothy Weiand + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = VaultIntegrationTestConfiguration.class) +class ReactiveVaultKeyValueTemplateVersionedIntegrationTests + extends AbstractReactiveVaultKeyValueTemplateIntegrationTests { + + ReactiveVaultKeyValueTemplateVersionedIntegrationTests() { + super("versioned", KeyValueBackend.versioned()); + } + + @Test + void shouldPatchSecret() { + + var oldKey = "key"; + var newKey = "newKey"; + + var secret = Collections.singletonMap(oldKey, "value"); + + var key = UUID.randomUUID().toString(); + + kvOperations.put(key, secret).as(StepVerifier::create).verifyComplete(); + + var newSecret = Collections.singletonMap(newKey, "newValue"); + + kvOperations.patch(key, newSecret) + .as(StepVerifier::create) + .assertNext(b -> assertThat(b).isTrue()) + .verifyComplete(); + kvOperations.list("/") + .collectList() + .as(StepVerifier::create) + .assertNext(list -> assertThat(list).contains(key)) + .verifyComplete(); + + kvOperations.get(key).as(StepVerifier::create).assertNext(vaultResponse -> { + var data = vaultResponse.getRequiredData(); + assertThat(data).containsKey(oldKey).containsKey(newKey); + }).verifyComplete(); + } + + @Test + void patchShouldFailWithSecretNotFoundException() { + + kvOperations.patch("unknown", Collections.singletonMap("foo", "newValue")) + .as(StepVerifier::create) + .expectErrorSatisfies(t -> { + if (t instanceof SecretNotFoundException e) { + assertThat(e).hasMessageContaining("versioned/data/unknown"); + assertThat(e.getPath()).isEqualTo("versioned/unknown"); + } + else { + fail("missing SecretNotFoundException"); + } + }) + .verify(); + } + +} diff --git a/spring-vault-core/src/test/java/org/springframework/vault/core/ReactiveVaultVersionedKeyValueTemplateIntegrationTests.java b/spring-vault-core/src/test/java/org/springframework/vault/core/ReactiveVaultVersionedKeyValueTemplateIntegrationTests.java new file mode 100644 index 00000000..356acfcc --- /dev/null +++ b/spring-vault-core/src/test/java/org/springframework/vault/core/ReactiveVaultVersionedKeyValueTemplateIntegrationTests.java @@ -0,0 +1,252 @@ +/* + * Copyright 2018-2022 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 static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.Collections; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.vault.VaultException; +import org.springframework.vault.domain.Person; +import org.springframework.vault.support.Versioned; +import org.springframework.vault.support.Versioned.Version; +import org.springframework.vault.util.IntegrationTestSupport; +import org.springframework.vault.util.RequiresVaultVersion; +import org.springframework.vault.util.VaultInitializer; +import reactor.test.StepVerifier; + +/** + * Integration tests for {@link ReactiveVaultVersionedKeyValueTemplate}. + * + * @author Timothy Weiand + */ +@ExtendWith(SpringExtension.class) +@RequiresVaultVersion(VaultInitializer.VERSIONING_INTRODUCED_WITH_VALUE) +@ContextConfiguration(classes = VaultIntegrationTestConfiguration.class) +class ReactiveVaultVersionedKeyValueTemplateIntegrationTests extends IntegrationTestSupport { + + ReactiveVaultVersionedKeyValueOperations reactiveVersionedOperations; + + @Autowired + ReactiveVaultVersionedKeyValueTemplateIntegrationTests(ReactiveVaultOperations reactiveVaultOperations) { + reactiveVersionedOperations = reactiveVaultOperations.opsForVersionedKeyValue("versioned"); + + } + + @Test + void shouldCreateVersionedSecret() { + var secret = Collections.singletonMap("key", "value"); + var key = UUID.randomUUID().toString(); + + reactiveVersionedOperations.put(key, Versioned.create(secret)).as(StepVerifier::create).assertNext(metadata -> { + assertThat(metadata.isDestroyed()).isFalse(); + assertThat(metadata.getCreatedAt()).isBetween(Instant.now().minusSeconds(60), + Instant.now().plusSeconds(60)); + assertThat(metadata.getDeletedAt()).isNull(); + }).verifyComplete(); + + } + + @Test + void shouldCreateComplexVersionedSecret() { + var person = new Person(); + person.setFirstname("Walter"); + person.setLastname("White"); + var key = UUID.randomUUID().toString(); + + reactiveVersionedOperations.put(key, Versioned.create(person)) + .as(StepVerifier::create) + .assertNext(m -> assertThat(m.getVersion().getVersion()).isEqualTo(1)) + .verifyComplete(); + + reactiveVersionedOperations.get(key, Person.class) + .as(StepVerifier::create) + .assertNext(versioned -> assertThat(versioned.getRequiredData()).isEqualTo(person)); + } + + @Test + void shouldCreateVersionedWithCAS() { + var secret = Collections.singletonMap("key", "value"); + var key = UUID.randomUUID().toString(); + + reactiveVersionedOperations.put(key, Versioned.create(secret, Version.unversioned())) + .as(StepVerifier::create) + .assertNext(m -> assertThat(m.getVersion().getVersion()).isEqualTo(1)) + .verifyComplete(); + + // this should fail + reactiveVersionedOperations.put(key, Versioned.create(secret, Version.unversioned())) + .as(StepVerifier::create) + .verifyErrorSatisfies(throwable -> assertThat(throwable).isExactlyInstanceOf(VaultException.class) + .hasMessageContaining("check-and-set parameter did not match the current version")); + } + + @Test + void shouldReadAndWriteVersionedSecret() { + var secret = Collections.singletonMap("key", "value"); + var key = UUID.randomUUID().toString(); + + reactiveVersionedOperations.put(key, Versioned.create(secret)) + .as(StepVerifier::create) + .assertNext(m -> assertThat(m.getVersion().getVersion()).isEqualTo(1)) + .verifyComplete(); + + reactiveVersionedOperations.get(key).as(StepVerifier::create).assertNext(loaded -> { + assertThat(loaded.getRequiredData()).isEqualTo(secret); + assertThat(loaded.getRequiredMetadata()).isNotNull(); + assertThat(loaded.getVersion()).isEqualTo(Version.from(1)); + }).verifyComplete(); + } + + @Test + void shouldListExistingSecrets() { + var secret = Collections.singletonMap("key", "value"); + var key = UUID.randomUUID().toString(); + + reactiveVersionedOperations.put(key, secret) + .as(StepVerifier::create) + .assertNext(m -> assertThat(m.getVersion().getVersion()).isEqualTo(1)) + .verifyComplete(); + + reactiveVersionedOperations.list("") + .collectList() + .as(StepVerifier::create) + .assertNext(list -> assertThat(list).contains(key)); + } + + @Test + void shouldReadDifferentVersions() { + var key = UUID.randomUUID().toString(); + + reactiveVersionedOperations.put(key, Collections.singletonMap("key", "v1")) + .as(StepVerifier::create) + .assertNext(m -> assertThat(m.getVersion().getVersion()).isEqualTo(1)) + .verifyComplete(); + reactiveVersionedOperations.put(key, Collections.singletonMap("key", "v2")) + .as(StepVerifier::create) + .assertNext(m -> assertThat(m.getVersion().getVersion()).isEqualTo(2)) + .verifyComplete(); + + reactiveVersionedOperations.get(key, Version.from(1)) + .as(StepVerifier::create) + .assertNext(versioned -> assertThat(versioned.getData()).isEqualTo(Collections.singletonMap("key", "v1"))); + reactiveVersionedOperations.get(key, Version.from(2)) + .as(StepVerifier::create) + .assertNext(versioned -> assertThat(versioned.getData()).isEqualTo(Collections.singletonMap("key", "v2"))); + } + + @Test + void shouldDeleteMostRecentVersion() { + var key = UUID.randomUUID().toString(); + + reactiveVersionedOperations.put(key, Collections.singletonMap("key", "v1")) + .as(StepVerifier::create) + .assertNext(m -> assertThat(m.getVersion().getVersion()).isEqualTo(1)) + .verifyComplete(); + reactiveVersionedOperations.put(key, Collections.singletonMap("key", "v2")) + .as(StepVerifier::create) + .assertNext(m -> assertThat(m.getVersion().getVersion()).isEqualTo(2)) + .verifyComplete(); + + reactiveVersionedOperations.delete(key).as(StepVerifier::create).verifyComplete(); + + reactiveVersionedOperations.get(key).as(StepVerifier::create).assertNext(versioned -> { + assertThat(versioned.getData()).isNull(); + assertThat(versioned.getVersion()).isEqualTo(Version.from(2)); + assertThat(versioned.getRequiredMetadata().isDestroyed()).isFalse(); + assertThat(versioned.getRequiredMetadata().getDeletedAt()).isBetween(Instant.now().minusSeconds(60), + Instant.now().plusSeconds(60)); + }); + } + + @Test + void shouldUndeleteVersion() { + var key = UUID.randomUUID().toString(); + + reactiveVersionedOperations.put(key, Collections.singletonMap("key", "v1")) + .as(StepVerifier::create) + .assertNext(m -> assertThat(m.getVersion().getVersion()).isEqualTo(1)) + .verifyComplete(); + reactiveVersionedOperations.put(key, Collections.singletonMap("key", "v2")) + .as(StepVerifier::create) + .assertNext(m -> assertThat(m.getVersion().getVersion()).isEqualTo(2)) + .verifyComplete(); + + reactiveVersionedOperations.delete(key, Version.from(2)).as(StepVerifier::create).verifyComplete(); + reactiveVersionedOperations.undelete(key, Version.from(2)).as(StepVerifier::create).verifyComplete(); + + reactiveVersionedOperations.get(key).as(StepVerifier::create).assertNext(versioned -> { + assertThat(versioned.getRequiredData()).isEqualTo(Collections.singletonMap("key", "v2")); + assertThat(versioned.getVersion()).isEqualTo(Version.from(2)); + assertThat(versioned.getRequiredMetadata().isDestroyed()).isFalse(); + assertThat(versioned.getRequiredMetadata().getDeletedAt()).isNull(); + }); + } + + @Test + void shouldDeleteIntermediateRecentVersion() { + var key = UUID.randomUUID().toString(); + + reactiveVersionedOperations.put(key, Collections.singletonMap("key", "v1")) + .as(StepVerifier::create) + .assertNext(m -> assertThat(m.getVersion().getVersion()).isEqualTo(1)) + .verifyComplete(); + reactiveVersionedOperations.put(key, Collections.singletonMap("key", "v2")) + .as(StepVerifier::create) + .assertNext(m -> assertThat(m.getVersion().getVersion()).isEqualTo(2)) + .verifyComplete(); + + reactiveVersionedOperations.delete(key, Version.from(1)).as(StepVerifier::create).verifyComplete(); + + reactiveVersionedOperations.get(key, Version.from(1)).as(StepVerifier::create).assertNext(versioned -> { + assertThat(versioned.getData()).isNull(); + assertThat(versioned.getVersion()).isEqualTo(Version.from(1)); + assertThat(versioned.getRequiredMetadata().isDestroyed()).isFalse(); + assertThat(versioned.getRequiredMetadata().getDeletedAt()).isBetween(Instant.now().minusSeconds(60), + Instant.now().plusSeconds(60)); + }).verifyComplete(); + } + + @Test + void shouldDestroyVersion() { + var key = UUID.randomUUID().toString(); + + reactiveVersionedOperations.put(key, Collections.singletonMap("key", "v1")) + .as(StepVerifier::create) + .assertNext(m -> assertThat(m.getVersion().getVersion()).isEqualTo(1)) + .verifyComplete(); + reactiveVersionedOperations.put(key, Collections.singletonMap("key", "v2")) + .as(StepVerifier::create) + .assertNext(m -> assertThat(m.getVersion().getVersion()).isEqualTo(2)) + .verifyComplete(); + + reactiveVersionedOperations.destroy(key, Version.from(2)).as(StepVerifier::create).verifyComplete(); + + reactiveVersionedOperations.get(key).as(StepVerifier::create).assertNext(versioned -> { + assertThat(versioned.getData()).isNull(); + assertThat(versioned.getVersion()).isEqualTo(Version.from(2)); + assertThat(versioned.getRequiredMetadata().isDestroyed()).isTrue(); + assertThat(versioned.getRequiredMetadata().getDeletedAt()).isNull(); + }).verifyComplete(); + } + +}