ReactiveVaultTemplate for the key-value backend version 2.

Closes gh-576
Original pull request: gh-807
This commit is contained in:
Timothy R. Weiand
2023-04-17 12:20:31 -07:00
committed by Mark Paluch
parent c7f47b2d5b
commit c303f55312
27 changed files with 2032 additions and 159 deletions

View File

@@ -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 <T> ParameterizedTypeReference<VaultResponseSupport<T>> getTypeReference(
final ParameterizedTypeReference<T> 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<VaultResponseSupport<T>>() {
@Override
public Type getType() {
return supportType;
}
};
}
public static <T> ParameterizedTypeReference<VaultResponseDataVersion2<T>> getDataTypeReference(
final Class<T> 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<VaultResponseDataVersion2<T>>() {
@Override
public Type getType() {
return supportType;
}
};
}
/**
* Obtain the error message from a JSON response.
* @param json must not be {@literal null}.

View File

@@ -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 <T> Mono<T> getRequiredData(VaultResponseSupport<T> support) {
return Mono.fromCallable(support::getRequiredData);
}
static Map<String, Object> makeMetadata(final Map<String, Object> metadata, final Map<String, Object> requiredData,
Map<String, ?> patch) {
Map<String, Object> data = new LinkedHashMap<>(requiredData);
data.putAll(patch);
Map<String, Object> body = new HashMap<>();
body.put("data", data);
body.put("options", Collections.singletonMap("cas", metadata.get("version")));
return body;
}
}

View File

@@ -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<String> list(String path) {
return reactiveVaultOperations.list(createDataPath(path));
}
@Override
@SuppressWarnings("unchecked")
public Mono<VaultResponse> get(String path) {
ParameterizedTypeReference<Map<String, Object>> 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 <T> Mono<VaultResponseSupport<T>> get(String path, Class<T> responseType) {
return doRead(path, responseType).onErrorResume(WebClientResponseException.NotFound.class, e -> Mono.empty());
}
@Override
public Mono<Boolean> patch(String path, Map<String, ?> patch) {
throw new IllegalStateException("K/V engine mount must be version 2 for patch support");
}
@Override
public Mono<Void> 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);
}
}

View File

@@ -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<String> list(String path) {
String pathToUse = path.equals("/") ? "" : path.endsWith("/") ? path : (path + "/");
// TODO: to test - null returns empty
ParameterizedTypeReference<VaultResponseSupport<Map<String, Object>>> type = VaultResponses
.getTypeReference(new ParameterizedTypeReference<>() {
});
return doReadRaw(String.format("%s?list=true", createBackendPath("metadata", pathToUse)), type, false)
.flatMap(ReactiveKeyValueHelper::getRequiredData)
.flatMapMany(response -> {
final List<String> list = (List<String>) 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);
}
}

View File

@@ -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<VaultResponse> get(String path) {
ParameterizedTypeReference<VaultResponseDataVersion2<Map<String, Object>>> 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<Map<String, Object>> data = response.getData();
if (null != data) {
vaultResponse.setData(data.getData());
vaultResponse.setMetadata(data.getMetadata());
}
return vaultResponse;
});
}
@Override
public <T> Mono<VaultResponseSupport<T>> get(String path, Class<T> responseType) {
ParameterizedTypeReference<VaultResponseSupport<VaultResponseDataVersion2<T>>> ref = VaultResponses
.getTypeReference(VaultResponses.getDataTypeReference(responseType));
return doReadRaw(createDataPath(path), ref, false)
.onErrorResume(WebClientResponseException.NotFound.class, e -> Mono.empty())
.map(response -> {
VaultResponseSupport<T> vaultResponse = new VaultResponseSupport<>();
VaultResponseSupport.updateWithoutData(vaultResponse, response);
VaultResponseDataVersion2<T> data = response.getData();
if (null != data) {
vaultResponse.setData(data.getData());
vaultResponse.setMetadata(data.getMetadata());
}
return vaultResponse;
});
}
@Override
public Mono<Boolean> patch(String path, Map<String, ?> 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<String, Object> 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<Void> put(String path, Object body) {
return doWrite(createDataPath(path), Collections.singletonMap("data", body)).then();
}
}

View File

@@ -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
* <p/>
* 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<Void> 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();
}
<I> Mono<VaultResponseSupport<I>> doRead(String path, Class<I> deserializeAs) {
ParameterizedTypeReference<VaultResponseSupport<I>> ref = VaultResponses.getTypeReference(deserializeAs);
return doReadRaw(createDataPath(path), ref, false);
}
<I> Mono<VaultResponseSupport<I>> doRead(String path, ParameterizedTypeReference<I> deserializeAs) {
ParameterizedTypeReference<VaultResponseSupport<I>> 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.
* <p>
* Returns Mono.error(WebClientResponseException.NotFound) if the secret is not found.
* @param path URI for request
* @param rawRef Type reference of return object
* @param <I> 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 <I> Mono<I> doReadRaw(String path, ParameterizedTypeReference<I> 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<VaultResponse> 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);
}

View File

@@ -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.
* <p/>
* 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<VaultMetadataResponse> 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<Void> 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<Void> delete(String path);
}

View File

@@ -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<VaultMetadataResponse> 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<Void> put(String path, VaultMetadataRequest body) {
Assert.notNull(body, "Body must not be null");
return vaultOperations.write(getMetadataPath(path), body).then();
}
@Override
public Mono<Void> 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;
}
}

View File

@@ -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.
* <p/>
* 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<VaultResponse> 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.
*/
<T> Mono<VaultResponseSupport<T>> get(String path, Class<T> 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<Boolean> patch(String path, Map<String, ?> patch);
/**
* Write the secret at {@code path}.
* @param path must not be {@literal null}.
* @param body must not be {@literal null}.
*/
Mono<Void> put(String path, Object body);
}

