Add support for versioned Key-Value secrets.
We now provide support for versioned secrets through the Template API.
VaultVersionedKeyValueOperations versioned = vaultOperations.opsForVersionedKeyValue("versioned");
Map<String, String> secrets = Collections.singletonMap("key", "value");
Metadata writtenVersion = versioned.write("my-secret", secrets);
Versioned<Map<String, String>> casUpdate = Versioned.create(Collections.singletonMap("key", "updated"), metadata);
versioned.write("my-secret", casUpdate);
versioned.delete("my-secret", metadata.getVersion());
versioned.destroy("my-secret", metadata.getVersion());
Closes gh-239.
This commit is contained in:
@@ -43,6 +43,15 @@ import org.springframework.web.client.RestClientException;
|
||||
*/
|
||||
public interface VaultOperations {
|
||||
|
||||
/**
|
||||
* Return {@link VaultVersionedKeyValueOperations}.
|
||||
*
|
||||
* @param path the mount path
|
||||
* @return the operations interface to interact with the versioned Vault Key/Value
|
||||
* (version 2) backend.
|
||||
*/
|
||||
VaultVersionedKeyValueOperations opsForVersionedKeyValue(String path);
|
||||
|
||||
/**
|
||||
* @return the operations interface to interact with the Vault PKI backend.
|
||||
*/
|
||||
|
||||
@@ -206,6 +206,11 @@ public class VaultTemplate implements InitializingBean, VaultOperations, Disposa
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public VaultVersionedKeyValueOperations opsForVersionedKeyValue(String path) {
|
||||
return new VaultVersionedKeyValueTemplate(this, path);
|
||||
}
|
||||
|
||||
@Override
|
||||
public VaultPkiOperations opsForPki() {
|
||||
return opsForPki("pki");
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright 2018 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
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.vault.core;
|
||||
|
||||
import java.util.List;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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 Mark Paluch
|
||||
* @since 2.1
|
||||
*/
|
||||
public interface VaultVersionedKeyValueOperations {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@Nullable
|
||||
List<String> list(String path);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@Nullable
|
||||
default Versioned<Map<String, Object>> read(String path) {
|
||||
return read(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.
|
||||
*/
|
||||
@Nullable
|
||||
Versioned<Map<String, Object>> read(String path, Version version);
|
||||
|
||||
/**
|
||||
* 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}.
|
||||
*/
|
||||
Metadata write(String path, Object body);
|
||||
|
||||
/**
|
||||
* Delete latest version of the secret at {@code path}.
|
||||
*
|
||||
* @param path must not be {@literal null}.
|
||||
*/
|
||||
void delete(String path);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
void destroy(String path, Version... versionsToDelete);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/*
|
||||
* Copyright 2018 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
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.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.http.HttpStatus;
|
||||
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.VaultResponse;
|
||||
import org.springframework.vault.support.Versioned;
|
||||
import org.springframework.vault.support.Versioned.Metadata;
|
||||
import org.springframework.vault.support.Versioned.Version;
|
||||
import org.springframework.vault.support.Versioned.Metadata.MetadataBuilder;
|
||||
import org.springframework.web.client.HttpStatusCodeException;
|
||||
|
||||
/**
|
||||
* Default implementation of {@link VaultVersionedKeyValueOperations}.
|
||||
*
|
||||
* @author Mark Paluch
|
||||
* @since 2.1
|
||||
*/
|
||||
public class VaultVersionedKeyValueTemplate implements VaultVersionedKeyValueOperations {
|
||||
|
||||
private final VaultOperations vaultOperations;
|
||||
|
||||
private final String path;
|
||||
|
||||
/**
|
||||
* Create a new {@link VaultVersionedKeyValueTemplate} 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 VaultVersionedKeyValueTemplate(VaultOperations vaultOperations, String path) {
|
||||
|
||||
Assert.notNull(vaultOperations, "VaultOperations must not be null");
|
||||
Assert.hasText(path, "Path must not be empty");
|
||||
|
||||
this.vaultOperations = vaultOperations;
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public List<String> list(String path) {
|
||||
return vaultOperations.list(createBackendPath("metadata", path));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public Versioned<Map<String, Object>> read(String path, Version version) {
|
||||
|
||||
Assert.hasText(path, "Path must not be empty");
|
||||
Assert.notNull(version, "Version must not be null");
|
||||
|
||||
String secretPath = version.isVersioned() ? String.format(
|
||||
"%s/data/%s?version=%d", this.path, path, version.getVersion())
|
||||
: createDataPath(path);
|
||||
|
||||
VaultResponse response = vaultOperations.doWithSession(restOperations -> {
|
||||
|
||||
try {
|
||||
return restOperations.getForObject(secretPath, VaultResponse.class);
|
||||
}
|
||||
catch (HttpStatusCodeException e) {
|
||||
|
||||
if (e.getStatusCode() == HttpStatus.NOT_FOUND) {
|
||||
if (e.getResponseBodyAsString().contains("deletion_time")) {
|
||||
return VaultResponses.unwrap(e.getResponseBodyAsString(),
|
||||
VaultResponse.class);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
throw VaultResponses.buildException(e, path);
|
||||
}
|
||||
});
|
||||
|
||||
if (response == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, Object> responseData = response.getRequiredData();
|
||||
Metadata metadata = getMetadata((Map) responseData.get("metadata"));
|
||||
|
||||
return Versioned.create((Map<String, Object>) responseData.get("data"), metadata);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Metadata write(String path, Object body) {
|
||||
|
||||
Assert.hasText(path, "Path must not be empty");
|
||||
|
||||
Map<Object, Object> data = new LinkedHashMap<>();
|
||||
Map<Object, Object> requestOptions = new LinkedHashMap<>();
|
||||
|
||||
if (body instanceof Versioned) {
|
||||
|
||||
Versioned<?> versioned = (Versioned<?>) body;
|
||||
|
||||
data.put("data", versioned.getData());
|
||||
data.put("options", requestOptions);
|
||||
|
||||
requestOptions.put("cas", versioned.getVersion().getVersion());
|
||||
}
|
||||
else {
|
||||
data.put("data", body);
|
||||
}
|
||||
|
||||
VaultResponse response = vaultOperations.write(createDataPath(path), data);
|
||||
|
||||
return getMetadata(response.getRequiredData());
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String path) {
|
||||
|
||||
Assert.hasText(path, "Path must not be empty");
|
||||
|
||||
vaultOperations.delete(createDataPath(path));
|
||||
}
|
||||
|
||||
@Override
|
||||
public 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) {
|
||||
delete(path);
|
||||
return;
|
||||
}
|
||||
|
||||
List<Integer> versions = toVersionList(versionsToDelete);
|
||||
|
||||
vaultOperations.write(createBackendPath("delete", path),
|
||||
Collections.singletonMap("versions", versions));
|
||||
}
|
||||
|
||||
private static List<Integer> toVersionList(Version[] versionsToDelete) {
|
||||
return Arrays.stream(versionsToDelete).filter(Version::isVersioned)
|
||||
.map(Version::getVersion).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public 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);
|
||||
|
||||
vaultOperations.write(createBackendPath("undelete", path),
|
||||
Collections.singletonMap("versions", versions));
|
||||
}
|
||||
|
||||
@Override
|
||||
public 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);
|
||||
|
||||
vaultOperations.write(createBackendPath("destroy", path),
|
||||
Collections.singletonMap("versions", versions));
|
||||
}
|
||||
|
||||
private String createDataPath(String path) {
|
||||
return createBackendPath("data", path);
|
||||
}
|
||||
|
||||
private String createBackendPath(String segment, String path) {
|
||||
return String.format("%s/%s/%s", this.path, segment, path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
/*
|
||||
* Copyright 2018 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
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.vault.support;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* Value object representing versioned secrets along {@link Version} metadata. A versioned
|
||||
* object can hold various states to represent:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Initial (not yet versioned) secrets via {@link Versioned#create(Object)}</li>
|
||||
* <li>Versioned secrets via {@link Versioned#create(Object, Version)}</li>
|
||||
* <li>Versioned secrets with {@link Metadata} attached
|
||||
* {@link Versioned#create(Object, Metadata)}</li>
|
||||
* </ul>
|
||||
*
|
||||
* Versioned secrets follow a lifecycle that spans from creation to destruction:
|
||||
*
|
||||
* <ol>
|
||||
* <li>Creation of an unversioned secret: Secret is not yet persisted.</li>
|
||||
* <li>Versioned secret: Secret is persisted.</li>
|
||||
* <li>Superseded versioned secret: A newer secret version is stored.</li>
|
||||
* <li>Deleted versioned secret: Version was deleted. Can be undeleted.</li>
|
||||
* <li>Destroyed versioned secret: Version was destroyed.</li>
|
||||
* </ol>
|
||||
*
|
||||
* @author Mark Paluch
|
||||
* @since 2.1
|
||||
* @see Version
|
||||
* @see Metadata
|
||||
*/
|
||||
public class Versioned<T> {
|
||||
|
||||
private final @Nullable T data;
|
||||
|
||||
private final Version version;
|
||||
|
||||
private final @Nullable Metadata metadata;
|
||||
|
||||
private Versioned(T data, Version version) {
|
||||
|
||||
this.version = version;
|
||||
this.metadata = null;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
private Versioned(@Nullable T data, Version version, Metadata metadata) {
|
||||
|
||||
this.version = version;
|
||||
this.metadata = metadata;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a {@link Version#unversioned() unversioned} given secret.
|
||||
*
|
||||
* @param secret must not be {@literal null}.
|
||||
* @return the {@link Versioned} object for {@code secret}
|
||||
*/
|
||||
public static <T> Versioned<T> create(T secret) {
|
||||
|
||||
Assert.notNull(secret, "Versioned data must not be null");
|
||||
|
||||
return new Versioned<>(secret, Version.unversioned());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a versioned secret object given {@code secret} and {@link Version}.
|
||||
* Versioned secret may contain no actual data as they can be in a deleted/destroyed
|
||||
* state.
|
||||
*
|
||||
* @param secret can be {@literal null}.
|
||||
* @param version must not be {@literal null}.
|
||||
* @return the {@link Versioned} object for {@code secret} and {@code Version}.
|
||||
*/
|
||||
public static <T> Versioned<T> create(@Nullable T secret, Version version) {
|
||||
|
||||
Assert.notNull(version, "Version must not be null");
|
||||
|
||||
return new Versioned<>(secret, version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a versioned secret object given {@code secret} and {@link Metadata}.
|
||||
* Versioned secret may contain no actual data as they can be in a deleted/destroyed
|
||||
* state.
|
||||
*
|
||||
* @param secret can be {@literal null}.
|
||||
* @param metadata must not be {@literal null}.
|
||||
* @return the {@link Versioned} object for {@code secret} and {@link Metadata}.
|
||||
*/
|
||||
public static <T> Versioned<T> create(@Nullable T secret, Metadata metadata) {
|
||||
|
||||
Assert.notNull(metadata, "Metadata must not be null");
|
||||
|
||||
return new Versioned<>(secret, metadata.getVersion(), metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the {@link Version} associated with this {@link Versioned} object.
|
||||
*/
|
||||
public Version getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {@literal true} if this versioned object has {@link Metadata} associated,
|
||||
* otherwise {@code false}
|
||||
*/
|
||||
public boolean hasMetadata() {
|
||||
return metadata != null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Metadata getMetadata() {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {@literal true} if this versioned object has data associated, or
|
||||
* {@code false}, of the version is deleted or destroyed.
|
||||
*/
|
||||
public boolean hasData() {
|
||||
return data != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the actual data for this versioned object. Can be {@literal null} if the
|
||||
* version is deleted or destroyed.
|
||||
*/
|
||||
@Nullable
|
||||
public T getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the required data for this versioned object. Throws
|
||||
* {@link IllegalStateException} if no data is associated.
|
||||
*
|
||||
* @return the non-null value held by this for this versioned object.
|
||||
* @throws IllegalStateException if no data is present.
|
||||
*/
|
||||
public T getRequiredData() {
|
||||
|
||||
T data = this.data;
|
||||
|
||||
if (data == null) {
|
||||
throw new IllegalStateException("Required data is not present");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the data element of this versioned object to an {@link Optional}.
|
||||
*
|
||||
* @return {@link Optional#of(Object) Optional} holding the actual value of this
|
||||
* versioned object if {@link #hasData() data is present}, {@link Optional#empty()} if
|
||||
* no data is associated.
|
||||
*/
|
||||
public Optional<T> toOptional() {
|
||||
return Optional.ofNullable(data);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o)
|
||||
return true;
|
||||
if (!(o instanceof Versioned))
|
||||
return false;
|
||||
Versioned<?> versioned = (Versioned<?>) o;
|
||||
return Objects.equals(data, versioned.data)
|
||||
&& Objects.equals(version, versioned.version)
|
||||
&& Objects.equals(metadata, versioned.metadata);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
|
||||
return Objects.hash(data, version, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Value object representing version metadata such as creation/deletion time.
|
||||
*/
|
||||
public static class Metadata {
|
||||
|
||||
private final Instant createdAt;
|
||||
|
||||
private final @Nullable Instant deletedAt;
|
||||
|
||||
private final boolean destroyed;
|
||||
|
||||
private final Version version;
|
||||
|
||||
private Metadata(Instant createdAt, @Nullable Instant deletedAt,
|
||||
boolean destroyed, Version version) {
|
||||
this.createdAt = createdAt;
|
||||
this.deletedAt = deletedAt;
|
||||
this.destroyed = destroyed;
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new {@link MetadataBuilder} to build {@link Metadata} objects.
|
||||
*
|
||||
* @return a new {@link MetadataBuilder} to build {@link Metadata} objects.
|
||||
*/
|
||||
public static MetadataBuilder builder() {
|
||||
return new MetadataBuilder();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {@link Instant} at which the version was created.
|
||||
*/
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {@literal true} if the version was deleted.
|
||||
*/
|
||||
public boolean isDeleted() {
|
||||
return deletedAt != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {@link Instant} at which the version was deleted. Can be
|
||||
* {@literal null} if the version is not deleted.
|
||||
*/
|
||||
@Nullable
|
||||
public Instant getDeletedAt() {
|
||||
return deletedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the version number.
|
||||
*/
|
||||
public Version getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {@literal true} if the version was destroyed.
|
||||
*/
|
||||
public boolean isDestroyed() {
|
||||
return destroyed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
|
||||
return getClass().getSimpleName() + " [createdAt=" + createdAt
|
||||
+ ", deletedAt=" + deletedAt + ", destroyed=" + destroyed
|
||||
+ ", version=" + version + ']';
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder for {@link Metadata} objects.
|
||||
*/
|
||||
public static class MetadataBuilder {
|
||||
|
||||
private @Nullable Instant createdAt;
|
||||
|
||||
private @Nullable Instant deletedAt;
|
||||
|
||||
private boolean destroyed;
|
||||
|
||||
private @Nullable Version version;
|
||||
|
||||
private MetadataBuilder() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure a created at {@link Instant}.
|
||||
*
|
||||
* @param createdAt timestamp at which the version was created, must not be
|
||||
* {@literal null}.
|
||||
* @return {@code this} {@link MetadataBuilder}.
|
||||
*/
|
||||
public MetadataBuilder createdAt(Instant createdAt) {
|
||||
|
||||
Assert.notNull(createdAt, "Created at must not be null");
|
||||
|
||||
this.createdAt = createdAt;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure a deleted at {@link Instant}.
|
||||
*
|
||||
* @param deletedAt timestamp at which the version was deleted, must not be
|
||||
* {@literal null}.
|
||||
* @return {@code this} {@link MetadataBuilder}.
|
||||
*/
|
||||
public MetadataBuilder deletedAt(Instant deletedAt) {
|
||||
|
||||
Assert.notNull(deletedAt, "Deleted at must not be null");
|
||||
|
||||
this.deletedAt = deletedAt;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the version was destroyed.
|
||||
*
|
||||
* @return {@code this} {@link MetadataBuilder}.
|
||||
*/
|
||||
public MetadataBuilder destroyed() {
|
||||
return destroyed(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the version was destroyed.
|
||||
*
|
||||
* @param destroyed
|
||||
* @return {@code this} {@link MetadataBuilder}.
|
||||
*/
|
||||
public MetadataBuilder destroyed(boolean destroyed) {
|
||||
this.destroyed = destroyed;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the {@link Version}.
|
||||
*
|
||||
* @param version must not be {@literal null}.
|
||||
* @return {@code this} {@link MetadataBuilder}.
|
||||
*/
|
||||
public MetadataBuilder version(Version version) {
|
||||
|
||||
Assert.notNull(version, "Version must not be null!");
|
||||
|
||||
this.version = version;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the {@link Metadata} object. Requires {@link #createdAt(Instant)} and
|
||||
* {@link #version(Version)} to be set.
|
||||
*
|
||||
* @return the {@link Metadata} object.
|
||||
*/
|
||||
public Metadata build() {
|
||||
|
||||
Assert.notNull(createdAt, "CreatedAt must not be null");
|
||||
Assert.notNull(version, "Version must not be null");
|
||||
|
||||
return new Metadata(createdAt, deletedAt, destroyed, version);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Value object representing a Vault version.
|
||||
* <p/>
|
||||
* Versions greater zero point to a specific secret version whereas version number
|
||||
* zero points to a placeholder whose meaning is tied to a specific operation. Version
|
||||
* number zero can mean first created version, latest version.
|
||||
*
|
||||
* @author Mark Paluch
|
||||
*/
|
||||
public static class Version {
|
||||
|
||||
static final Version UNVERSIONED = new Version(0);
|
||||
|
||||
private final int version;
|
||||
|
||||
private Version(int version) {
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the unversioned {@link Version} as placeholder for specific operations
|
||||
* that require version number zero.
|
||||
*/
|
||||
public static Version unversioned() {
|
||||
return UNVERSIONED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a {@link Version} given a {@code versionNumber}.
|
||||
*
|
||||
* @param versionNumber the version number.
|
||||
* @return the {@link Version} for {@code versionNumber}.
|
||||
*/
|
||||
public static Version from(int versionNumber) {
|
||||
|
||||
if (versionNumber > 0) {
|
||||
return new Version(versionNumber);
|
||||
}
|
||||
|
||||
return UNVERSIONED;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {@literal true} if this {@link Version} points to a valid version
|
||||
* number, {@literal false} otherwise.
|
||||
* <p/>
|
||||
* Version numbers that are equal zero are placeholders to denote unversioned or
|
||||
* latest versions in the context of particular versioning operations.
|
||||
*/
|
||||
public boolean isVersioned() {
|
||||
return version > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the version number.
|
||||
*/
|
||||
public int getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o)
|
||||
return true;
|
||||
if (!(o instanceof Version))
|
||||
return false;
|
||||
Version version1 = (Version) o;
|
||||
return version == version1.version;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(version);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return String.format("Version[%d]", version);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* Copyright 2018 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
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.vault.core;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
import org.springframework.test.context.junit4.SpringRunner;
|
||||
import org.springframework.vault.VaultException;
|
||||
import org.springframework.vault.support.Versioned;
|
||||
import org.springframework.vault.support.Versioned.Metadata;
|
||||
import org.springframework.vault.support.Versioned.Version;
|
||||
import org.springframework.vault.util.IntegrationTestSupport;
|
||||
import org.springframework.vault.util.VaultRule;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.junit.Assume.assumeTrue;
|
||||
|
||||
/**
|
||||
* Integration tests for {@link VaultVersionedKeyValueTemplate}.
|
||||
*
|
||||
* @author Mark Paluch
|
||||
*/
|
||||
@RunWith(SpringRunner.class)
|
||||
@ContextConfiguration(classes = VaultIntegrationTestConfiguration.class)
|
||||
public class VaultVersionedKeyValueTemplateIntegrationTests extends
|
||||
IntegrationTestSupport {
|
||||
|
||||
@Autowired
|
||||
private VaultOperations vaultOperations;
|
||||
private VaultVersionedKeyValueOperations versionedOperations;
|
||||
|
||||
@Before
|
||||
public void before() {
|
||||
|
||||
assumeTrue(prepare().getVersion().isGreaterThanOrEqualTo(
|
||||
VaultRule.VERSIONING_INTRODUCED_WITH));
|
||||
|
||||
versionedOperations = vaultOperations.opsForVersionedKeyValue("versioned");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldCreateVersionedSecret() {
|
||||
|
||||
Map<String, String> secret = Collections.singletonMap("key", "value");
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
|
||||
Metadata metadata = versionedOperations.write(key, Versioned.create(secret));
|
||||
|
||||
assertThat(metadata.isDestroyed()).isFalse();
|
||||
assertThat(metadata.getCreatedAt()).isBetween(Instant.now().minusSeconds(60),
|
||||
Instant.now().plusSeconds(60));
|
||||
assertThat(metadata.getDeletedAt()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldCreateVersionedWithCAS() {
|
||||
|
||||
Map<String, String> secret = Collections.singletonMap("key", "value");
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
|
||||
versionedOperations.write(key, Versioned.create(secret, Version.unversioned()));
|
||||
|
||||
// this should fail
|
||||
assertThatThrownBy(
|
||||
() -> versionedOperations.write(key,
|
||||
Versioned.create(secret, Version.unversioned())))
|
||||
.isExactlyInstanceOf(VaultException.class).hasMessageContaining(
|
||||
"check-and-set parameter did not match the current version");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldReadAndWriteVersionedSecret() {
|
||||
|
||||
Map<String, String> secret = Collections.singletonMap("key", "value");
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
|
||||
versionedOperations.write(key, Versioned.create(secret));
|
||||
|
||||
Versioned<Map<String, Object>> loaded = versionedOperations.read(key);
|
||||
|
||||
assertThat(loaded.getData()).isEqualTo(secret);
|
||||
assertThat(loaded.getMetadata()).isNotNull();
|
||||
assertThat(loaded.getVersion()).isEqualTo(Version.from(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldListExistingSecrets() {
|
||||
|
||||
Map<String, String> secret = Collections.singletonMap("key", "value");
|
||||
String key = UUID.randomUUID().toString();
|
||||
|
||||
versionedOperations.write(key, secret);
|
||||
|
||||
assertThat(versionedOperations.list("")).contains(key);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldReadDifferentVersions() {
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
|
||||
versionedOperations.write(key, Collections.singletonMap("key", "v1"));
|
||||
versionedOperations.write(key, Collections.singletonMap("key", "v2"));
|
||||
|
||||
assertThat(versionedOperations.read(key, Version.from(1)).getData()).isEqualTo(
|
||||
Collections.singletonMap("key", "v1"));
|
||||
assertThat(versionedOperations.read(key, Version.from(2)).getData()).isEqualTo(
|
||||
Collections.singletonMap("key", "v2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldDeleteMostRecentVersion() {
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
|
||||
versionedOperations.write(key, Collections.singletonMap("key", "v1"));
|
||||
versionedOperations.write(key, Collections.singletonMap("key", "v2"));
|
||||
|
||||
versionedOperations.delete(key);
|
||||
|
||||
Versioned<Map<String, Object>> versioned = versionedOperations.read(key);
|
||||
|
||||
assertThat(versioned.getData()).isNull();
|
||||
assertThat(versioned.getVersion()).isEqualTo(Version.from(2));
|
||||
assertThat(versioned.getMetadata().isDestroyed()).isFalse();
|
||||
assertThat(versioned.getMetadata().getDeletedAt()).isBetween(
|
||||
Instant.now().minusSeconds(60), Instant.now().plusSeconds(60));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldUndeleteVersion() {
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
|
||||
versionedOperations.write(key, Collections.singletonMap("key", "v1"));
|
||||
versionedOperations.write(key, Collections.singletonMap("key", "v2"));
|
||||
|
||||
versionedOperations.delete(key, Version.from(2));
|
||||
versionedOperations.undelete(key, Version.from(2));
|
||||
|
||||
Versioned<Map<String, Object>> versioned = versionedOperations.read(key);
|
||||
|
||||
assertThat(versioned.getData()).isEqualTo(Collections.singletonMap("key", "v2"));
|
||||
assertThat(versioned.getVersion()).isEqualTo(Version.from(2));
|
||||
assertThat(versioned.getMetadata().isDestroyed()).isFalse();
|
||||
assertThat(versioned.getMetadata().getDeletedAt()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldDeleteIntermediateRecentVersion() {
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
|
||||
versionedOperations.write(key, Collections.singletonMap("key", "v1"));
|
||||
versionedOperations.write(key, Collections.singletonMap("key", "v2"));
|
||||
|
||||
versionedOperations.delete(key, Version.from(1));
|
||||
|
||||
Versioned<Map<String, Object>> versioned = versionedOperations.read(key,
|
||||
Version.from(1));
|
||||
|
||||
assertThat(versioned.getData()).isNull();
|
||||
assertThat(versioned.getVersion()).isEqualTo(Version.from(1));
|
||||
assertThat(versioned.getMetadata().isDestroyed()).isFalse();
|
||||
assertThat(versioned.getMetadata().getDeletedAt()).isBetween(
|
||||
Instant.now().minusSeconds(60), Instant.now().plusSeconds(60));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldDestroyVersion() {
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
|
||||
versionedOperations.write(key, Collections.singletonMap("key", "v1"));
|
||||
versionedOperations.write(key, Collections.singletonMap("key", "v2"));
|
||||
|
||||
versionedOperations.destroy(key, Version.from(2));
|
||||
|
||||
Versioned<Map<String, Object>> versioned = versionedOperations.read(key);
|
||||
|
||||
assertThat(versioned.getData()).isNull();
|
||||
assertThat(versioned.getVersion()).isEqualTo(Version.from(2));
|
||||
assertThat(versioned.getMetadata().isDestroyed()).isTrue();
|
||||
assertThat(versioned.getMetadata().getDeletedAt()).isNull();
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
package org.springframework.vault.util;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -150,10 +151,25 @@ public class PrepareVault {
|
||||
* @param secretBackend must not be {@literal null} or empty.
|
||||
*/
|
||||
public void mountSecret(String secretBackend) {
|
||||
mountSecret(secretBackend, secretBackend, Collections.emptyMap());
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount an secret backend {@code secretBackend} at {@code path}.
|
||||
*
|
||||
* @param secretBackend must not be {@literal null} or empty.
|
||||
* @param path must not be {@literal null} or empty.
|
||||
* @param config must not be {@literal null}.
|
||||
*/
|
||||
public void mountSecret(String secretBackend, String path, Map<String, Object> config) {
|
||||
|
||||
Assert.hasText(secretBackend, "SecretBackend must not be empty");
|
||||
Assert.hasText(path, "Mount path must not be empty");
|
||||
Assert.notNull(config, "Configuration must not be null");
|
||||
|
||||
adminOperations.mount(secretBackend, VaultMount.create(secretBackend));
|
||||
VaultMount mount = VaultMount.builder().type(secretBackend).config(config)
|
||||
.build();
|
||||
adminOperations.mount(path, mount);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,6 +219,15 @@ public class PrepareVault {
|
||||
vaultOperations.opsForSys().mount("secret", kv);
|
||||
}
|
||||
|
||||
public void mountVersionedKvBackend() {
|
||||
|
||||
mountSecret("kv", "versioned", Collections.emptyMap());
|
||||
vaultOperations.write(
|
||||
"sys/mounts/versioned/tune",
|
||||
Collections.singletonMap("options",
|
||||
Collections.singletonMap("version", "2")));
|
||||
}
|
||||
|
||||
public VaultOperations getVaultOperations() {
|
||||
return vaultOperations;
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ public class VaultRule extends ExternalResource {
|
||||
if (this.prepareVault.getVersion().isGreaterThanOrEqualTo(
|
||||
VERSIONING_INTRODUCED_WITH)) {
|
||||
this.prepareVault.disableGenericVersioning();
|
||||
this.prepareVault.mountVersionedKvBackend();
|
||||
}
|
||||
|
||||
this.token = Settings.token();
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
[[new-features.2-1-0]]
|
||||
=== What's new in Spring Vault 2.1
|
||||
* <<vault.authentication.gcpgce,GCP Compute>> and <<vault.authentication.gcpiam,GCP IAM>> authentication.
|
||||
* Template API support for versioned Key/Value backends.
|
||||
|
||||
[[new-features.2-0-0]]
|
||||
=== What's new in Spring Vault 2.0
|
||||
|
||||
Reference in New Issue
Block a user