Add operations for interaction with kv metadata

Original pull request: gh-561.
See gh-432.
This commit is contained in:
zak905
2020-05-19 12:43:21 +02:00
committed by Mark Paluch
parent b9bb58472c
commit 034185bb17
8 changed files with 540 additions and 3 deletions

View File

@@ -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 <a href="https://www.vaultproject.io/api-docs/secret/kv/kv-v2#update-metadata">kv backend metadata api docs</a>
*/
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);
}

View File

@@ -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<String, Object> 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<String, Object> 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<Versioned.Metadata> 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)));
}
}

View File

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

View File

@@ -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<VaultResponseSupport<JsonNode>> {
}

View File

@@ -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 <a href="https://www.vaultproject.io/api-docs/secret/kv/kv-v2#update-metadata">Update Metadata</a>
*/
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 <a href="https://golang.org/pkg/time/#ParseDuration">Go duration format string</a>.
*/
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 <a href="https://golang.org/pkg/time/#ParseDuration">Go duration format string</a>.
*
* @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);
}
}
}

View File

@@ -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<Versioned.Metadata> versions;
VaultMetadataResponse(boolean casRequired, Instant createdTime, int currentVersion, String deleteVersionAfter,
int maxVersions, int oldestVersion, Instant updatedTime, List<Versioned.Metadata> 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 <a href="https://golang.org/pkg/time/#ParseDuration">Go duration format string</a>.
*/
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<Versioned.Metadata> 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<Versioned.Metadata> 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<Versioned.Metadata> versions) {
this.versions = versions;
return this;
}
public VaultMetadataResponse build() {
return new VaultMetadataResponse(casRequired, createdTime, currentVersion, deleteVersionAfter, maxVersions,
oldestVersion, updatedTime, versions);
}
}
}

View File

@@ -331,9 +331,6 @@ public class Versioned<T> {
* @return {@code this} {@link MetadataBuilder}.
*/
public MetadataBuilder deletedAt(Instant deletedAt) {
Assert.notNull(deletedAt, "Deleted at must not be null");
this.deletedAt = deletedAt;
return this;
}

View File

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