From 04e92626bd38b7035bccbaf247495cd85391513a Mon Sep 17 00:00:00 2001 From: Stefan Kalscheuer Date: Mon, 19 Nov 2018 18:07:14 +0100 Subject: [PATCH 1/7] Add response models for KV v2 API Secret response is now split in data and metadata. Pure metadata queries return the new SecretMetadata class. --- .../model/response/MetadataResponse.java | 57 ++++++++ .../model/response/SecretResponse.java | 42 +++++- .../response/embedded/SecretMetadata.java | 127 ++++++++++++++++++ .../response/embedded/VersionMetadata.java | 108 +++++++++++++++ 4 files changed, 328 insertions(+), 6 deletions(-) create mode 100644 src/main/java/de/stklcode/jvault/connector/model/response/MetadataResponse.java create mode 100644 src/main/java/de/stklcode/jvault/connector/model/response/embedded/SecretMetadata.java create mode 100644 src/main/java/de/stklcode/jvault/connector/model/response/embedded/VersionMetadata.java diff --git a/src/main/java/de/stklcode/jvault/connector/model/response/MetadataResponse.java b/src/main/java/de/stklcode/jvault/connector/model/response/MetadataResponse.java new file mode 100644 index 0000000..9f00c07 --- /dev/null +++ b/src/main/java/de/stklcode/jvault/connector/model/response/MetadataResponse.java @@ -0,0 +1,57 @@ +/* + * Copyright 2016-2018 Stefan Kalscheuer + * + * 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 de.stklcode.jvault.connector.model.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import de.stklcode.jvault.connector.exception.InvalidResponseException; +import de.stklcode.jvault.connector.model.response.embedded.SecretMetadata; +import de.stklcode.jvault.connector.model.response.embedded.VersionMetadata; + +import java.io.IOException; +import java.util.Map; + +/** + * Vault response for secret metadata (KV v2). + * + * @author Stefan Kalscheuer + * @since 0.8 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class MetadataResponse extends VaultDataResponse { + + private SecretMetadata metadata; + + @Override + public final void setData(final Map data) throws InvalidResponseException { + ObjectMapper mapper = new ObjectMapper(); + try { + this.metadata = mapper.readValue(mapper.writeValueAsString(data), SecretMetadata.class); + } catch (IOException e) { + throw new InvalidResponseException("Failed deserializing response", e); + } + } + + /** + * Get the actual metadata. + * @return + */ + public SecretMetadata getMetadata() { + return metadata; + } +} diff --git a/src/main/java/de/stklcode/jvault/connector/model/response/SecretResponse.java b/src/main/java/de/stklcode/jvault/connector/model/response/SecretResponse.java index 8dd9180..b77bbdd 100644 --- a/src/main/java/de/stklcode/jvault/connector/model/response/SecretResponse.java +++ b/src/main/java/de/stklcode/jvault/connector/model/response/SecretResponse.java @@ -19,6 +19,7 @@ package de.stklcode.jvault.connector.model.response; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.databind.ObjectMapper; import de.stklcode.jvault.connector.exception.InvalidResponseException; +import de.stklcode.jvault.connector.model.response.embedded.VersionMetadata; import java.io.IOException; import java.util.HashMap; @@ -33,10 +34,25 @@ import java.util.Map; @JsonIgnoreProperties(ignoreUnknown = true) public class SecretResponse extends VaultDataResponse { private Map data; + private VersionMetadata metadata; @Override public final void setData(final Map data) throws InvalidResponseException { - this.data = data; + if (data.size() == 2 + && data.containsKey("data") && data.get("data") instanceof Map + && data.containsKey("metadata") && data.get("metadata") instanceof Map) { + ObjectMapper mapper = new ObjectMapper(); + try { + // This is apparently a KV v2 value. + this.data = (Map) data.get("data"); + this.metadata = mapper.readValue(mapper.writeValueAsString(data.get("metadata")), VersionMetadata.class); + } catch (ClassCastException | IOException e) { + throw new InvalidResponseException("Failed deserializing response", e); + } + } else { + // For KV v1 without metadata just store the data map. + this.data = data; + } } /** @@ -46,11 +62,22 @@ public class SecretResponse extends VaultDataResponse { * @since 0.4.0 */ public final Map getData() { - if (data == null) + if (data == null) { return new HashMap<>(); + } return data; } + /** + * Get secret metadata. This is only available for KV v2 secrets. + * + * @return Metadata of the secret. + * @since 0.8 + */ + public final VersionMetadata getMetadata() { + return metadata; + } + /** * Get a single value for given key. * @@ -59,8 +86,9 @@ public class SecretResponse extends VaultDataResponse { * @since 0.4.0 */ public final Object get(final String key) { - if (data == null) + if (data == null) { return null; + } return getData().get(key); } @@ -74,8 +102,9 @@ public class SecretResponse extends VaultDataResponse { @Deprecated public final String getValue() { Object value = get("value"); - if (value == null) + if (value == null) { return null; + } return value.toString(); } @@ -97,7 +126,7 @@ public class SecretResponse extends VaultDataResponse { /** * Get response parsed as JSON. * - * @param key the key + * @param key the key * @param type Class to parse response * @param Class to parse response * @return Parsed object or {@code null} if absent @@ -107,8 +136,9 @@ public class SecretResponse extends VaultDataResponse { public final T get(final String key, final Class type) throws InvalidResponseException { try { Object rawValue = get(key); - if (rawValue == null) + if (rawValue == null) { return null; + } return new ObjectMapper().readValue(rawValue.toString(), type); } catch (IOException e) { throw new InvalidResponseException("Unable to parse response payload: " + e.getMessage()); diff --git a/src/main/java/de/stklcode/jvault/connector/model/response/embedded/SecretMetadata.java b/src/main/java/de/stklcode/jvault/connector/model/response/embedded/SecretMetadata.java new file mode 100644 index 0000000..f1d2f02 --- /dev/null +++ b/src/main/java/de/stklcode/jvault/connector/model/response/embedded/SecretMetadata.java @@ -0,0 +1,127 @@ +/* + * Copyright 2016-2018 Stefan Kalscheuer + * + * 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 de.stklcode.jvault.connector.model.response.embedded; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Map; + +/** + * Embedded metadata for Key-Value v2 secrets. + * + * @author Stefan Kalscheuer + * @since 0.8 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class SecretMetadata { + private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSSX"); + + @JsonProperty("created_time") + private String createdTimeString; + + @JsonProperty("current_version") + private Integer currentVersion; + + @JsonProperty("max_version") + private Integer maxVersions; + + @JsonProperty("oldest_version") + private Integer oldestVersion; + + @JsonProperty("updated_time") + private String updatedTime; + + @JsonProperty("versions") + private Map versions; + + /** + * @return Time of secret creation as raw string representation. + */ + public String getCreatedTimeString() { + return createdTimeString; + } + + /** + * @return Time of secret creation. + */ + public ZonedDateTime getCreatedTime() { + if (createdTimeString != null && !createdTimeString.isEmpty()) { + try { + return ZonedDateTime.parse(createdTimeString, TIME_FORMAT); + } catch (DateTimeParseException e) { + // Ignore. + } + } + + return null; + } + + /** + * @return Current version number. + */ + public Integer getCurrentVersion() { + return currentVersion; + } + + /** + * @return Maximum number of versions. + */ + public Integer getMaxVersions() { + return maxVersions; + } + + /** + * @return Oldest available version number. + */ + public Integer getOldestVersion() { + return oldestVersion; + } + + /** + * @return Time of secret update as raw string representation. + */ + public String getUpdatedTimeString() { + return updatedTime; + } + + /** + * @return Time of secret update.. + */ + public ZonedDateTime getUpdatedTime() { + if (updatedTime != null && !updatedTime.isEmpty()) { + try { + return ZonedDateTime.parse(updatedTime, TIME_FORMAT); + } catch (DateTimeParseException e) { + // Ignore. + } + } + + return null; + } + + /** + * @return Version of the entry. + */ + public Map getVersions() { + return versions; + } + +} diff --git a/src/main/java/de/stklcode/jvault/connector/model/response/embedded/VersionMetadata.java b/src/main/java/de/stklcode/jvault/connector/model/response/embedded/VersionMetadata.java new file mode 100644 index 0000000..2759af1 --- /dev/null +++ b/src/main/java/de/stklcode/jvault/connector/model/response/embedded/VersionMetadata.java @@ -0,0 +1,108 @@ +/* + * Copyright 2016-2018 Stefan Kalscheuer + * + * 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 de.stklcode.jvault.connector.model.response.embedded; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.List; +import java.util.Map; + +/** + * Embedded metadata for a single Key-Value v2 version. + * + * @author Stefan Kalscheuer + * @since 0.8 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class VersionMetadata { + private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSSX"); + + @JsonProperty("created_time") + private String createdTimeString; + + @JsonProperty("deletion_time") + private String deletionTimeString; + + @JsonProperty("destroyed") + private boolean destroyed; + + @JsonProperty("version") + private Integer version; + + /** + * @return Time of secret creation as raw string representation. + */ + public String getCreatedTimeString() { + return createdTimeString; + } + + /** + * @return Time of secret creation. + */ + public ZonedDateTime getCreatedTime() { + if (createdTimeString != null && !createdTimeString.isEmpty()) { + try { + return ZonedDateTime.parse(createdTimeString, TIME_FORMAT); + } catch (DateTimeParseException e) { + // Ignore. + } + } + + return null; + } + + /** + * @return Time for secret deletion as raw string representation. + */ + public String getDeletionTimeString() { + return deletionTimeString; + } + + /** + * @return Time for secret deletion. + */ + public ZonedDateTime getDeletionTime() { + if (deletionTimeString != null && !deletionTimeString.isEmpty()) { + try { + return ZonedDateTime.parse(deletionTimeString, TIME_FORMAT); + } catch (DateTimeParseException e) { + // Ignore. + } + } + + return null; + } + + /** + * @return Whether the secret is destroyed. + */ + public boolean isDestroyed() { + return destroyed; + } + + /** + * @return Version of the entry. + */ + public Integer getVersion() { + return version; + } + +} From 068a87d9155f2791ee5dd50c553c7ccddec999c9 Mon Sep 17 00:00:00 2001 From: Stefan Kalscheuer Date: Mon, 19 Nov 2018 18:09:30 +0100 Subject: [PATCH 2/7] Add methods for reading KV v2 data and metadata --- .../jvault/connector/HTTPVaultConnector.java | 34 +++++++++++++++++++ .../jvault/connector/VaultConnector.java | 22 ++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/main/java/de/stklcode/jvault/connector/HTTPVaultConnector.java b/src/main/java/de/stklcode/jvault/connector/HTTPVaultConnector.java index 668d1e3..3282834 100644 --- a/src/main/java/de/stklcode/jvault/connector/HTTPVaultConnector.java +++ b/src/main/java/de/stklcode/jvault/connector/HTTPVaultConnector.java @@ -598,6 +598,40 @@ public class HTTPVaultConnector implements VaultConnector { } } + @Override + public final SecretResponse readSecretData(final String key) throws VaultConnectorException { + if (!isAuthorized()) { + throw new AuthorizationRequiredException(); + } + /* Request HTTP response and parse secret metadata */ + try { + String response = requestGet(PATH_SECRET + "data/" + key, new HashMap<>()); + return jsonMapper.readValue(response, SecretResponse.class); + } catch (IOException e) { + throw new InvalidResponseException(Error.PARSE_RESPONSE, e); + } catch (URISyntaxException ignored) { + /* this should never occur and may leak sensible information */ + throw new InvalidRequestException(Error.URI_FORMAT); + } + } + + @Override + public final MetadataResponse readSecretMetadata(final String key) throws VaultConnectorException { + if (!isAuthorized()) { + throw new AuthorizationRequiredException(); + } + /* Request HTTP response and parse secret metadata */ + try { + String response = requestGet(PATH_SECRET + "metadata/" + key, new HashMap<>()); + return jsonMapper.readValue(response, MetadataResponse.class); + } catch (IOException e) { + throw new InvalidResponseException(Error.PARSE_RESPONSE, e); + } catch (URISyntaxException ignored) { + /* this should never occur and may leak sensible information */ + throw new InvalidRequestException(Error.URI_FORMAT); + } + } + @Override public final List list(final String path) throws VaultConnectorException { if (!isAuthorized()) diff --git a/src/main/java/de/stklcode/jvault/connector/VaultConnector.java b/src/main/java/de/stklcode/jvault/connector/VaultConnector.java index c6b48ac..4f690df 100644 --- a/src/main/java/de/stklcode/jvault/connector/VaultConnector.java +++ b/src/main/java/de/stklcode/jvault/connector/VaultConnector.java @@ -408,6 +408,28 @@ public interface VaultConnector extends AutoCloseable, Serializable { return read(PATH_SECRET + "/" + key); } + /** + * Retrieve secret data Vault. + * Prefix "secret/data" is automatically added to key. Only available for KV v2 secrets. + * + * @param key Secret identifier + * @return Metadata response + * @throws VaultConnectorException on error + * @since 0.8 + */ + SecretResponse readSecretData(final String key) throws VaultConnectorException; + + /** + * Retrieve secret metadata from Vault. + * Prefix "secret/metadata" is automatically added to key. Only available for KV v2 secrets. + * + * @param key Secret identifier + * @return Metadata response + * @throws VaultConnectorException on error + * @since 0.8 + */ + MetadataResponse readSecretMetadata(final String key) throws VaultConnectorException; + /** * List available nodes from Vault. * From e3f2193df2828d97e86178bf70c395f853904101 Mon Sep 17 00:00:00 2001 From: Stefan Kalscheuer Date: Tue, 20 Nov 2018 11:26:02 +0100 Subject: [PATCH 3/7] Add capability to pass options map when writing to Vault This is required to create or update KV v2 secrets. The existing write method delegates to the new one with null-value for the options map. --- .../jvault/connector/HTTPVaultConnector.java | 22 +++++++++++++++---- .../jvault/connector/VaultConnector.java | 15 ++++++++++++- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/main/java/de/stklcode/jvault/connector/HTTPVaultConnector.java b/src/main/java/de/stklcode/jvault/connector/HTTPVaultConnector.java index 3282834..8e16e6e 100644 --- a/src/main/java/de/stklcode/jvault/connector/HTTPVaultConnector.java +++ b/src/main/java/de/stklcode/jvault/connector/HTTPVaultConnector.java @@ -650,15 +650,29 @@ public class HTTPVaultConnector implements VaultConnector { } @Override - public final void write(final String key, final Map data) throws VaultConnectorException { - if (!isAuthorized()) + public final void write(final String key, final Map data, final Map options) throws VaultConnectorException { + if (!isAuthorized()) { throw new AuthorizationRequiredException(); + } - if (key == null || key.isEmpty()) + if (key == null || key.isEmpty()) { throw new InvalidRequestException("Secret path must not be empty."); + } - if (!requestPost(key, data).isEmpty()) + // By default data is directly passed as payload. + Object payload = data; + + // If options are given, split payload in two parts. + if (options != null) { + Map payloadMap = new HashMap<>(); + payloadMap.put("data", data); + payloadMap.put("options", options); + payload = payloadMap; + } + + if (!requestPost(key, payload).isEmpty()) { throw new InvalidResponseException(Error.UNEXPECTED_RESPONSE); + } } @Override diff --git a/src/main/java/de/stklcode/jvault/connector/VaultConnector.java b/src/main/java/de/stklcode/jvault/connector/VaultConnector.java index 4f690df..5f61e2a 100644 --- a/src/main/java/de/stklcode/jvault/connector/VaultConnector.java +++ b/src/main/java/de/stklcode/jvault/connector/VaultConnector.java @@ -474,7 +474,20 @@ public interface VaultConnector extends AutoCloseable, Serializable { * @throws VaultConnectorException on error * @since 0.5.0 */ - void write(final String key, final Map data) throws VaultConnectorException; + default void write(final String key, final Map data) throws VaultConnectorException { + write(key, data, null); + } + + /** + * Write value to Vault. + * + * @param key Secret path + * @param data Secret content. Value must be be JSON serializable. + * @param options Secret options (optional). + * @throws VaultConnectorException on error + * @since 0.8 {@code options} parameter added + */ + void write(final String key, final Map data, final Map options) throws VaultConnectorException; /** * Write secret to Vault. From e41a61f33b017620dda1ab5b8222950cc0a805ec Mon Sep 17 00:00:00 2001 From: Stefan Kalscheuer Date: Tue, 20 Nov 2018 11:49:01 +0100 Subject: [PATCH 4/7] Add methods to delete, undelete and destroy KV v2 secret versions --- .../jvault/connector/HTTPVaultConnector.java | 59 ++++++++++++++++++- .../jvault/connector/VaultConnector.java | 53 +++++++++++++++++ 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/src/main/java/de/stklcode/jvault/connector/HTTPVaultConnector.java b/src/main/java/de/stklcode/jvault/connector/HTTPVaultConnector.java index 8e16e6e..c3e77e3 100644 --- a/src/main/java/de/stklcode/jvault/connector/HTTPVaultConnector.java +++ b/src/main/java/de/stklcode/jvault/connector/HTTPVaultConnector.java @@ -74,6 +74,11 @@ public class HTTPVaultConnector implements VaultConnector { private static final String PATH_AUTH_APPROLE_ROLE = "auth/approle/role/%s%s"; private static final String PATH_REVOKE = "sys/leases/revoke/"; private static final String PATH_HEALTH = "sys/health"; + private static final String PATH_DATA = "/data/"; + private static final String PATH_METADATA = "/metadata/"; + private static final String PATH_DELETE = "/delete/"; + private static final String PATH_UNDELETE = "/undelete/"; + private static final String PATH_DESTROY = "/destroy/"; private static final String HEADER_VAULT_TOKEN = "X-Vault-Token"; @@ -605,7 +610,7 @@ public class HTTPVaultConnector implements VaultConnector { } /* Request HTTP response and parse secret metadata */ try { - String response = requestGet(PATH_SECRET + "data/" + key, new HashMap<>()); + String response = requestGet(PATH_SECRET + PATH_DATA + key, new HashMap<>()); return jsonMapper.readValue(response, SecretResponse.class); } catch (IOException e) { throw new InvalidResponseException(Error.PARSE_RESPONSE, e); @@ -622,7 +627,7 @@ public class HTTPVaultConnector implements VaultConnector { } /* Request HTTP response and parse secret metadata */ try { - String response = requestGet(PATH_SECRET + "metadata/" + key, new HashMap<>()); + String response = requestGet(PATH_SECRET + PATH_METADATA + key, new HashMap<>()); return jsonMapper.readValue(response, MetadataResponse.class); } catch (IOException e) { throw new InvalidResponseException(Error.PARSE_RESPONSE, e); @@ -688,6 +693,56 @@ public class HTTPVaultConnector implements VaultConnector { throw new InvalidResponseException(Error.UNEXPECTED_RESPONSE); } + @Override + public final void deleteLatestSecretVersion(final String key) throws VaultConnectorException { + delete(PATH_SECRET + PATH_DATA + key); + } + + @Override + public final void deleteAllSecretVersions(final String key) throws VaultConnectorException { + delete(PATH_SECRET + PATH_METADATA + key); + } + + @Override + public final void deleteSecretVersions(final String key, final int... versions) throws VaultConnectorException { + handleSecretVersions(PATH_DELETE, key, versions); + } + + @Override + public final void undeleteSecretVersions(final String key, final int... versions) throws VaultConnectorException { + handleSecretVersions(PATH_UNDELETE, key, versions); + } + + @Override + public final void destroySecretVersions(final String key, final int... versions) throws VaultConnectorException { + handleSecretVersions(PATH_DESTROY, key, versions); + } + + /** + * Common method to bundle secret version operations. + * + * @param pathPart Path part to query. + * @param key Secret key. + * @param versions Versions to handle. + * @throws VaultConnectorException on error + * @since 0.8 + */ + private void handleSecretVersions(final String pathPart, final String key, final int... versions) throws VaultConnectorException { + if (!isAuthorized()) { + throw new AuthorizationRequiredException(); + } + + /* Request HTTP response and expect empty result */ + Map payload = new HashMap<>(); + payload.put("versions", versions); + String response = requestPost(PATH_SECRET + pathPart + key, payload); + + /* Response should be code 204 without content */ + if (!response.isEmpty()) { + throw new InvalidResponseException(Error.UNEXPECTED_RESPONSE); + } + } + @Override public final void revoke(final String leaseID) throws VaultConnectorException { if (!isAuthorized()) diff --git a/src/main/java/de/stklcode/jvault/connector/VaultConnector.java b/src/main/java/de/stklcode/jvault/connector/VaultConnector.java index 5f61e2a..50a23bf 100644 --- a/src/main/java/de/stklcode/jvault/connector/VaultConnector.java +++ b/src/main/java/de/stklcode/jvault/connector/VaultConnector.java @@ -539,6 +539,59 @@ public interface VaultConnector extends AutoCloseable, Serializable { delete(PATH_SECRET + "/" + key); } + /** + * Delete latest version of a secret from Vault. + * Only available for KV v2 stores. + * + * @param key Secret path. + * @throws VaultConnectorException on error + * @since 0.8 + */ + void deleteLatestSecretVersion(final String key) throws VaultConnectorException; + + /** + * Delete latest version of a secret from Vault. + * Only available for KV v2 stores. + * + * @param key Secret path. + * @throws VaultConnectorException on error + * @since 0.8 + */ + void deleteAllSecretVersions(final String key) throws VaultConnectorException; + + /** + * Delete secret versions from Vault. + * Only available for KV v2 stores. + * + * @param key Secret path. + * @param versions Versions of the secret to delete. + * @throws VaultConnectorException on error + * @since 0.8 + */ + void deleteSecretVersions(final String key, final int... versions) throws VaultConnectorException; + + /** + * Undelete (restore) secret versions from Vault. + * Only available for KV v2 stores. + * + * @param key Secret path. + * @param versions Versions of the secret to undelete. + * @throws VaultConnectorException on error + * @since 0.8 + */ + void undeleteSecretVersions(final String key, final int... versions) throws VaultConnectorException; + + /** + * Destroy secret versions from Vault. + * Only available for KV v2 stores. + * + * @param key Secret path. + * @param versions Versions of the secret to destroy. + * @throws VaultConnectorException on error + * @since 0.8 + */ + void destroySecretVersions(final String key, final int... versions) throws VaultConnectorException; + /** * Revoke given lease immediately. * From 493bed55f071d7c37e8ef642e0f0b49a026c5192 Mon Sep 17 00:00:00 2001 From: Stefan Kalscheuer Date: Tue, 20 Nov 2018 12:07:41 +0100 Subject: [PATCH 5/7] Add method to read specific secret version --- .../jvault/connector/HTTPVaultConnector.java | 8 ++++++-- .../jvault/connector/VaultConnector.java | 20 ++++++++++++++++--- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/main/java/de/stklcode/jvault/connector/HTTPVaultConnector.java b/src/main/java/de/stklcode/jvault/connector/HTTPVaultConnector.java index c3e77e3..722647f 100644 --- a/src/main/java/de/stklcode/jvault/connector/HTTPVaultConnector.java +++ b/src/main/java/de/stklcode/jvault/connector/HTTPVaultConnector.java @@ -604,13 +604,17 @@ public class HTTPVaultConnector implements VaultConnector { } @Override - public final SecretResponse readSecretData(final String key) throws VaultConnectorException { + public final SecretResponse readSecretVersion(final String key, final Integer version) throws VaultConnectorException { if (!isAuthorized()) { throw new AuthorizationRequiredException(); } /* Request HTTP response and parse secret metadata */ try { - String response = requestGet(PATH_SECRET + PATH_DATA + key, new HashMap<>()); + Map args = new HashMap<>(); + if (version != null) { + args.put("version", version.toString()); + } + String response = requestGet(PATH_SECRET + PATH_DATA + key, args); return jsonMapper.readValue(response, SecretResponse.class); } catch (IOException e) { throw new InvalidResponseException(Error.PARSE_RESPONSE, e); diff --git a/src/main/java/de/stklcode/jvault/connector/VaultConnector.java b/src/main/java/de/stklcode/jvault/connector/VaultConnector.java index 50a23bf..5fac27a 100644 --- a/src/main/java/de/stklcode/jvault/connector/VaultConnector.java +++ b/src/main/java/de/stklcode/jvault/connector/VaultConnector.java @@ -409,15 +409,29 @@ public interface VaultConnector extends AutoCloseable, Serializable { } /** - * Retrieve secret data Vault. + * Retrieve the latest secret data for specific version from Vault. * Prefix "secret/data" is automatically added to key. Only available for KV v2 secrets. * * @param key Secret identifier - * @return Metadata response + * @return Secret response * @throws VaultConnectorException on error * @since 0.8 */ - SecretResponse readSecretData(final String key) throws VaultConnectorException; + default SecretResponse readSecretData(final String key) throws VaultConnectorException { + return readSecretVersion(key, null); + } + + /** + * Retrieve secret data from Vault. + * Prefix "secret/data" is automatically added to key. Only available for KV v2 secrets. + * + * @param key Secret identifier + * @param version Version to read. If {@code null} or zero, the latest version will be returned. + * @return Secret response + * @throws VaultConnectorException on error + * @since 0.8 + */ + SecretResponse readSecretVersion(final String key, final Integer version) throws VaultConnectorException; /** * Retrieve secret metadata from Vault. From c2bd54ca225a0a940ad30fc49525a98f4ec5e559 Mon Sep 17 00:00:00 2001 From: Stefan Kalscheuer Date: Tue, 20 Nov 2018 12:11:37 +0100 Subject: [PATCH 6/7] Extend unit test to new KV v2 methods [skip ci] This test does not yet work without changes, because KV v2 is mounted on non-standard path and this is not yet supported (see #25). --- .../connector/HTTPVaultConnectorTest.java | 123 ++++++++++++++++++ src/test/resources/data_dir/sys/policy/_user | 2 +- 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/src/test/java/de/stklcode/jvault/connector/HTTPVaultConnectorTest.java b/src/test/java/de/stklcode/jvault/connector/HTTPVaultConnectorTest.java index 00115bf..5a36f3b 100644 --- a/src/test/java/de/stklcode/jvault/connector/HTTPVaultConnectorTest.java +++ b/src/test/java/de/stklcode/jvault/connector/HTTPVaultConnectorTest.java @@ -77,6 +77,12 @@ public class HTTPVaultConnectorTest { private static final String SECRET_KEY_JSON = "json"; private static final String SECRET_KEY_COMPLEX = "complex"; + // KV v2 secret with 2 versions. + private static final String PATH_KV2 = "kv/"; + private static final String SECRET2_KEY = "foo2"; + private static final String SECRET2_VALUE1 = "bar2"; + private static final String SECRET2_VALUE2 = "bar3"; + private Process vaultProcess; private VaultConnector connector; @@ -730,6 +736,123 @@ public class HTTPVaultConnectorTest { } } + /** + * Test reading of secrets from KV v2 store. + */ + @Test + public void readSecretV2Test() { + authUser(); + assumeTrue(connector.isAuthorized()); + + // Try to read accessible path with known value. + SecretResponse res; + try { + res = connector.readSecretData(SECRET2_KEY); + assertThat("Metadata not populated for KV v2 secret", res.getMetadata(), is(notNullValue())); + assertThat("Unexpected secret version", res.getMetadata().getVersion(), is(2)); + assertThat("Known secret returned invalid value.", res.getValue(), is(SECRET2_VALUE2)); + } catch (VaultConnectorException e) { + fail("Valid secret path could not be read: " + e.getMessage()); + } + + // Try to read different version of same secret. + try { + res = connector.readSecretVersion(SECRET2_KEY, 1); + assertThat("Unexpected secret version", res.getMetadata().getVersion(), is(1)); + assertThat("Known secret returned invalid value.", res.getValue(), is(SECRET2_VALUE1)); + } catch (VaultConnectorException e) { + fail("Valid secret version could not be read: " + e.getMessage()); + } + } + + /** + * Test reading of secret metadata from KV v2 store. + */ + @Test + public void readSecretMetadataTest() { + authUser(); + assumeTrue(connector.isAuthorized()); + + // Try to read accessible path with known value. + try { + MetadataResponse res = connector.readSecretMetadata(SECRET2_KEY); + assertThat("Metadata not populated for KV v2 secret", res.getMetadata(), is(notNullValue())); + assertThat("Unexpected secret version", res.getMetadata().getCurrentVersion(), is(2)); + assertThat("Unexpected number of secret versions", res.getMetadata().getVersions().size(), is(2)); + assertThat("Creation date should be present", res.getMetadata().getCreatedTime(), is(notNullValue())); + assertThat("Update date should be present", res.getMetadata().getUpdatedTime(), is(notNullValue())); + } catch (VaultConnectorException e) { + fail("Valid secret path could not be read: " + e.getMessage()); + } + } + + /** + * Test deleting specific secret versions from KV v2 store. + */ + @Test + public void handleSecretVersionsTest() { + authUser(); + assumeTrue(connector.isAuthorized()); + + // Try to delete inexisting versions. + MetadataResponse meta; + try { + connector.deleteSecretVersions(SECRET2_KEY, 5, 42); + meta = connector.readSecretMetadata(SECRET2_KEY); + } catch (VaultConnectorException e) { + fail("Revealed non-existence of secret versions"); + } + + // Now delete existing version and verify. + try { + connector.deleteSecretVersions(SECRET2_KEY, 1); + meta = connector.readSecretMetadata(SECRET2_KEY); + assertThat("Expected deletion time for secret 1", meta.getMetadata().getVersions().get(1).getDeletionTime(), is(notNullValue())); + } catch (VaultConnectorException e) { + fail("Deleting existing version failed"); + } + + // Undelete the just deleted version. + try { + connector.undeleteSecretVersions(SECRET2_KEY, 1); + meta = connector.readSecretMetadata(SECRET2_KEY); + assertThat("Expected deletion time for secret 1 to be reset", meta.getMetadata().getVersions().get(1).getDeletionTime(), is(nullValue())); + } catch (VaultConnectorException e) { + fail("Undeleting existing version failed"); + } + + // Now destroy it. + try { + connector.destroySecretVersions(SECRET2_KEY, 1); + meta = connector.readSecretMetadata(SECRET2_KEY); + assertThat("Expected secret 1 to be marked destroyed", meta.getMetadata().getVersions().get(1).isDestroyed(), is(true)); + } catch (VaultConnectorException e) { + fail("Destroying existing version failed"); + } + + // Delete latest version. + try { + connector.deleteLatestSecretVersion(SECRET2_KEY); + meta = connector.readSecretMetadata(SECRET2_KEY); + assertThat("Expected secret 2 to be deleted", meta.getMetadata().getVersions().get(2).getDeletionTime(), is(notNullValue())); + } catch (VaultConnectorException e) { + fail("Deleting latest version failed"); + } + + // Delete all versions. + try { + connector.deleteAllSecretVersions(SECRET2_KEY); + } catch (VaultConnectorException e) { + fail("Deleting latest version failed: " + e.getMessage()); + } + try { + connector.readSecretMetadata(SECRET2_KEY); + fail("Reading metadata of deleted secret should not succeed"); + } catch (Exception e) { + assertThat(e, is(instanceOf(InvalidResponseException.class))); + } + } + /** * Test listing secrets. */ diff --git a/src/test/resources/data_dir/sys/policy/_user b/src/test/resources/data_dir/sys/policy/_user index 0397bb6..979c9f0 100644 --- a/src/test/resources/data_dir/sys/policy/_user +++ b/src/test/resources/data_dir/sys/policy/_user @@ -1 +1 @@ -{"Value":"AAAAAQLCgjqndhozT2JTFStJ8yqLGSlBsqtol6u7Rfl1oX1fIfYevraxwpCFORxRx3v77RDNX0xzXkJ1taJ8LVx/9m4GEp5XPh2AsB0nPy0Sfr0s1jqR4Ev8d+z6X01099F6mNfUAnx3gmGuubXZC28Sp3dLBf9Xy080mD0yd+GqlHp2WXnW0aWQKchWwArkTHRxR1722tkbXmr8E72aRz+5eyHapnWXnKhppznQPkGaOY2y9nxhoOM04FVqHA=="} +{"Value":"AAAAAQKh97jibnzLjYI1Er2PQ1+U2voHGqTowY24utgPth4i3fCwUbJ15i8JY/4DiqjjyNTfDni4deNiUNIsn2PzWsPsEoiDS83rVPDibov4TKjHFomxew1oEqXOTmqYlKZcaeR5FiDWMLA8jPwkkP/6mknRp3AnjUUGNvw5EJVFREBJh+qw52CCRztcPJ7lYyUuOfBn0OHLowTV9QlQ6zmyBSu+HY2xrFtucboe1l3VNUdAbq3qk139CGzn9chzUi6xBtUf2JLiuuyFYOcPMMtFu+tzq09WH07T4OuUp5l7ytI+9dTnyCtciHDZNlflZyaIssNoBFThiJ0GeeZK3wYSspb+wG86xS7MoGJZhAnlvXjvCebKY4hekX9O"} From d1876c88aa0689510936cd77ce1c1ae183a1efef Mon Sep 17 00:00:00 2001 From: Stefan Kalscheuer Date: Tue, 20 Nov 2018 13:50:28 +0100 Subject: [PATCH 7/7] Add unit tests for secret metadata models and fixed JSON property name --- .../model/response/MetadataResponse.java | 3 +- .../response/embedded/SecretMetadata.java | 2 +- .../model/response/MetadataResponseTest.java | 100 ++++++++++++++++++ .../model/response/SecretResponseTest.java | 89 ++++++++++++++-- 4 files changed, 184 insertions(+), 10 deletions(-) create mode 100644 src/test/java/de/stklcode/jvault/connector/model/response/MetadataResponseTest.java diff --git a/src/main/java/de/stklcode/jvault/connector/model/response/MetadataResponse.java b/src/main/java/de/stklcode/jvault/connector/model/response/MetadataResponse.java index 9f00c07..24fc801 100644 --- a/src/main/java/de/stklcode/jvault/connector/model/response/MetadataResponse.java +++ b/src/main/java/de/stklcode/jvault/connector/model/response/MetadataResponse.java @@ -49,7 +49,8 @@ public class MetadataResponse extends VaultDataResponse { /** * Get the actual metadata. - * @return + * + * @return Metadata. */ public SecretMetadata getMetadata() { return metadata; diff --git a/src/main/java/de/stklcode/jvault/connector/model/response/embedded/SecretMetadata.java b/src/main/java/de/stklcode/jvault/connector/model/response/embedded/SecretMetadata.java index f1d2f02..0f2e518 100644 --- a/src/main/java/de/stklcode/jvault/connector/model/response/embedded/SecretMetadata.java +++ b/src/main/java/de/stklcode/jvault/connector/model/response/embedded/SecretMetadata.java @@ -40,7 +40,7 @@ public final class SecretMetadata { @JsonProperty("current_version") private Integer currentVersion; - @JsonProperty("max_version") + @JsonProperty("max_versions") private Integer maxVersions; @JsonProperty("oldest_version") diff --git a/src/test/java/de/stklcode/jvault/connector/model/response/MetadataResponseTest.java b/src/test/java/de/stklcode/jvault/connector/model/response/MetadataResponseTest.java new file mode 100644 index 0000000..f0a59a9 --- /dev/null +++ b/src/test/java/de/stklcode/jvault/connector/model/response/MetadataResponseTest.java @@ -0,0 +1,100 @@ +/* + * Copyright 2016-2018 Stefan Kalscheuer + * + * 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 de.stklcode.jvault.connector.model.response; + +import com.fasterxml.jackson.databind.ObjectMapper; +import de.stklcode.jvault.connector.exception.InvalidResponseException; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * JUnit Test for {@link MetadataResponse} model. + * + * @author Stefan Kalscheuer + * @since 0.8 + */ +public class MetadataResponseTest { + private static final String V1_TIME = "2018-03-22T02:24:06.945319214Z"; + private static final String V3_TIME = "2018-03-22T02:36:43.986212308Z"; + private static final String V2_TIME = "2018-03-22T02:36:33.954880664Z"; + private static final Integer CURRENT_VERSION = 3; + private static final Integer MAX_VERSIONS = 0; + private static final Integer OLDEST_VERSION = 1; + + private static final String META_JSON = "{\n" + + " \"data\": {\n" + + " \"created_time\": \"" + V1_TIME + "\",\n" + + " \"current_version\": " + CURRENT_VERSION + ",\n" + + " \"max_versions\": " + MAX_VERSIONS + ",\n" + + " \"oldest_version\": " + OLDEST_VERSION + ",\n" + + " \"updated_time\": \"" + V3_TIME + "\",\n" + + " \"versions\": {\n" + + " \"1\": {\n" + + " \"created_time\": \"" + V1_TIME + "\",\n" + + " \"deletion_time\": \"" + V2_TIME + "\",\n" + + " \"destroyed\": true\n" + + " },\n" + + " \"2\": {\n" + + " \"created_time\": \"" + V2_TIME + "\",\n" + + " \"deletion_time\": \"\",\n" + + " \"destroyed\": false\n" + + " },\n" + + " \"3\": {\n" + + " \"created_time\": \"" + V3_TIME + "\",\n" + + " \"deletion_time\": \"\",\n" + + " \"destroyed\": false\n" + + " }\n" + + " }\n" + + " }\n" + + "}"; + + /** + * Test creation from JSON value as returned by Vault (JSON example copied from Vault documentation). + */ + @Test + public void jsonRoundtrip() { + try { + MetadataResponse res = new ObjectMapper().readValue(META_JSON, MetadataResponse.class); + assertThat("Parsed response is NULL", res, is(notNullValue())); + assertThat("Parsed metadatra is NULL", res.getMetadata(), is(notNullValue())); + assertThat("Incorrect created time", res.getMetadata().getCreatedTimeString(), is(V1_TIME)); + assertThat("Parting created time failed", res.getMetadata().getCreatedTime(), is(notNullValue())); + assertThat("Incorrect current version", res.getMetadata().getCurrentVersion(), is(CURRENT_VERSION)); + assertThat("Incorrect max versions", res.getMetadata().getMaxVersions(), is(MAX_VERSIONS)); + assertThat("Incorrect oldest version", res.getMetadata().getOldestVersion(), is(OLDEST_VERSION)); + assertThat("Incorrect updated time", res.getMetadata().getUpdatedTimeString(), is(V3_TIME)); + assertThat("Parting updated time failed", res.getMetadata().getUpdatedTime(), is(notNullValue())); + assertThat("Incorrect number of versions", res.getMetadata().getVersions().size(), is(3)); + assertThat("Incorrect version 1 delete time", res.getMetadata().getVersions().get(1).getDeletionTimeString(), is(V2_TIME)); + assertThat("Parsion version delete time failed", res.getMetadata().getVersions().get(1).getDeletionTime(), is(notNullValue())); + assertThat("Incorrect version 1 destroyed state", res.getMetadata().getVersions().get(1).isDestroyed(), is(true)); + assertThat("Incorrect version 2 created time", res.getMetadata().getVersions().get(2).getCreatedTimeString(), is(V2_TIME)); + assertThat("Parsion version created failed", res.getMetadata().getVersions().get(2).getCreatedTime(), is(notNullValue())); + assertThat("Incorrect version 3 destroyed state", res.getMetadata().getVersions().get(3).isDestroyed(), is(false)); + + } catch (IOException e) { + fail("MetadataResoponse deserialization failed: " + e.getMessage()); + } + } +} diff --git a/src/test/java/de/stklcode/jvault/connector/model/response/SecretResponseTest.java b/src/test/java/de/stklcode/jvault/connector/model/response/SecretResponseTest.java index 310aa97..f23c59c 100644 --- a/src/test/java/de/stklcode/jvault/connector/model/response/SecretResponseTest.java +++ b/src/test/java/de/stklcode/jvault/connector/model/response/SecretResponseTest.java @@ -53,6 +53,8 @@ public class SecretResponseTest { private static final String SECRET_DATA_V1 = "yes"; private static final String SECRET_DATA_K2 = "value"; private static final String SECRET_DATA_V2 = "world"; + private static final String SECRET_META_CREATED = "2018-03-22T02:24:06.945319214Z"; + private static final String SECRET_META_DELETED = "2018-03-23T03:25:07.056420325Z"; private static final List SECRET_WARNINGS = null; private static final String SECRET_JSON = "{\n" + " \"request_id\": \"" + SECRET_REQUEST_ID + "\",\n" + @@ -65,6 +67,44 @@ public class SecretResponseTest { " },\n" + " \"warnings\": " + SECRET_WARNINGS + "\n" + "}"; + private static final String SECRET_JSON_V2 = "{\n" + + " \"request_id\": \"" + SECRET_REQUEST_ID + "\",\n" + + " \"lease_id\": \"" + SECRET_LEASE_ID + "\",\n" + + " \"lease_duration\": " + SECRET_LEASE_DURATION + ",\n" + + " \"renewable\": " + SECRET_RENEWABLE + ",\n" + + " \"data\": {\n" + + " \"data\": {\n" + + " \"" + SECRET_DATA_K1 + "\": \"" + SECRET_DATA_V1 + "\",\n" + + " \"" + SECRET_DATA_K2 + "\": \"" + SECRET_DATA_V2 + "\"\n" + + " },\n" + + " \"metadata\": {\n" + + " \"created_time\": \"" + SECRET_META_CREATED + "\",\n" + + " \"deletion_time\": \"\",\n" + + " \"destroyed\": false,\n" + + " \"version\": 1\n" + + " }\n" + + " },\n" + + " \"warnings\": " + SECRET_WARNINGS + "\n" + + "}"; + private static final String SECRET_JSON_V2_2 = "{\n" + + " \"request_id\": \"" + SECRET_REQUEST_ID + "\",\n" + + " \"lease_id\": \"" + SECRET_LEASE_ID + "\",\n" + + " \"lease_duration\": " + SECRET_LEASE_DURATION + ",\n" + + " \"renewable\": " + SECRET_RENEWABLE + ",\n" + + " \"data\": {\n" + + " \"data\": {\n" + + " \"" + SECRET_DATA_K1 + "\": \"" + SECRET_DATA_V1 + "\",\n" + + " \"" + SECRET_DATA_K2 + "\": \"" + SECRET_DATA_V2 + "\"\n" + + " },\n" + + " \"metadata\": {\n" + + " \"created_time\": \"" + SECRET_META_CREATED + "\",\n" + + " \"deletion_time\": \"" + SECRET_META_DELETED + "\",\n" + + " \"destroyed\": true,\n" + + " \"version\": 2\n" + + " }\n" + + " },\n" + + " \"warnings\": " + SECRET_WARNINGS + "\n" + + "}"; static { @@ -118,16 +158,49 @@ public class SecretResponseTest { @Test public void jsonRoundtrip() { try { - SecretResponse res = new ObjectMapper().readValue(SECRET_JSON, SecretResponse.class); - assertThat("Parsed response is NULL", res, is(notNullValue())); - assertThat("Incorrect lease ID", res.getLeaseId(), is(SECRET_LEASE_ID)); - assertThat("Incorrect lease duration", res.getLeaseDuration(), is(SECRET_LEASE_DURATION)); - assertThat("Incorrect renewable status", res.isRenewable(), is(SECRET_RENEWABLE)); - assertThat("Incorrect warnings", res.getWarnings(), is(SECRET_WARNINGS)); - assertThat("Response does not contain correct data", res.get(SECRET_DATA_K1), is(SECRET_DATA_V1)); - assertThat("Response does not contain correct data", res.get(SECRET_DATA_K2), is(SECRET_DATA_V2)); + assertSecretData(new ObjectMapper().readValue(SECRET_JSON, SecretResponse.class)); + } catch (IOException e) { + fail("SecretResponse deserialization failed: " + e.getMessage()); + } + + // KV v2 secret. + try { + SecretResponse res = new ObjectMapper().readValue(SECRET_JSON_V2, SecretResponse.class); + assertSecretData(res); + assertThat("SecretResponse does not contain metadata", res.getMetadata(), is(notNullValue())); + assertThat("Incorrect creation date string", res.getMetadata().getCreatedTimeString(), is(SECRET_META_CREATED)); + assertThat("Creation date parsing failed", res.getMetadata().getCreatedTime(), is(notNullValue())); + assertThat("Incorrect deletion date string", res.getMetadata().getDeletionTimeString(), is(emptyString())); + assertThat("Incorrect deletion date", res.getMetadata().getDeletionTime(), is(nullValue())); + assertThat("Secret destroyed when not expected", res.getMetadata().isDestroyed(), is(false)); + assertThat("Incorrect secret version", res.getMetadata().getVersion(), is(1)); + } catch (IOException e) { + fail("SecretResponse deserialization failed: " + e.getMessage()); + } + + // Deleted KV v2 secret. + try { + SecretResponse res = new ObjectMapper().readValue(SECRET_JSON_V2_2, SecretResponse.class); + assertSecretData(res); + assertThat("SecretResponse does not contain metadata", res.getMetadata(), is(notNullValue())); + assertThat("Incorrect creation date string", res.getMetadata().getCreatedTimeString(), is(SECRET_META_CREATED)); + assertThat("Creation date parsing failed", res.getMetadata().getCreatedTime(), is(notNullValue())); + assertThat("Incorrect deletion date string", res.getMetadata().getDeletionTimeString(), is(SECRET_META_DELETED)); + assertThat("Incorrect deletion date", res.getMetadata().getDeletionTime(), is(notNullValue())); + assertThat("Secret destroyed when not expected", res.getMetadata().isDestroyed(), is(true)); + assertThat("Incorrect secret version", res.getMetadata().getVersion(), is(2)); } catch (IOException e) { fail("SecretResponse deserialization failed: " + e.getMessage()); } } + + private void assertSecretData(SecretResponse res) { + assertThat("Parsed response is NULL", res, is(notNullValue())); + assertThat("Incorrect lease ID", res.getLeaseId(), is(SECRET_LEASE_ID)); + assertThat("Incorrect lease duration", res.getLeaseDuration(), is(SECRET_LEASE_DURATION)); + assertThat("Incorrect renewable status", res.isRenewable(), is(SECRET_RENEWABLE)); + assertThat("Incorrect warnings", res.getWarnings(), is(SECRET_WARNINGS)); + assertThat("Response does not contain correct data", res.get(SECRET_DATA_K1), is(SECRET_DATA_V1)); + assertThat("Response does not contain correct data", res.get(SECRET_DATA_K2), is(SECRET_DATA_V2)); + } }