diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultOperations.java b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultOperations.java
index e091c533..3cd77a46 100644
--- a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultOperations.java
+++ b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultOperations.java
@@ -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.
*/
diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultTemplate.java b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultTemplate.java
index 13add496..0f8f4e05 100644
--- a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultTemplate.java
+++ b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultTemplate.java
@@ -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");
diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultVersionedKeyValueOperations.java b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultVersionedKeyValueOperations.java
new file mode 100644
index 00000000..7665655b
--- /dev/null
+++ b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultVersionedKeyValueOperations.java
@@ -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.
+ *
+ * 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 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> 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> 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);
+}
diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultVersionedKeyValueTemplate.java b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultVersionedKeyValueTemplate.java
new file mode 100644
index 00000000..aa4997b9
--- /dev/null
+++ b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultVersionedKeyValueTemplate.java
@@ -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 list(String path) {
+ return vaultOperations.list(createBackendPath("metadata", path));
+ }
+
+ @Override
+ @Nullable
+ public Versioned> 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 responseData = response.getRequiredData();
+ Metadata metadata = getMetadata((Map) responseData.get("metadata"));
+
+ return Versioned.create((Map) responseData.get("data"), metadata);
+ }
+
+ @Override
+ public Metadata write(String path, Object body) {
+
+ Assert.hasText(path, "Path must not be empty");
+
+ Map data = new LinkedHashMap<>();
+ Map 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 responseMetadata) {
+
+ MetadataBuilder builder = Metadata.builder();
+ TemporalAccessor created_time = getDate(responseMetadata, "created_time");
+ TemporalAccessor deletion_time = getDate(responseMetadata, "deletion_time");
+
+ builder.createdAt(Instant.from(created_time));
+
+ if (deletion_time != null) {
+ builder.deletedAt(Instant.from(deletion_time));
+ }
+
+ if (Boolean.TRUE.equals(responseMetadata.get("destroyed"))) {
+ builder.destroyed();
+ }
+
+ Integer version = (Integer) responseMetadata.get("version");
+ builder.version(Version.from(version));
+
+ return builder.build();
+ }
+
+ @Nullable
+ private static TemporalAccessor getDate(Map responseMetadata,
+ String key) {
+
+ String date = (String) responseMetadata.getOrDefault(key, "");
+ if (StringUtils.hasText(date)) {
+ return DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(date);
+ }
+ return null;
+ }
+
+ @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 versions = toVersionList(versionsToDelete);
+
+ vaultOperations.write(createBackendPath("delete", path),
+ Collections.singletonMap("versions", versions));
+ }
+
+ private static List 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 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 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);
+ }
+}
diff --git a/spring-vault-core/src/main/java/org/springframework/vault/support/Versioned.java b/spring-vault-core/src/main/java/org/springframework/vault/support/Versioned.java
new file mode 100644
index 00000000..81718bf8
--- /dev/null
+++ b/spring-vault-core/src/main/java/org/springframework/vault/support/Versioned.java
@@ -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:
+ *
+ *
+ * Initial (not yet versioned) secrets via {@link Versioned#create(Object)}
+ * Versioned secrets via {@link Versioned#create(Object, Version)}
+ * Versioned secrets with {@link Metadata} attached
+ * {@link Versioned#create(Object, Metadata)}
+ *
+ *
+ * Versioned secrets follow a lifecycle that spans from creation to destruction:
+ *
+ *
+ * Creation of an unversioned secret: Secret is not yet persisted.
+ * Versioned secret: Secret is persisted.
+ * Superseded versioned secret: A newer secret version is stored.
+ * Deleted versioned secret: Version was deleted. Can be undeleted.
+ * Destroyed versioned secret: Version was destroyed.
+ *
+ *
+ * @author Mark Paluch
+ * @since 2.1
+ * @see Version
+ * @see Metadata
+ */
+public class Versioned {
+
+ 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 Versioned 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 Versioned 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 Versioned 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 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.
+ *
+ * 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.
+ *
+ * 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);
+ }
+ }
+}
diff --git a/spring-vault-core/src/test/java/org/springframework/vault/core/VaultVersionedKeyValueTemplateIntegrationTests.java b/spring-vault-core/src/test/java/org/springframework/vault/core/VaultVersionedKeyValueTemplateIntegrationTests.java
new file mode 100644
index 00000000..2494415c
--- /dev/null
+++ b/spring-vault-core/src/test/java/org/springframework/vault/core/VaultVersionedKeyValueTemplateIntegrationTests.java
@@ -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 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 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 secret = Collections.singletonMap("key", "value");
+
+ String key = UUID.randomUUID().toString();
+
+ versionedOperations.write(key, Versioned.create(secret));
+
+ Versioned> 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 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> 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> 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> 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> 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();
+ }
+}
diff --git a/spring-vault-core/src/test/java/org/springframework/vault/util/PrepareVault.java b/spring-vault-core/src/test/java/org/springframework/vault/util/PrepareVault.java
index 7d814f2c..305a2ac5 100644
--- a/spring-vault-core/src/test/java/org/springframework/vault/util/PrepareVault.java
+++ b/spring-vault-core/src/test/java/org/springframework/vault/util/PrepareVault.java
@@ -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 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;
}
diff --git a/spring-vault-core/src/test/java/org/springframework/vault/util/VaultRule.java b/spring-vault-core/src/test/java/org/springframework/vault/util/VaultRule.java
index ff1fe516..24385df3 100644
--- a/spring-vault-core/src/test/java/org/springframework/vault/util/VaultRule.java
+++ b/spring-vault-core/src/test/java/org/springframework/vault/util/VaultRule.java
@@ -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();
diff --git a/src/main/asciidoc/new-features.adoc b/src/main/asciidoc/new-features.adoc
index ff8b8856..19444650 100644
--- a/src/main/asciidoc/new-features.adoc
+++ b/src/main/asciidoc/new-features.adoc
@@ -4,6 +4,7 @@
[[new-features.2-1-0]]
=== What's new in Spring Vault 2.1
* <> and <> authentication.
+* Template API support for versioned Key/Value backends.
[[new-features.2-0-0]]
=== What's new in Spring Vault 2.0