diff --git a/src/main/java/de/stklcode/jvault/connector/HTTPVaultConnector.java b/src/main/java/de/stklcode/jvault/connector/HTTPVaultConnector.java index b69853e..9d37749 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"; @@ -620,6 +625,44 @@ public class HTTPVaultConnector implements VaultConnector { } } + @Override + 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 { + 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); + } 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 + PATH_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()) { @@ -639,7 +682,7 @@ public class HTTPVaultConnector implements VaultConnector { } @Override - public final void write(final String key, final Map data) throws VaultConnectorException { + public final void write(final String key, final Map data, final Map options) throws VaultConnectorException { if (!isAuthorized()) { throw new AuthorizationRequiredException(); } @@ -648,7 +691,18 @@ public class HTTPVaultConnector implements VaultConnector { 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); } } @@ -668,6 +722,56 @@ public class HTTPVaultConnector implements VaultConnector { } } + @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 48277d5..ea99e6b 100644 --- a/src/main/java/de/stklcode/jvault/connector/VaultConnector.java +++ b/src/main/java/de/stklcode/jvault/connector/VaultConnector.java @@ -408,6 +408,42 @@ public interface VaultConnector extends AutoCloseable, Serializable { return read(PATH_SECRET + "/" + key); } + /** + * 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 Secret response + * @throws VaultConnectorException on error + * @since 0.8 + */ + 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. + * 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. * @@ -452,7 +488,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. @@ -504,6 +553,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. * 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..24fc801 --- /dev/null +++ b/src/main/java/de/stklcode/jvault/connector/model/response/MetadataResponse.java @@ -0,0 +1,58 @@ +/* + * 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 Metadata. + */ + 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 ab3c8a5..7b9878a 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; + } } /** @@ -52,6 +68,16 @@ public class SecretResponse extends VaultDataResponse { 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. * @@ -100,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 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..0f2e518 --- /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_versions") + 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; + } + +} diff --git a/src/test/java/de/stklcode/jvault/connector/HTTPVaultConnectorTest.java b/src/test/java/de/stklcode/jvault/connector/HTTPVaultConnectorTest.java index 4ed63f2..c22da52 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; @@ -731,6 +737,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/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 9e25975..cc66efe 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)); + } } 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"}