diff --git a/src/main/java/de/stklcode/jvault/connector/HTTPVaultConnector.java b/src/main/java/de/stklcode/jvault/connector/HTTPVaultConnector.java index 1d7cbad..0d07d0a 100644 --- a/src/main/java/de/stklcode/jvault/connector/HTTPVaultConnector.java +++ b/src/main/java/de/stklcode/jvault/connector/HTTPVaultConnector.java @@ -334,7 +334,7 @@ public class HTTPVaultConnector implements VaultConnector { payload.put("value", policy); payload.put("display_name", displayName); - /* Issue request anx expect code 204 with empty response */ + /* Issue request and expect code 204 with empty response */ request.postWithoutResponse(PATH_AUTH_APPID + "map/app-id/" + appID, payload, token); return true; @@ -347,7 +347,7 @@ public class HTTPVaultConnector implements VaultConnector { Map payload = new HashMap<>(); payload.put("value", appID); - /* Issue request anx expect code 204 with empty response */ + /* Issue request and expect code 204 with empty response */ request.postWithoutResponse(PATH_AUTH_APPID + "map/user-id/" + userID, payload, token); return true; @@ -357,7 +357,7 @@ public class HTTPVaultConnector implements VaultConnector { public final boolean createAppRole(final AppRole role) throws VaultConnectorException { requireAuth(); - /* Issue request anx expect code 204 with empty response */ + /* Issue request and expect code 204 with empty response */ request.postWithoutResponse(String.format(PATH_AUTH_APPROLE_ROLE, role.getName(), ""), role, token); /* Set custom ID if provided */ @@ -375,7 +375,7 @@ public class HTTPVaultConnector implements VaultConnector { public final boolean deleteAppRole(final String roleName) throws VaultConnectorException { requireAuth(); - /* Issue request anx expect code 204 with empty response */ + /* Issue request and expect code 204 with empty response */ request.deleteWithoutResponse(String.format(PATH_AUTH_APPROLE_ROLE, roleName, ""), token); return true; @@ -400,7 +400,7 @@ public class HTTPVaultConnector implements VaultConnector { Map payload = new HashMap<>(); payload.put("role_id", roleID); - /* Issue request anx expect code 204 with empty response */ + /* Issue request and expect code 204 with empty response */ request.postWithoutResponse(String.format(PATH_AUTH_APPROLE_ROLE, roleName, "/role-id"), payload, token); return true; @@ -446,7 +446,7 @@ public class HTTPVaultConnector implements VaultConnector { throws VaultConnectorException { requireAuth(); - /* Issue request anx expect code 204 with empty response */ + /* Issue request and expect code 204 with empty response */ request.postWithoutResponse( String.format(PATH_AUTH_APPROLE_ROLE, roleName, "/secret-id/destroy"), new AppRoleSecret(secretID), @@ -504,6 +504,28 @@ public class HTTPVaultConnector implements VaultConnector { return request.get(mount + PATH_METADATA + key, new HashMap<>(), token, MetadataResponse.class); } + @Override + public final SecretVersionResponse writeSecretData(final String mount, final String key, final Map data, final Integer cas) throws VaultConnectorException { + requireAuth(); + + if (key == null || key.isEmpty()) { + throw new InvalidRequestException("Secret path must not be empty."); + } + + // Add CAS value to options map if present. + Map options = new HashMap<>(); + if (cas != null) { + options.put("cas", cas); + } + + Map payload = new HashMap<>(); + payload.put("data", data); + payload.put("options", options); + + /* Issue request and parse metadata response */ + return request.post(mount + PATH_DATA + key, payload, token, SecretVersionResponse.class); + } + @Override public final List list(final String path) throws VaultConnectorException { requireAuth(); @@ -532,7 +554,7 @@ public class HTTPVaultConnector implements VaultConnector { payload = payloadMap; } - /* Issue request anx expect code 204 with empty response */ + /* Issue request and expect code 204 with empty response */ request.postWithoutResponse(key, payload, token); } @@ -540,7 +562,7 @@ public class HTTPVaultConnector implements VaultConnector { public final void delete(final String key) throws VaultConnectorException { requireAuth(); - /* Issue request anx expect code 204 with empty response */ + /* Issue request and expect code 204 with empty response */ request.deleteWithoutResponse(key, token); } @@ -586,7 +608,7 @@ public class HTTPVaultConnector implements VaultConnector { Map payload = new HashMap<>(); payload.put("versions", versions); - /* Issue request anx expect code 204 with empty response */ + /* Issue request and expect code 204 with empty response */ request.postWithoutResponse(mount + pathPart + key, payload, token); } @@ -594,7 +616,7 @@ public class HTTPVaultConnector implements VaultConnector { public final void revoke(final String leaseID) throws VaultConnectorException { requireAuth(); - /* Issue request anx expect code 204 with empty response */ + /* Issue request and expect code 204 with empty response */ request.putWithoutResponse(PATH_REVOKE + leaseID, new HashMap<>(), token); } diff --git a/src/main/java/de/stklcode/jvault/connector/VaultConnector.java b/src/main/java/de/stklcode/jvault/connector/VaultConnector.java index ce02b60..fd310c3 100644 --- a/src/main/java/de/stklcode/jvault/connector/VaultConnector.java +++ b/src/main/java/de/stklcode/jvault/connector/VaultConnector.java @@ -435,6 +435,49 @@ public interface VaultConnector extends AutoCloseable, Serializable { return readSecretVersion(mount, key, null); } + /** + * Write secret to Vault. + * Prefix "secret/" is automatically added to path. Only available for KV v2 secrets. + * + * @param key Secret identifier. + * @param data Secret content. Value must be be JSON serializable. + * @return Metadata for the created/updated secret. + * @throws VaultConnectorException on error + * @since 0.8 + */ + default SecretVersionResponse writeSecretData(final String key, final Map data) throws VaultConnectorException { + return writeSecretData(PATH_SECRET, key, data, null); + } + + /** + * Write secret to Vault. + * Prefix "secret/" is automatically added to path. Only available for KV v2 secrets. + * + * @param mount Secret store mountpoint (without leading or trailing slash). + * @param key Secret identifier + * @param data Secret content. Value must be be JSON serializable. + * @return Metadata for the created/updated secret. + * @throws VaultConnectorException on error + * @since 0.8 + */ + default SecretVersionResponse writeSecretData(final String mount, final String key, final Map data) throws VaultConnectorException { + return writeSecretData(mount, key, data, null); + } + + /** + * Write secret to Vault. + * Prefix "secret/" is automatically added to path. Only available for KV v2 secrets. + * + * @param mount Secret store mountpoint (without leading or trailing slash). + * @param key Secret identifier + * @param data Secret content. Value must be be JSON serializable. + * @param cas Use Check-And-Set operation, i.e. only allow writing if current version matches this value. + * @return Metadata for the created/updated secret. + * @throws VaultConnectorException on error + * @since 0.8 + */ + SecretVersionResponse writeSecretData(final String mount, final String key, final Map data, final Integer cas) throws VaultConnectorException; + /** * Retrieve secret data from Vault. * Prefix "secret/data" is automatically added to key. Only available for KV v2 secrets. @@ -456,7 +499,7 @@ public interface VaultConnector extends AutoCloseable, Serializable { * @param mount Secret store mountpoint (without leading or trailing slash). * @param key Secret identifier * @param version Version to read. If {@code null} or zero, the latest version will be returned. - * @return Secret response + * @return Secret responsef * @throws VaultConnectorException on error * @since 0.8 */ diff --git a/src/main/java/de/stklcode/jvault/connector/model/response/SecretVersionResponse.java b/src/main/java/de/stklcode/jvault/connector/model/response/SecretVersionResponse.java new file mode 100644 index 0000000..5c14be5 --- /dev/null +++ b/src/main/java/de/stklcode/jvault/connector/model/response/SecretVersionResponse.java @@ -0,0 +1,56 @@ +/* + * 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.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.Map; + +/** + * Vault response for a single secret version metatada, i.e. after update (KV v2). + * + * @author Stefan Kalscheuer + * @since 0.8 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class SecretVersionResponse extends VaultDataResponse { + + private VersionMetadata metadata; + + @Override + public final void setData(final Map data) throws InvalidResponseException { + ObjectMapper mapper = new ObjectMapper(); + try { + this.metadata = mapper.readValue(mapper.writeValueAsString(data), VersionMetadata.class); + } catch (IOException e) { + throw new InvalidResponseException("Failed deserializing response", e); + } + } + + /** + * Get the actual metadata. + * + * @return Metadata. + */ + public VersionMetadata getMetadata() { + return metadata; + } +} diff --git a/src/test/java/de/stklcode/jvault/connector/HTTPVaultConnectorTest.java b/src/test/java/de/stklcode/jvault/connector/HTTPVaultConnectorTest.java index 368cf40..1c20afe 100644 --- a/src/test/java/de/stklcode/jvault/connector/HTTPVaultConnectorTest.java +++ b/src/test/java/de/stklcode/jvault/connector/HTTPVaultConnectorTest.java @@ -32,9 +32,7 @@ import java.io.*; import java.lang.reflect.Field; import java.net.ServerSocket; import java.nio.file.Paths; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; +import java.util.*; import java.util.concurrent.TimeUnit; import static org.apache.commons.io.FileUtils.copyDirectory; @@ -82,6 +80,8 @@ public class HTTPVaultConnectorTest { private static final String SECRET2_KEY = "foo2"; private static final String SECRET2_VALUE1 = "bar2"; private static final String SECRET2_VALUE2 = "bar3"; + private static final String SECRET2_VALUE3 = "bar4"; + private static final String SECRET2_VALUE4 = "bar4"; private Process vaultProcess; private VaultConnector connector; @@ -766,6 +766,62 @@ public class HTTPVaultConnectorTest { } } + /** + * Test writing of secrets to KV v2 store. + */ + @Test + public void writeSecretV2Test() { + authUser(); + assumeTrue(connector.isAuthorized()); + + // First get the current version of the secret. + int currentVersion = -1; + try { + MetadataResponse res = connector.readSecretMetadata(MOUNT_KV2, SECRET2_KEY); + currentVersion = res.getMetadata().getCurrentVersion(); + } catch (VaultConnectorException e) { + fail("Reading secret metadata failed: " + e.getMessage()); + } + + // Now write (update) the data and verify the version. + try { + Map data = new HashMap<>(); + data.put("value", SECRET2_VALUE3); + SecretVersionResponse res = connector.writeSecretData(MOUNT_KV2, SECRET2_KEY, data); + assertThat("Version not updated after writing secret", res.getMetadata().getVersion(), is(currentVersion + 1)); + currentVersion = res.getMetadata().getVersion(); + } catch (VaultConnectorException e) { + fail("Writing secret to KV v2 store failed: " + e.getMessage()); + } + + // Verify the content. + try { + SecretResponse res = connector.readSecretData(MOUNT_KV2, SECRET2_KEY); + assertThat("Data not updated correctly", res.getValue(), is(SECRET2_VALUE3)); + } catch (VaultConnectorException e) { + fail("Reading secret from KV v2 store failed: " + e.getMessage()); + } + + // Now try with explicit CAS value (invalid). + try { + Map data = new HashMap<>(); + data.put("value", SECRET2_VALUE4); + SecretVersionResponse res = connector.writeSecretData(MOUNT_KV2, SECRET2_KEY, data, currentVersion - 1); + fail("Writing secret to KV v2 with invalid CAS value succeeded"); + } catch (VaultConnectorException e) { + assertThat("Unexpected exception", e, is(instanceOf(InvalidResponseException.class))); + } + + // And finally with a correct CAS value. + try { + Map data = new HashMap<>(); + data.put("value", SECRET2_VALUE4); + SecretVersionResponse res = connector.writeSecretData(MOUNT_KV2, SECRET2_KEY, data, currentVersion); + } catch (VaultConnectorException e) { + fail("Writing secret to KV v2 with correct CAS value failed: " + e.getMessage()); + } + } + /** * Test reading of secret metadata from KV v2 store. */ diff --git a/src/test/java/de/stklcode/jvault/connector/model/response/SecretVersionResponseTest.java b/src/test/java/de/stklcode/jvault/connector/model/response/SecretVersionResponseTest.java new file mode 100644 index 0000000..184745a --- /dev/null +++ b/src/test/java/de/stklcode/jvault/connector/model/response/SecretVersionResponseTest.java @@ -0,0 +1,66 @@ +/* + * 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 org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * JUnit Test for {@link SecretVersionResponse} model. + * + * @author Stefan Kalscheuer + * @since 0.8 + */ +public class SecretVersionResponseTest { + private static final String CREATION_TIME = "2018-03-22T02:24:06.945319214Z"; + private static final String DELETION_TIME = "2018-03-22T02:36:43.986212308Z"; + private static final Integer VERSION = 42; + + private static final String META_JSON = "{\n" + + " \"data\": {\n" + + " \"created_time\": \"" + CREATION_TIME + "\",\n" + + " \"deletion_time\": \"" + DELETION_TIME + "\",\n" + + " \"destroyed\": false,\n" + + " \"version\": " + VERSION + "\n" + + " }\n" + + "}"; + + /** + * Test creation from JSON value as returned by Vault (JSON example copied from Vault documentation). + */ + @Test + public void jsonRoundtrip() { + try { + SecretVersionResponse res = new ObjectMapper().readValue(META_JSON, SecretVersionResponse.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(CREATION_TIME)); + assertThat("Incorrect deletion time", res.getMetadata().getDeletionTimeString(), is(DELETION_TIME)); + assertThat("Incorrect destroyed state", res.getMetadata().isDestroyed(), is(false)); + assertThat("Incorrect version", res.getMetadata().getVersion(), is(VERSION)); + } catch (IOException e) { + fail("SecretVersionResponse deserialization failed: " + e.getMessage()); + } + } +}