diff --git a/src/main/java/com/bettercloud/vault/api/Logical.java b/src/main/java/com/bettercloud/vault/api/Logical.java index e52bbf07..d8444c4e 100644 --- a/src/main/java/com/bettercloud/vault/api/Logical.java +++ b/src/main/java/com/bettercloud/vault/api/Logical.java @@ -29,6 +29,8 @@ */ public class Logical { + private static final WriteOptions EMPTY_WRITE_OPTIONS = new WriteOptions().build(); + private final VaultConfig config; private String nameSpace; @@ -219,42 +221,31 @@ public LogicalResponse read(final String path, Boolean shouldRetry, final Intege */ public LogicalResponse write(final String path, final Map nameValuePairs) throws VaultException { if (engineVersionForSecretPath(path).equals(2)) { - return write(path, nameValuePairs, logicalOperations.writeV2); - } else return write(path, nameValuePairs, logicalOperations.writeV1); + return write(path, nameValuePairs, logicalOperations.writeV2, EMPTY_WRITE_OPTIONS); + } else return write(path, nameValuePairs, logicalOperations.writeV1, EMPTY_WRITE_OPTIONS); + } + + public LogicalResponse write(final String path, final Map nameValuePairs, + final WriteOptions writeOptions) throws VaultException { + if (this.engineVersionForSecretPath(path) != 2) { + throw new VaultException("Write options are only supported in KV Engine version 2."); + } + return write(path, nameValuePairs, logicalOperations.writeV2, writeOptions); } private LogicalResponse write(final String path, final Map nameValuePairs, - final logicalOperations operation) throws VaultException { + final logicalOperations operation, + final WriteOptions writeOptions) throws VaultException { int retryCount = 0; while (true) { try { - JsonObject requestJson = Json.object(); - if (nameValuePairs != null) { - for (final Map.Entry pair : nameValuePairs.entrySet()) { - final Object value = pair.getValue(); - if (value == null) { - requestJson = requestJson.add(pair.getKey(), (String) null); - } else if (value instanceof Boolean) { - requestJson = requestJson.add(pair.getKey(), (Boolean) pair.getValue()); - } else if (value instanceof Integer) { - requestJson = requestJson.add(pair.getKey(), (Integer) pair.getValue()); - } else if (value instanceof Long) { - requestJson = requestJson.add(pair.getKey(), (Long) pair.getValue()); - } else if (value instanceof Float) { - requestJson = requestJson.add(pair.getKey(), (Float) pair.getValue()); - } else if (value instanceof Double) { - requestJson = requestJson.add(pair.getKey(), (Double) pair.getValue()); - } else if (value instanceof JsonValue) { - requestJson = requestJson.add(pair.getKey(), (JsonValue) pair.getValue()); - } else { - requestJson = requestJson.add(pair.getKey(), pair.getValue().toString()); - } - } - } + JsonObject dataJson = buildJsonFromMap(nameValuePairs); + JsonObject optionsJson = writeOptions.isEmpty() ? null : buildJsonFromMap(writeOptions.getOptionsMap()); + // Make an HTTP request to Vault final RestResponse restResponse = new Rest()//NOPMD .url(config.getAddress() + "/v1/" + adjustPathForReadOrWrite(path, config.getPrefixPathDepth(), operation)) - .body(jsonObjectToWriteFromEngineVersion(operation, requestJson).toString().getBytes(StandardCharsets.UTF_8)) + .body(jsonObjectToWriteFromEngineVersion(operation, dataJson, optionsJson).toString().getBytes(StandardCharsets.UTF_8)) .header("X-Vault-Token", config.getToken()) .header("X-Vault-Namespace", this.nameSpace) .connectTimeoutSeconds(config.getOpenTimeout()) @@ -644,4 +635,32 @@ private Integer engineVersionForSecretPath(final String secretPath) { public Integer getEngineVersionForSecretPath(final String path) { return this.engineVersionForSecretPath(path); } + + private JsonObject buildJsonFromMap(Map nameValuePairs) { + JsonObject jsonObject = Json.object(); + if (nameValuePairs != null) { + for (final Map.Entry pair : nameValuePairs.entrySet()) { + final Object value = pair.getValue(); + if (value == null) { + jsonObject = jsonObject.add(pair.getKey(), (String) null); + } else if (value instanceof Boolean) { + jsonObject = jsonObject.add(pair.getKey(), (Boolean) pair.getValue()); + } else if (value instanceof Integer) { + jsonObject = jsonObject.add(pair.getKey(), (Integer) pair.getValue()); + } else if (value instanceof Long) { + jsonObject = jsonObject.add(pair.getKey(), (Long) pair.getValue()); + } else if (value instanceof Float) { + jsonObject = jsonObject.add(pair.getKey(), (Float) pair.getValue()); + } else if (value instanceof Double) { + jsonObject = jsonObject.add(pair.getKey(), (Double) pair.getValue()); + } else if (value instanceof JsonValue) { + jsonObject = jsonObject.add(pair.getKey(), (JsonValue) pair.getValue()); + } else { + jsonObject = jsonObject.add(pair.getKey(), pair.getValue().toString()); + } + } + } + return jsonObject; + } + } diff --git a/src/main/java/com/bettercloud/vault/api/LogicalUtilities.java b/src/main/java/com/bettercloud/vault/api/LogicalUtilities.java index 1fbd7126..7429f014 100644 --- a/src/main/java/com/bettercloud/vault/api/LogicalUtilities.java +++ b/src/main/java/com/bettercloud/vault/api/LogicalUtilities.java @@ -191,20 +191,27 @@ public static String adjustPathForVersionDestroy(final String path, final int pr } /** - * In version two, when writing a secret, the JSONObject must be nested with "data" as the key. + * In version two, when writing a secret, the JSONObject must be nested with "data" as the key + * and an "options" key may be optionally provided * * @param operation The operation being performed, e.g. writeV1, or writeV2. * @param jsonObject The jsonObject that is going to be written. + * @param optionsJsonObject The options jsonObject that is going to be written or null if none * @return This jsonObject mutated for the operation. */ public static JsonObject jsonObjectToWriteFromEngineVersion( - final Logical.logicalOperations operation, final JsonObject jsonObject) { + final Logical.logicalOperations operation, final JsonObject jsonObject, + final JsonObject optionsJsonObject) { if (operation.equals(Logical.logicalOperations.writeV2)) { final JsonObject wrappedJson = new JsonObject(); wrappedJson.add("data", jsonObject); + if (null != optionsJsonObject) { + wrappedJson.add("options", optionsJsonObject); + } return wrappedJson; } else { return jsonObject; } } + } diff --git a/src/main/java/com/bettercloud/vault/api/WriteOptions.java b/src/main/java/com/bettercloud/vault/api/WriteOptions.java new file mode 100644 index 00000000..b9dbbc61 --- /dev/null +++ b/src/main/java/com/bettercloud/vault/api/WriteOptions.java @@ -0,0 +1,34 @@ +package com.bettercloud.vault.api; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public class WriteOptions { + + public static final String CHECK_AND_SET_KEY = "cas"; + + private final Map options = new HashMap<>(); + + public WriteOptions checkAndSet(Long version) { + return setOption(CHECK_AND_SET_KEY, version); + } + + public WriteOptions setOption(String name, Object value) { + options.put(name, value); + return this; + } + + public WriteOptions build() { + return this; + } + + public Map getOptionsMap() { + return Collections.unmodifiableMap(options); + } + + public boolean isEmpty() { + return options.isEmpty(); + } + +} diff --git a/src/main/java/com/bettercloud/vault/response/DataMetadata.java b/src/main/java/com/bettercloud/vault/response/DataMetadata.java new file mode 100644 index 00000000..8ecc674b --- /dev/null +++ b/src/main/java/com/bettercloud/vault/response/DataMetadata.java @@ -0,0 +1,29 @@ +package com.bettercloud.vault.response; + +import java.util.Collections; +import java.util.Map; + +public class DataMetadata { + + public static final String VERSION_KEY = "version"; + + private final Map metadataMap; + + public DataMetadata(Map metadataMap) { + this.metadataMap = metadataMap; + } + + public Long getVersion() { + final String versionString = metadataMap.get(VERSION_KEY); + return (null != versionString) ? Long.valueOf(versionString) : null; + } + + public Map getMetadataMap() { + return Collections.unmodifiableMap(metadataMap); + } + + public boolean isEmpty() { + return metadataMap.isEmpty(); + } + +} diff --git a/src/main/java/com/bettercloud/vault/response/LogicalResponse.java b/src/main/java/com/bettercloud/vault/response/LogicalResponse.java index 93f3fb8d..a4ecf402 100644 --- a/src/main/java/com/bettercloud/vault/response/LogicalResponse.java +++ b/src/main/java/com/bettercloud/vault/response/LogicalResponse.java @@ -24,6 +24,7 @@ public class LogicalResponse extends VaultResponse { private String leaseId; private Boolean renewable; private Long leaseDuration; + private final Map dataMetadata = new HashMap<>(); /** * @param restResponse The raw HTTP response from Vault. @@ -60,6 +61,10 @@ public Long getLeaseDuration() { return leaseDuration; } + public DataMetadata getDataMetadata() { + return new DataMetadata(dataMetadata); + } + private void parseMetadataFields() { try { final String jsonString = new String(getRestResponse().getBody(), StandardCharsets.UTF_8); @@ -78,19 +83,16 @@ private void parseResponseData(final Logical.logicalOperations operation) { JsonObject jsonObject = Json.parse(jsonString).asObject(); if (operation.equals(Logical.logicalOperations.readV2)) { jsonObject = jsonObject.get("data").asObject(); + + JsonValue metadataValue = jsonObject.get("metadata"); + if (null != metadataValue) { + parseJsonIntoMap(metadataValue.asObject(), dataMetadata); + } } data = new HashMap<>(); dataObject = jsonObject.get("data").asObject(); - for (final JsonObject.Member member : dataObject) { - final JsonValue jsonValue = member.getValue(); - if (jsonValue == null || jsonValue.isNull()) { - continue; - } else if (jsonValue.isString()) { - data.put(member.getName(), jsonValue.asString()); - } else { - data.put(member.getName(), jsonValue.toString()); - } - } + parseJsonIntoMap(dataObject, data); + // For list operations convert the array of keys to a list of values if (operation.equals(Logical.logicalOperations.listV1) || operation.equals(Logical.logicalOperations.listV2)) { if ( @@ -108,4 +110,18 @@ private void parseResponseData(final Logical.logicalOperations operation) { } catch (Exception ignored) { } } + + private void parseJsonIntoMap(JsonObject jsonObject, Map map) { + for (final JsonObject.Member member : jsonObject) { + final JsonValue jsonValue = member.getValue(); + if (jsonValue == null || jsonValue.isNull()) { + continue; + } else if (jsonValue.isString()) { + map.put(member.getName(), jsonValue.asString()); + } else { + map.put(member.getName(), jsonValue.toString()); + } + } + } + } diff --git a/src/test-integration/java/com/bettercloud/vault/api/AuthBackendTokenTests.java b/src/test-integration/java/com/bettercloud/vault/api/AuthBackendTokenTests.java index a5f9e337..2a3ab548 100644 --- a/src/test-integration/java/com/bettercloud/vault/api/AuthBackendTokenTests.java +++ b/src/test-integration/java/com/bettercloud/vault/api/AuthBackendTokenTests.java @@ -9,7 +9,6 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Arrays; -import java.util.List; import java.util.UUID; import org.junit.BeforeClass; import org.junit.ClassRule; diff --git a/src/test-integration/java/com/bettercloud/vault/api/LogicalTests.java b/src/test-integration/java/com/bettercloud/vault/api/LogicalTests.java index a5a7d473..04052c8f 100644 --- a/src/test-integration/java/com/bettercloud/vault/api/LogicalTests.java +++ b/src/test-integration/java/com/bettercloud/vault/api/LogicalTests.java @@ -4,6 +4,7 @@ import com.bettercloud.vault.VaultConfig; import com.bettercloud.vault.VaultException; import com.bettercloud.vault.response.AuthResponse; +import com.bettercloud.vault.response.DataMetadata; import com.bettercloud.vault.response.LogicalResponse; import com.bettercloud.vault.util.VaultContainer; import java.io.IOException; @@ -18,6 +19,7 @@ import org.junit.Test; import org.junit.rules.ExpectedException; +import static junit.framework.Assert.assertNotNull; import static junit.framework.TestCase.assertEquals; import static junit.framework.TestCase.assertFalse; import static junit.framework.TestCase.assertTrue; @@ -64,6 +66,99 @@ public void testWriteAndRead() throws VaultException { assertEquals(value, valueRead); } + @Test + public void testWriteWithCheckAndSetAndReadWithDataMetadata() throws VaultException { + final String secretPath = "secret/checkAndSet"; + + final Vault vault = container.getRootVault(); + + final String value = "firstWorld"; + final Map testData = new HashMap<>(); + testData.put("value", value); + + WriteOptions writeOptions = new WriteOptions().checkAndSet(0L).build(); + vault.logical().write(secretPath, testData, writeOptions); + + final LogicalResponse readResponse = vault.logical().read(secretPath); + final String valueRead = readResponse.getData().get("value"); + assertEquals(value, valueRead); + final DataMetadata dataMetadata = readResponse.getDataMetadata(); + assertNotNull(dataMetadata); + assertFalse(dataMetadata.isEmpty()); + final Long secretVersion = dataMetadata.getVersion(); + assertNotNull(secretVersion); + assertEquals(1L, secretVersion.longValue()); + } + + @Test + public void testWriteWithCheckAndSetWrongCreateVersion() throws VaultException { + final String secretPath = "secret/checkAndSetWrongVersion"; + + final Vault vault = container.getRootVault(); + + final String value = "firstWorld"; + final Map testData = new HashMap<>(); + testData.put("value", value); + + WriteOptions writeOptions = new WriteOptions().checkAndSet(1L).build(); + LogicalResponse writeResponse = vault.logical().write(secretPath, testData, writeOptions); + assertEquals(400, writeResponse.getRestResponse().getStatus()); + } + + @Test + public void tesUpdateWithCheckAndSetAndReadWithDataMetadata() throws VaultException { + final String secretPath = "secret/checkAndSetUpdate"; + + final Vault vault = container.getRootVault(); + + final String createValue = "firstWorld"; + final Map testDataCreate = new HashMap<>(); + testDataCreate.put("value", createValue); + + LogicalResponse createResponse = vault.logical().write(secretPath, testDataCreate); + assertEquals(200, createResponse.getRestResponse().getStatus()); + + final String updateValue = "secondWorld"; + final Map testDataUpdate = new HashMap<>(); + testDataUpdate.put("value", updateValue); + + WriteOptions updateOptions = new WriteOptions().checkAndSet(1L).build(); + LogicalResponse updateResponse = vault.logical().write(secretPath, testDataUpdate, updateOptions); + assertEquals(200, updateResponse.getRestResponse().getStatus()); + + final LogicalResponse readResponse = vault.logical().read(secretPath); + final String valueRead = readResponse.getData().get("value"); + assertEquals(updateValue, valueRead); + final DataMetadata dataMetadata = readResponse.getDataMetadata(); + assertNotNull(dataMetadata); + assertFalse(dataMetadata.isEmpty()); + final Long secretVersion = dataMetadata.getVersion(); + assertNotNull(secretVersion); + assertEquals(2L, secretVersion.longValue()); + } + + @Test + public void testWriteWithCheckAndSetWrongUpdateVersion() throws VaultException { + final String secretPath = "secret/checkAndSetUpdateWrongVersion"; + + final Vault vault = container.getRootVault(); + + final String createValue = "firstWorld"; + final Map testDataCreate = new HashMap<>(); + testDataCreate.put("value", createValue); + + LogicalResponse createResponse = vault.logical().write(secretPath, testDataCreate); + assertEquals(200, createResponse.getRestResponse().getStatus()); + + final String updateValue = "secondWorld"; + final Map testDataUpdate = new HashMap<>(); + testDataUpdate.put("value", updateValue); + + WriteOptions updateOptions = new WriteOptions().checkAndSet(2L).build(); + LogicalResponse updateResponse = vault.logical().write(secretPath, testDataUpdate, updateOptions); + assertEquals(400, updateResponse.getRestResponse().getStatus()); + } + /** * Write a secret and verify that it can be read, using KV Secrets engine version 1. * diff --git a/src/test/java/com/bettercloud/vault/LogicalUtilitiesTests.java b/src/test/java/com/bettercloud/vault/LogicalUtilitiesTests.java index 00e5b110..6d173f78 100644 --- a/src/test/java/com/bettercloud/vault/LogicalUtilitiesTests.java +++ b/src/test/java/com/bettercloud/vault/LogicalUtilitiesTests.java @@ -103,11 +103,17 @@ public void adjustPathForVersionDestroyTests() { @Test public void jsonObjectToWriteFromEngineVersionTests() { JsonObject jsonObjectV2 = new JsonObject().add("test", "test"); - JsonObject jsonObjectFromEngineVersionV2 = LogicalUtilities.jsonObjectToWriteFromEngineVersion(Logical.logicalOperations.writeV2, jsonObjectV2); + JsonObject jsonObjectFromEngineVersionV2 = LogicalUtilities.jsonObjectToWriteFromEngineVersion(Logical.logicalOperations.writeV2, jsonObjectV2, null); Assert.assertEquals(jsonObjectFromEngineVersionV2.get("data"), jsonObjectV2); + Assert.assertNull(jsonObjectFromEngineVersionV2.get("options")); + + JsonObject optionsJsonObject = new JsonObject().add("cas", "0"); + JsonObject jsonObjectFromEngineVersion2WithOptions = LogicalUtilities.jsonObjectToWriteFromEngineVersion(Logical.logicalOperations.writeV2, jsonObjectV2, optionsJsonObject); + Assert.assertEquals(jsonObjectFromEngineVersion2WithOptions.get("data"), jsonObjectV2); + Assert.assertEquals(jsonObjectFromEngineVersion2WithOptions.get("options"), optionsJsonObject); JsonObject jsonObjectV1 = new JsonObject().add("test", "test"); - JsonObject jsonObjectFromEngineVersionV1 = LogicalUtilities.jsonObjectToWriteFromEngineVersion(Logical.logicalOperations.writeV1, jsonObjectV1); + JsonObject jsonObjectFromEngineVersionV1 = LogicalUtilities.jsonObjectToWriteFromEngineVersion(Logical.logicalOperations.writeV1, jsonObjectV1, null); Assert.assertNull(jsonObjectFromEngineVersionV1.get("data")); }