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:
Mark Paluch
2018-04-11 14:52:39 +02:00
parent d58cdf6021
commit 8230bcc702
9 changed files with 1056 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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