From 8230bcc702dc69d91c844cf1562a68ca12d1e24b Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Wed, 11 Apr 2018 14:52:39 +0200 Subject: [PATCH] Add support for versioned Key-Value secrets. We now provide support for versioned secrets through the Template API. VaultVersionedKeyValueOperations versioned = vaultOperations.opsForVersionedKeyValue("versioned"); Map secrets = Collections.singletonMap("key", "value"); Metadata writtenVersion = versioned.write("my-secret", secrets); Versioned> 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. --- .../vault/core/VaultOperations.java | 9 + .../vault/core/VaultTemplate.java | 5 + .../VaultVersionedKeyValueOperations.java | 114 +++++ .../core/VaultVersionedKeyValueTemplate.java | 235 +++++++++ .../vault/support/Versioned.java | 453 ++++++++++++++++++ ...ionedKeyValueTemplateIntegrationTests.java | 212 ++++++++ .../vault/util/PrepareVault.java | 27 +- .../springframework/vault/util/VaultRule.java | 1 + src/main/asciidoc/new-features.adoc | 1 + 9 files changed, 1056 insertions(+), 1 deletion(-) create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/core/VaultVersionedKeyValueOperations.java create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/core/VaultVersionedKeyValueTemplate.java create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/support/Versioned.java create mode 100644 spring-vault-core/src/test/java/org/springframework/vault/core/VaultVersionedKeyValueTemplateIntegrationTests.java 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: + * + *
    + *
  1. Creation of an unversioned secret: Secret is not yet persisted.
  2. + *
  3. Versioned secret: Secret is persisted.
  4. + *
  5. Superseded versioned secret: A newer secret version is stored.
  6. + *
  7. Deleted versioned secret: Version was deleted. Can be undeleted.
  8. + *
  9. Destroyed versioned secret: Version was destroyed.
  10. + *
+ * + * @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