View File

@@ -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.
* <p/>
*
* @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<String> 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.
*/
<T> Mono<T> get(String path);
/**
* Delete the secret at {@code path}.
* @param path must not be {@literal null}.
*/
Mono<Void> delete(String path);
/**
* @return the used API version.
*/
KeyValueBackend getApiVersion();
}

View File

@@ -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.

View File

@@ -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 <T> Function<ClientResponse, Mono<T>> mapResponse(Class<T> bodyType, String path, HttpMethod method) {
return response -> isSuccess(response) ? response.bodyToMono(bodyType) : mapOtherwise(response, path, method);
}
public static <T> Function<ClientResponse, Mono<T>> mapResponse(ParameterizedTypeReference<T> 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 <T> Mono<T> 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<VaultResponse> 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<VaultResponseSupport<T>> 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<VaultListResponse> 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<String>) 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 <T> Function<ClientResponse, Mono<T>> mapResponse(Class<T> bodyType, String path,
HttpMethod method) {
return response -> isSuccess(response) ? response.bodyToMono(bodyType) : mapOtherwise(response, path, method);
}
private static <T> Function<ClientResponse, Mono<T>> mapResponse(ParameterizedTypeReference<T> 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 <T> Mono<T> 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<Map<String, Object>> {
}
private enum NoTokenSupplier implements VaultTokenSupplier {
INSTANCE;
@@ -381,4 +395,8 @@ public class ReactiveVaultTemplate implements ReactiveVaultOperations {
}
private static class VaultListResponse extends VaultResponseSupport<Map<String, Object>> {
}
}

View File

@@ -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.
* <p/>
* 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<Versioned<Map<String, Object>>> 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.
*/
<T> Mono<Versioned<T>> 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 <T> Mono<Versioned<T>> get(String path, Class<T> 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.
*/
<T> Mono<Versioned<T>> get(String path, Version version, Class<T> 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<Metadata> 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<Void> 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<Void> 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<Void> destroy(String path, Version... versionsToDelete);
/**
* Return {@link ReactiveVaultKeyValueMetadataOperations}
* @return the operations interface to interact with the Vault Key/Value metadata
* backend
*/
ReactiveVaultKeyValueMetadataOperations opsForKeyValueMetadata();
}

View File

@@ -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<Integer> toVersionList(Version[] versionsToDelete) {
return Arrays.stream(versionsToDelete)
.filter(Version::isVersioned)
.map(Version::getVersion)
.collect(Collectors.toList());
}
@Override
@SuppressWarnings("unchecked")
public Mono<Versioned<Map<String, Object>>> 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<String, Object>) m.getData(), m.getMetadata()));
}
@Override
public <T> Mono<Versioned<T>> get(String path, Version version, Class<T> 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 <T> Mono<Versioned<T>> doRead(String path, Version version, Class<T> responseType) {
String secretPath = version.isVersioned()
? String.format("%s?version=%d", createDataPath(path), version.getVersion()) : createDataPath(path);
ParameterizedTypeReference<VaultResponseSupport<VaultResponseSupport<T>>> 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<String, Object> 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<Metadata> put(String path, Object body) {
Assert.hasText(path, "Path must not be empty");
LinkedHashMap<Object, Object> data = new LinkedHashMap<>();
LinkedHashMap<Object, Object> 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<Void> 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<Integer> versions = toVersionList(versionsToDelete);
return doWrite(createBackendPath("delete", path), Collections.singletonMap("versions", versions)).then().log();
}
@Override
public Mono<Void> undelete(String path, Version... versionsToDelete) {
Assert.hasText(path, "Path must not be empty");
Assert.noNullElements(versionsToDelete, "Versions must not be null");
List<Integer> versions = toVersionList(versionsToDelete);
return doWrite(createBackendPath("undelete", path), Collections.singletonMap("versions", versions)).then();
}
@Override
public Mono<Void> destroy(String path, Version... versionsToDelete) {
Assert.hasText(path, "Path must not be empty");
Assert.noNullElements(versionsToDelete, "Versions must not be null");
List<Integer> 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<T> extends VaultResponseSupport<VaultResponseSupport<T>> {
}
}

View File

@@ -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;

View File

@@ -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<String, Object> metadata = readResponse.getMetadata();
Map<String, Object> data = new LinkedHashMap<>(readResponse.getRequiredData());
data.putAll(patch);
Map<String, Object> body = new HashMap<>();
body.put("data", data);
body.put("options", Collections.singletonMap("cas", metadata.get("version")));
Map<String, Object> body = ReactiveKeyValueHelper.makeMetadata(readResponse.getMetadata(),
readResponse.getRequiredData(), patch);
try {
doWrite(createDataPath(path), body);

View File

@@ -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<Map> 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<String, Object> 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<Versioned.Metadata> buildVersions(Map<String, Map<String, Object>> versions) {
return versions.entrySet()
.stream()
.map(entry -> buildVersion(entry.getKey(), entry.getValue()))
.collect(Collectors.toList());
}
private static Versioned.Metadata buildVersion(String version, Map<String, Object> 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<String, String>) 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;
}
}

View File

@@ -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<String, Object> 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<String, Object> 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<String, Object> 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<Metadata> buildVersions(Map<String, Map<String, Object>> versions) {
return versions.entrySet()
.stream()
.map(entry -> buildVersion(entry.getKey(), entry.getValue()))
.collect(Collectors.toList());
}
private static Versioned.Metadata buildVersion(String version, Map<String, Object> 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();
}
}

View File

@@ -122,7 +122,7 @@ public class VaultVersionedKeyValueTemplate extends VaultKeyValue2Accessor imple
}
VaultResponseSupport<JsonNode> 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<String, Object> 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<String, String>) responseMetadata.get("custom_metadata"));
return builder.build();
}
@Nullable
private static TemporalAccessor getDate(Map<String, Object> 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

View File

@@ -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<T> {
@Nullable
private T data;
@Nullable
private Map<String, Object> metadata;
@Nullable
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
@Nullable
public Map<String, Object> getMetadata() {
return metadata;
}
public void setMetadata(Map<String, Object> metadata) {
this.metadata = metadata;
}
}

View File

@@ -63,6 +63,17 @@ public class VaultResponseSupport<T> {
@Nullable
private List<String> 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.
*/

View File

@@ -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<T> extends VaultResponseSupport<VaultResponseDataVersion2<T>> {
}

View File

@@ -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<String, String> 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<String, String> 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<String, String> 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();
}
}

View File

@@ -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<String, Object> 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();
}
}

View File

@@ -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<String, Object> 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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}