diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValueMetadataOperations.java b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValueMetadataOperations.java new file mode 100644 index 00000000..195c5558 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValueMetadataOperations.java @@ -0,0 +1,34 @@ +package org.springframework.vault.core; + +import org.springframework.vault.support.VaultMetadataRequest; +import org.springframework.vault.support.VaultMetadataResponse; + +/** + * Interface that specifies kv metadata related operations + * + * @author Zakaria Amine + * @see kv backend metadata api docs + */ +public interface VaultKeyValueMetadataOperations { + + /** + * permanently deletes the key metadata and all version data for the specified key. All version history will be removed. + * @param path the secret path, must not be null or empty + */ + void delete(String path); + + /** + * retrieves the metadata and versions for the secret at the specified path. + * @param path the secret path, must not be null or empty + * @return {@link VaultMetadataResponse} + */ + VaultMetadataResponse get(String path); + + /** + * Updates the secret metadata, or creates new metadata if not present. + * + * @param path the secret path, must not be null or empty + * @param body {@link VaultMetadataRequest} + */ + void put(String path, VaultMetadataRequest body); +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValueMetadataTemplate.java b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValueMetadataTemplate.java new file mode 100644 index 00000000..9028e865 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultKeyValueMetadataTemplate.java @@ -0,0 +1,107 @@ +package org.springframework.vault.core; + +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Spliterator; +import java.util.Spliterators; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +import org.springframework.util.Assert; +import org.springframework.vault.client.VaultResponses; +import org.springframework.vault.support.VaultMetadataRequest; +import org.springframework.vault.support.VaultMetadataResponse; +import org.springframework.vault.support.Versioned; +import org.springframework.web.client.HttpStatusCodeException; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class VaultKeyValueMetadataTemplate implements VaultKeyValueMetadataOperations { + + private final VaultOperations vaultOperations; + + private final String basePath; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + public VaultKeyValueMetadataTemplate(VaultOperations vaultOperations, String basePath) { + Assert.notNull(vaultOperations, "VaultOperations must not be null"); + this.vaultOperations = vaultOperations; + this.basePath = basePath; + } + + @Override + public void delete(String path) { + Assert.hasText(path, "Path must not be empty"); + vaultOperations.delete("/"+this.basePath+"/metadata/" + path); + } + + @Override + public VaultMetadataResponse get(String path) { + Assert.hasText(path, "Path must not be empty"); + Map metadataResponse = + vaultOperations.read("/" + this.basePath + "/metadata/" + path, Map.class).getData(); + + return fromMap(metadataResponse); + } + + @Override + public void put(String path, VaultMetadataRequest body) { + Assert.hasText(path, "Path must not be empty"); + Assert.notNull(body, "Body must not be null"); + vaultOperations.doWithSession(restOperations -> { + try { + restOperations.put("/"+this.basePath+"/metadata/" + path, body); + return null; + } + catch (HttpStatusCodeException e) { + throw VaultResponses.buildException(e, path); + } + }); + } + + private VaultMetadataResponse fromMap(Map metadataResponse) { + return VaultMetadataResponse.builder() + .casRequired(Boolean.parseBoolean(String.valueOf(metadataResponse.get("cas_required")))) + .createdTime(toInstant(metadataResponse.get("created_time"))) + .currentVersion(Integer.parseInt(String.valueOf(metadataResponse.get("current_version")))) + .deleteVersionAfter(String.valueOf(metadataResponse.get("delete_version_after"))) + .maxVersions(Integer.parseInt(String.valueOf(metadataResponse.get("max_versions")))) + .oldestVersion(Integer.parseInt(String.valueOf(metadataResponse.get("oldest_version")))) + .updatedTime(toInstant(metadataResponse.get("updated_time"))) + .versions(buildVersions(metadataResponse.get("versions"))) + .build(); + } + + private static List buildVersions(Object versions) { + try { + JsonNode kvVersions = OBJECT_MAPPER.readTree(OBJECT_MAPPER.writeValueAsString(versions)); + + return StreamSupport.stream(Spliterators.spliteratorUnknownSize(kvVersions.fieldNames(), Spliterator.DISTINCT), false) + .map(version -> fromJsonNode(kvVersions.get(version), version)) + .collect(Collectors.toList()); + } + catch (Exception e) { + e.printStackTrace(); + return new ArrayList<>(); + } + } + + private static Versioned.Metadata fromJsonNode(JsonNode versionData, String version) { + Instant createdTime = toInstant(versionData.get("created_time").asText()); + Instant deletionTime = Objects.equals(versionData.get("deletion_time").asText(), "") ? null : toInstant(versionData.get("deletion_time").asText()); + boolean destroyed = versionData.get("destroyed").asBoolean(); + Versioned.Version kvVersion = Versioned.Version.from(Integer.parseInt(version)); + + return Versioned.Metadata.builder().createdAt(createdTime).deletedAt(deletionTime).destroyed(destroyed).version(kvVersion).build(); + } + + private static Instant toInstant(Object date) { + return Instant.from(DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(String.valueOf(date))); + } +} 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 index 838cef70..26687f25 100644 --- 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 @@ -120,4 +120,11 @@ public interface VaultVersionedKeyValueOperations extends VaultKeyValueOperation * @param versionsToDelete must not be {@literal null} or empty. */ void destroy(String path, Version... versionsToDelete); + + /** + * Return {@link VaultKeyValueMetadataOperations} + * + * @return the operations interface to interact with the Vault Key/Value metadata backend + */ + VaultKeyValueMetadataOperations opsForKeyValueMetadata(); } 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 index eaf75274..66b3651f 100644 --- 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 @@ -53,6 +53,8 @@ public class VaultVersionedKeyValueTemplate extends VaultKeyValue2Accessor private final VaultOperations vaultOperations; + private final String path; + /** * Create a new {@link VaultVersionedKeyValueTemplate} given {@link VaultOperations} * and the mount {@code path}. @@ -65,6 +67,7 @@ public class VaultVersionedKeyValueTemplate extends VaultKeyValue2Accessor super(vaultOperations, path); this.vaultOperations = vaultOperations; + this.path = path; } @Nullable @@ -241,6 +244,11 @@ public class VaultVersionedKeyValueTemplate extends VaultKeyValue2Accessor Collections.singletonMap("versions", versions)); } + @Override + public VaultKeyValueMetadataOperations opsForKeyValueMetadata() { + return new VaultKeyValueMetadataTemplate(vaultOperations, path); + } + private static class VersionedResponse extends VaultResponseSupport> { } diff --git a/spring-vault-core/src/main/java/org/springframework/vault/support/VaultMetadataRequest.java b/spring-vault-core/src/main/java/org/springframework/vault/support/VaultMetadataRequest.java new file mode 100644 index 00000000..da1d8b5d --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/support/VaultMetadataRequest.java @@ -0,0 +1,101 @@ +package org.springframework.vault.support; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Value object to bind Vault HTTP kv metadata update API requests. + * + * @author Zakaria Amine + * @see Update Metadata + */ +public class VaultMetadataRequest { + + @JsonProperty("max_versions") + private int maxVersions; + + @JsonProperty("cas_required") + private boolean casRequired; + + @JsonProperty("delete_version_after") + private String deleteVersionAfter; + + VaultMetadataRequest(int maxVersions, boolean casRequired, String deleteVersionAfter) { + this.maxVersions = maxVersions; + this.casRequired = casRequired; + this.deleteVersionAfter = deleteVersionAfter; + } + + public static VaultMetadataRequestBuilder builder() { + return new VaultMetadataRequestBuilder(); + } + + /** + * @return The number of versions to keep per key. + */ + public int getMaxVersions() { + return maxVersions; + } + + /** + * @return If true all keys will require the cas parameter to be set on all write requests. + */ + public boolean isCasRequired() { + return casRequired; + } + + /** + * @return the deletion_time for all new versions written to this key. Accepts Go duration format string. + */ + public String getDeleteVersionAfter() { + return deleteVersionAfter; + } + + public static class VaultMetadataRequestBuilder { + + private int maxVersions; + private boolean casRequired; + private String deleteVersionAfter; + + /** + * + * sets the number of versions to keep per key. + * + * @param maxVersions + * @return {@link VaultMetadataRequest} + */ + public VaultMetadataRequestBuilder maxVersions(int maxVersions) { + this.maxVersions = maxVersions; + return this; + } + + /** + * + * sets the cas_required parameter. If true all keys will require the cas parameter to be set on all write requests. + * + * @param casRequired + * @return {@link VaultMetadataRequest} + */ + public VaultMetadataRequestBuilder casRequired(boolean casRequired) { + this.casRequired = casRequired; + return this; + } + + /** + * sets the deletion_time for all new versions written to this key. Accepts Go duration format string. + * + * @param deleteVersionAfter + * @return {@link VaultMetadataRequest} + */ + public VaultMetadataRequestBuilder deleteVersionAfter(String deleteVersionAfter) { + this.deleteVersionAfter = deleteVersionAfter; + return this; + } + + /** + * @return a new {@link VaultMetadataRequest} + */ + public VaultMetadataRequest build() { + return new VaultMetadataRequest(maxVersions, casRequired, deleteVersionAfter); + } + } +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/support/VaultMetadataResponse.java b/spring-vault-core/src/main/java/org/springframework/vault/support/VaultMetadataResponse.java new file mode 100644 index 00000000..5d80c233 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/support/VaultMetadataResponse.java @@ -0,0 +1,180 @@ +package org.springframework.vault.support; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +/** + * Value object to bind Vault HTTP kv read metadata API responses. + * + * @author Zakaria Amine + */ +public class VaultMetadataResponse { + + private boolean casRequired; + + private Instant createdTime; + + private int currentVersion; + + private String deleteVersionAfter; + + private int maxVersions; + + private int oldestVersion; + + private Instant updatedTime; + + private List versions; + + VaultMetadataResponse(boolean casRequired, Instant createdTime, int currentVersion, String deleteVersionAfter, + int maxVersions, int oldestVersion, Instant updatedTime, List versions) { + this.casRequired = casRequired; + this.createdTime = createdTime; + this.currentVersion = currentVersion; + this.deleteVersionAfter = deleteVersionAfter; + this.maxVersions = maxVersions; + this.oldestVersion = oldestVersion; + this.updatedTime = updatedTime; + this.versions = versions; + } + + public static VaultMetadataResponseBuilder builder() {return new VaultMetadataResponseBuilder();} + + /** + * + * @return + */ + public boolean isCasRequired() { + return casRequired; + } + + /** + * + * @return the metadata creation time + */ + public Instant getCreatedTime() { + return createdTime; + } + + /** + * + * @return the active secret version + */ + public int getCurrentVersion() { + return currentVersion; + } + + /** + * + * @return the duration after which a secret is to be deleted. 0 for unlimited duration. follows Go duration format string. + */ + public String getDeleteVersionAfter() { + return deleteVersionAfter; + } + + /** + * + * @return max secret versions accepted by this key + */ + public int getMaxVersions() { + return maxVersions; + } + + /** + * + * @return oldest key version + */ + public int getOldestVersion() { + return oldestVersion; + } + + /** + * + * @return the metadata update time + */ + public Instant getUpdatedTime() { + return updatedTime; + } + + /** + * + * Follows the following format. + * + * "versions": { + * "1": { + * "created_time": "2020-05-18T12:23:09.895587932Z", + * "deletion_time": "2020-05-18T12:31:00.66257744Z", + * "destroyed": false + * }, + * "2": { + * "created_time": "2020-05-18T12:23:10.122081788Z", + * "deletion_time": "", + * "destroyed": false + * } + * } + * + * @return the key versions and their details + */ + public List getVersions() { + return versions; + } + + + public static class VaultMetadataResponseBuilder { + + private boolean casRequired; + private Instant createdTime; + private int currentVersion; + private String deleteVersionAfter; + private int maxVersions; + private int oldestVersion; + private Instant updatedTime; + private List versions; + + public VaultMetadataResponseBuilder casRequired(boolean casRequired) { + this.casRequired = casRequired; + return this; + } + + public VaultMetadataResponseBuilder createdTime(Instant createdTime) { + this.createdTime = createdTime; + return this; + } + + public VaultMetadataResponseBuilder currentVersion(int currentVersion) { + this.currentVersion = currentVersion; + return this; + } + + public VaultMetadataResponseBuilder deleteVersionAfter(String deleteVersionAfter) { + this.deleteVersionAfter = deleteVersionAfter; + return this; + } + + public VaultMetadataResponseBuilder maxVersions(int maxVersions) { + this.maxVersions = maxVersions; + return this; + } + + public VaultMetadataResponseBuilder oldestVersion(int oldestVersion) { + this.oldestVersion = oldestVersion; + return this; + } + + public VaultMetadataResponseBuilder updatedTime(Instant updatedTime) { + this.updatedTime = updatedTime; + return this; + } + + public VaultMetadataResponseBuilder versions(List versions) { + this.versions = versions; + return this; + } + + public VaultMetadataResponse build() { + return new VaultMetadataResponse(casRequired, createdTime, currentVersion, deleteVersionAfter, maxVersions, + oldestVersion, updatedTime, versions); + } + } +} 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 index bb6e28c3..316da1c9 100644 --- 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 @@ -331,9 +331,6 @@ public class Versioned { * @return {@code this} {@link MetadataBuilder}. */ public MetadataBuilder deletedAt(Instant deletedAt) { - - Assert.notNull(deletedAt, "Deleted at must not be null"); - this.deletedAt = deletedAt; return this; } diff --git a/spring-vault-core/src/test/java/org/springframework/vault/core/VaultKeyValueMetadataTemplateIntegrationTests.java b/spring-vault-core/src/test/java/org/springframework/vault/core/VaultKeyValueMetadataTemplateIntegrationTests.java new file mode 100644 index 00000000..49b15f79 --- /dev/null +++ b/spring-vault-core/src/test/java/org/springframework/vault/core/VaultKeyValueMetadataTemplateIntegrationTests.java @@ -0,0 +1,103 @@ +package org.springframework.vault.core; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.vault.support.VaultMetadataRequest; +import org.springframework.vault.support.VaultMetadataResponse; +import org.springframework.vault.support.VaultResponse; +import org.springframework.vault.support.Versioned; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = VaultIntegrationTestConfiguration.class) +public class VaultKeyValueMetadataTemplateIntegrationTests extends AbstractVaultKeyValueTemplateIntegrationTests { + + private static final String SECRET_NAME = "test"; + private VaultKeyValueMetadataOperations vaultKeyValueMetadataOperations; + + VaultKeyValueMetadataTemplateIntegrationTests() { + super("versioned", VaultKeyValueOperationsSupport.KeyValueBackend.versioned()); + } + + @BeforeEach + void setup() { + Map secret = new HashMap<>(); + secret.put("key", "value"); + + kvOperations.put(SECRET_NAME, secret); + vaultKeyValueMetadataOperations = vaultOperations.opsForVersionedKeyValue("versioned").opsForKeyValueMetadata(); + } + + @Test + public void shouldReadMetadataForANewKVEntry() { + + VaultMetadataResponse metadataResponse = vaultKeyValueMetadataOperations.get(SECRET_NAME); + + assertThat(metadataResponse.getMaxVersions()).isEqualTo(0); + assertThat(metadataResponse.getCurrentVersion()).isEqualTo(1); + assertThat(metadataResponse.getVersions()).hasSize(1); + assertThat(metadataResponse.isCasRequired()).isFalse(); + assertThat(metadataResponse.getDeleteVersionAfter()).isEqualTo("0s"); + assertThat(metadataResponse.getCreatedTime().isBefore(Instant.now())).isTrue(); + assertThat(metadataResponse.getUpdatedTime().isBefore(Instant.now())).isTrue(); + + Versioned.Metadata version1 = metadataResponse.getVersions().get(0); + + assertThat(version1.getDeletedAt()).isNull(); + assertThat(version1.getCreatedAt()).isBefore(Instant.now()); + assertThat(version1.getVersion().getVersion()).isEqualTo(1); + } + + @Test + public void shouldUpdateMetadataVersions() { + Map secret = new HashMap<>(); + secret.put("newkey", "newvalue"); + kvOperations.put(SECRET_NAME, secret); + + VaultMetadataResponse metadataResponse = vaultKeyValueMetadataOperations.get(SECRET_NAME); + + assertThat(metadataResponse.getCurrentVersion()).isEqualTo(2); + assertThat(metadataResponse.getVersions()).hasSize(2); + } + + @Test + public void shouldUpdateKVMetadata() { + VaultMetadataRequest request = VaultMetadataRequest.builder().casRequired(true).deleteVersionAfter("6h30m0s").maxVersions(20).build(); + + vaultKeyValueMetadataOperations.put(SECRET_NAME, request); + + VaultMetadataResponse metadataResponseAfterUpdate = vaultKeyValueMetadataOperations.get(SECRET_NAME); + + assertThat(metadataResponseAfterUpdate.isCasRequired()).isEqualTo(request.isCasRequired()); + assertThat(metadataResponseAfterUpdate.getMaxVersions()).isEqualTo(request.getMaxVersions()); + assertThat(metadataResponseAfterUpdate.getDeleteVersionAfter()).isEqualTo(request.getDeleteVersionAfter()); + } + + @Test + public void shouldDeleteMetadata() { + kvOperations.delete(SECRET_NAME); + VaultMetadataResponse metadataResponse = vaultKeyValueMetadataOperations.get(SECRET_NAME); + Versioned.Metadata version1 = metadataResponse.getVersions().get(0); + assertThat(version1.getDeletedAt()).isBefore(Instant.now()); + + vaultKeyValueMetadataOperations.delete(SECRET_NAME); + + VaultResponse response = kvOperations.get(SECRET_NAME); + + assertThat(response).isNull(); + } + + @AfterEach + void cleanup() { + vaultKeyValueMetadataOperations.delete(SECRET_NAME); + } +}