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