Require API version for Key/Value backend usage.

We now require specification of the desired Key/Value API version to interact with Vault's Key/Value backends. Vault 0.10.1 removed request downgrading so the client is in charge of using the proper API version.

KeyValue API implementation classes are package-protected now as API stability in Vault isn't given so we want to be able to adapt to API changes without breaking public API.

See gh-245.
This commit is contained in:
Mark Paluch
2018-05-05 13:16:46 +02:00
parent 39e3adb24a
commit 1e36b8f90c
12 changed files with 367 additions and 92 deletions

View File

@@ -0,0 +1,124 @@
/*
* Copyright 2018 the original author or authors.
*
* 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 org.springframework.vault.core;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.vault.support.VaultResponse;
import org.springframework.vault.support.VaultResponseSupport;
/**
* Default implementation of {@link VaultKeyValueOperations} for the Key/Value backend
* version 1.
*
* @author Mark Paluch
* @since 2.1
* @see KeyValueBackend#KV_1
*/
class VaultKeyValue1Template extends VaultKeyValueAccessor implements
VaultKeyValueOperations {
private final VaultOperations vaultOperations;
private final String path;
/**
* Create a new {@link VaultKeyValue1Template} given {@link VaultOperations} and the
* mount {@code path}.
*
* @param vaultOperations must not be {@literal null}.
* @param path must not be empty or {@literal null}.
*/
public VaultKeyValue1Template(VaultOperations vaultOperations, String path) {
super(vaultOperations, path);
this.vaultOperations = vaultOperations;
this.path = path;
}
@Nullable
@Override
public List<String> list(String path) {
return vaultOperations.list(createDataPath(path));
}
@Nullable
@Override
public VaultResponse get(String path) {
Assert.hasText(path, "Path must not be empty");
return doRead(path, Map.class, (response, data) -> {
VaultResponse vaultResponse = new VaultResponse();
vaultResponse.setRenewable(response.isRenewable());
vaultResponse.setAuth(response.getAuth());
vaultResponse.setLeaseDuration(response.getLeaseDuration());
vaultResponse.setLeaseId(response.getLeaseId());
vaultResponse.setMetadata(response.getMetadata());
vaultResponse.setRequestId(response.getRequestId());
vaultResponse.setWarnings(response.getWarnings());
vaultResponse.setWrapInfo(response.getWrapInfo());
vaultResponse.setData(data);
return vaultResponse;
});
}
@Nullable
@Override
@SuppressWarnings("unchecked")
public <T> VaultResponseSupport<T> get(String path, Class<T> responseType) {
Assert.hasText(path, "Path must not be empty");
Assert.notNull(responseType, "Response type must not be null");
return doRead(path, responseType, (response, data) -> {
VaultResponseSupport result = response;
result.setData(data);
return result;
});
}
@Override
public void put(String path, Object body) {
Assert.hasText(path, "Path must not be empty");
doWrite(createDataPath(path), body);
}
@Override
public KeyValueBackend getApiVersion() {
return KeyValueBackend.KV_1;
}
@Override
JsonNode getJsonNode(VaultResponseSupport<JsonNode> response) {
return response.getRequiredData();
}
@Override
String createDataPath(String path) {
return String.format("%s/%s", this.path, path);
}
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright 2018 the original author or authors.
*
* 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 org.springframework.vault.core;
import java.util.Collections;
import java.util.List;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.http.HttpMethod;
import org.springframework.lang.Nullable;
import org.springframework.vault.support.VaultResponseSupport;
/**
* Support class to build accessor methods for the Vault key-value backend version 2.
*
* @author Mark Paluch
* @since 2.1
* @see KeyValueBackend#KV_2
*/
abstract class VaultKeyValue2Accessor extends VaultKeyValueAccessor {
private final String path;
/**
* Create a new {@link VaultKeyValue2Accessor} given {@link VaultOperations} and the
* mount {@code path}.
*
* @param vaultOperations must not be {@literal null}.
* @param path must not be empty or {@literal null}.
*/
VaultKeyValue2Accessor(VaultOperations vaultOperations, String path) {
super(vaultOperations, path);
this.path = path;
}
@Nullable
@Override
@SuppressWarnings("unchecked")
public List<String> list(String path) {
String pathToUse = path.equals("/") ? "" : path.endsWith("/") ? path
: (path + "/");
VaultListResponse read = doRead(restOperations -> {
return restOperations.exchange(String.format("%s?list=true",
createBackendPath("metadata", pathToUse)), HttpMethod.GET, null,
VaultListResponse.class);
});
if (read == null) {
return Collections.emptyList();
}
return (List<String>) read.getRequiredData().get("keys");
}
@Override
public KeyValueBackend getApiVersion() {
return KeyValueBackend.KV_2;
}
JsonNode getJsonNode(VaultResponseSupport<JsonNode> response) {
return response.getRequiredData().at("/data");
}
String createDataPath(String path) {
return createBackendPath("data", path);
}
String createBackendPath(String segment, String path) {
return String.format("%s/%s/%s", this.path, segment, path);
}
}

View File

@@ -24,23 +24,23 @@ import org.springframework.vault.support.VaultResponse;
import org.springframework.vault.support.VaultResponseSupport;
/**
* Default implementation of {@link VaultKeyValueOperations}.
* Default implementation of {@link VaultKeyValueOperations} for the key-value backend
* version 2.
*
* @author Mark Paluch
* @since 2.1
*/
public class VaultKeyValueTemplate extends VaultKeyValueAccessor implements
class VaultKeyValue2Template extends VaultKeyValue2Accessor implements
VaultKeyValueOperations {
/**
* Create a new {@link VaultKeyValueTemplate} given {@link VaultOperations} and the
* Create a new {@link VaultKeyValue2Template} given {@link VaultOperations} and the
* mount {@code path}.
*
* @param vaultOperations must not be {@literal null}.
* @param path must not be empty or {@literal null}.
*/
public VaultKeyValueTemplate(VaultOperations vaultOperations, String path) {
public VaultKeyValue2Template(VaultOperations vaultOperations, String path) {
super(vaultOperations, path);
}

View File

@@ -16,17 +16,15 @@
package org.springframework.vault.core;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.function.BiFunction;
import java.util.function.Function;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -42,16 +40,16 @@ import org.springframework.web.client.RestOperations;
import org.springframework.web.client.RestTemplate;
/**
* Base class for {@link VaultVersionedKeyValueTemplate} and {@link VaultKeyValueTemplate}
* and other Vault KV-accessing helpers, defining common
* Base class for {@link VaultVersionedKeyValueTemplate} and
* {@link VaultKeyValue2Template} and other Vault KV-accessing helpers, defining common
* <p/>
* Not intended to be used directly. See {@link VaultVersionedKeyValueTemplate} and
* {@link VaultKeyValueTemplate}.
* {@link VaultKeyValue2Template}.
*
* @author Mark Paluch
* @since 2.1
*/
public abstract class VaultKeyValueAccessor implements VaultKeyValueOperationsSupport {
abstract class VaultKeyValueAccessor implements VaultKeyValueOperationsSupport {
private final VaultOperations vaultOperations;
@@ -66,7 +64,7 @@ public abstract class VaultKeyValueAccessor implements VaultKeyValueOperationsSu
* @param vaultOperations must not be {@literal null}.
* @param path must not be empty or {@literal null}.
*/
public VaultKeyValueAccessor(VaultOperations vaultOperations, String path) {
VaultKeyValueAccessor(VaultOperations vaultOperations, String path) {
Assert.notNull(vaultOperations, "VaultOperations must not be null");
Assert.hasText(path, "Path must not be empty");
@@ -76,43 +74,23 @@ public abstract class VaultKeyValueAccessor implements VaultKeyValueOperationsSu
this.mapper = extractObjectMapper(vaultOperations);
}
@Nullable
@Override
@SuppressWarnings("unchecked")
public List<String> list(String path) {
String pathToUse = path.equals("/") ? "" : path.endsWith("/") ? path
: (path + "/");
VaultListResponse read = doRead((restOperations, httpEntity) -> {
return restOperations.exchange(String.format("%s?list=true",
createBackendPath("metadata", pathToUse)), HttpMethod.GET,
httpEntity, VaultListResponse.class);
});
if (read == null) {
return Collections.emptyList();
}
return (List<String>) read.getRequiredData().get("keys");
}
@Override
public void delete(String path) {
Assert.hasText(path, "Path must not be empty");
doWithSession(((restOperations, httpHeaders) -> {
vaultOperations.doWithSession((restOperations -> {
restOperations.exchange(createDataPath(path), HttpMethod.DELETE,
new HttpEntity<>(httpHeaders), Void.class);
null,
Void.class);
return null;
}));
}
/**
* Read a secret at {@code path} and deserialize the nested {@literal data} element to
* the given {@link Class type}.
* Read a secret at {@code path} and deserialize the {@literal data} element to the
* given {@link Class type}.
*
* @param path must not be {@literal null}.
* @param deserializeAs must not be {@literal null}.
@@ -133,7 +111,7 @@ public abstract class VaultKeyValueAccessor implements VaultKeyValueOperationsSu
if (response != null) {
JsonNode jsonNode = response.getRequiredData().at("/data");
JsonNode jsonNode = getJsonNode(response);
return mappingFunction.apply(response, deserialize(jsonNode, deserializeAs));
}
@@ -141,6 +119,29 @@ public abstract class VaultKeyValueAccessor implements VaultKeyValueOperationsSu
return null;
}
/**
* Read a secret at {@code path} and deserialize the {@literal data} element to the
* given {@link ParameterizedTypeReference type}.
*
* @param path must not be {@literal null} or empty.
* @param typeReference must not be {@literal null}
* @return mapped value.
*/
@Nullable
<T> T doRead(String path, ParameterizedTypeReference<T> typeReference) {
return doRead((restOperations) -> {
return restOperations.exchange(path, HttpMethod.GET, null, typeReference);
});
}
/**
* Deserialize a {@link JsonNode} to the requested {@link Class type}.
*
* @param jsonNode must not be {@literal null}.
* @param type must not be {@literal null}.
* @return the deserialized object.
*/
<T> T deserialize(JsonNode jsonNode, Class<T> type) {
try {
@@ -151,23 +152,21 @@ public abstract class VaultKeyValueAccessor implements VaultKeyValueOperationsSu
}
}
/**
* Perform a read action within a callback that gets access to a session-bound
* {@link RestOperations} object. {@link HttpStatusCodeException} with
* {@link HttpStatus#NOT_FOUND} are translated to a {@literal null} response.
*
* @param callback must not be {@literal null}.
* @return can be {@literal null}.
*/
@Nullable
private <T> T doRead(String path, ParameterizedTypeReference<T> typeReference) {
<T> T doRead(Function<RestOperations, ResponseEntity<T>> callback) {
return doRead((restOperations, httpEntity) -> {
return restOperations.exchange(path, HttpMethod.GET, httpEntity,
typeReference);
});
}
@Nullable
private <T> T doRead(
BiFunction<RestOperations, HttpEntity<?>, ResponseEntity<T>> callback) {
return doWithSession((restOperations, headers) -> {
return vaultOperations.doWithSession((restOperations) -> {
try {
return callback.apply(restOperations, new HttpEntity<>(headers))
return callback.apply(restOperations)
.getBody();
}
catch (HttpStatusCodeException e) {
@@ -181,6 +180,13 @@ public abstract class VaultKeyValueAccessor implements VaultKeyValueOperationsSu
});
}
/**
* Write the {@code body} to the given Vault {@code path}.
*
* @param path must not be {@literal null} or empty.
* @param body
* @return the response of this write action.
*/
@Nullable
VaultResponse doWrite(String path, Object body) {
@@ -188,9 +194,9 @@ public abstract class VaultKeyValueAccessor implements VaultKeyValueOperationsSu
try {
return doWithSession((restOperations, httpHeaders) -> {
return vaultOperations.doWithSession((restOperations) -> {
return restOperations.exchange(path, HttpMethod.POST,
new HttpEntity<>(body, httpHeaders), VaultResponse.class)
new HttpEntity<>(body), VaultResponse.class)
.getBody();
});
}
@@ -199,25 +205,19 @@ public abstract class VaultKeyValueAccessor implements VaultKeyValueOperationsSu
}
}
@Nullable
<T> T doWithSession(BiFunction<RestOperations, HttpHeaders, T> callback) {
/**
* Return the {@link JsonNode} that contains the actual response body.
*
* @param response
* @return
*/
abstract JsonNode getJsonNode(VaultResponseSupport<JsonNode> response);
return vaultOperations.doWithSession(restOperations -> {
HttpHeaders headers = new HttpHeaders();
headers.set("X-Vault-Kv-Client", "v2");
return callback.apply(restOperations, headers);
});
}
String createDataPath(String path) {
return createBackendPath("data", path);
}
String createBackendPath(String segment, String path) {
return String.format("%s/%s/%s", this.path, segment, path);
}
/**
* @param path must not be {@literal null} or empty.
* @return backend path representing the data path.
*/
abstract String createDataPath(String path);
private static ObjectMapper extractObjectMapper(VaultOperations vaultOperations) {
@@ -231,7 +231,6 @@ public abstract class VaultKeyValueAccessor implements VaultKeyValueOperationsSu
Optional<AbstractJackson2HttpMessageConverter> jackson2Converter = template
.getMessageConverters()
.stream()
//
.filter(AbstractJackson2HttpMessageConverter.class::isInstance) //
.map(AbstractJackson2HttpMessageConverter.class::cast) //
.findFirst();

View File

@@ -31,6 +31,7 @@ import org.springframework.vault.support.VaultResponseSupport;
* @author Mark Paluch
* @since 2.1
* @see VaultVersionedKeyValueOperations
* @see KeyValueBackend
*/
public interface VaultKeyValueOperations extends VaultKeyValueOperationsSupport {

View File

@@ -54,4 +54,39 @@ public interface VaultKeyValueOperationsSupport {
* @param path must not be {@literal null}.
*/
void delete(String path);
/**
* @return the used API version.
*/
KeyValueBackend getApiVersion();
/**
* Enumeration of supported Key/Value backend API versions.
*/
enum KeyValueBackend {
/**
* Key/Value backend version 1 (unversioned).
*/
KV_1,
/**
* K/V backend version 2 (versioned).
*/
KV_2;
/**
* @return the K/V version 1 (unversioned).
*/
public static KeyValueBackend unversioned() {
return KV_1;
}
/**
* @return the K/V version 2 (versioned).
*/
public static KeyValueBackend versioned() {
return KV_2;
}
}
}

View File

@@ -19,6 +19,7 @@ import java.util.List;
import org.springframework.lang.Nullable;
import org.springframework.vault.VaultException;
import org.springframework.vault.core.VaultKeyValueOperationsSupport.KeyValueBackend;
import org.springframework.vault.support.VaultResponse;
import org.springframework.vault.support.VaultResponseSupport;
import org.springframework.web.client.RestClientException;
@@ -46,12 +47,12 @@ public interface VaultOperations {
/**
* Return {@link VaultKeyValueOperations}.
*
* @param path the mount path
* @return the operations interface to interact with the Vault Key/Value (version 2)
* backend.
* @param path the mount path, must not be empty or {@literal null}.
* @param apiVersion API version to use, must not be {@literal null}.
* @return the operations interface to interact with the Vault Key/Value backend.
* @since 2.1
*/
VaultKeyValueOperations opsForKeyValue(String path);
VaultKeyValueOperations opsForKeyValue(String path, KeyValueBackend apiVersion);
/**
* Return {@link VaultVersionedKeyValueOperations}.

View File

@@ -37,6 +37,7 @@ import org.springframework.vault.client.VaultEndpoint;
import org.springframework.vault.client.VaultEndpointProvider;
import org.springframework.vault.client.VaultHttpHeaders;
import org.springframework.vault.client.VaultResponses;
import org.springframework.vault.core.VaultKeyValueOperationsSupport.KeyValueBackend;
import org.springframework.vault.support.VaultResponse;
import org.springframework.vault.support.VaultResponseSupport;
import org.springframework.web.client.HttpStatusCodeException;
@@ -206,8 +207,18 @@ public class VaultTemplate implements InitializingBean, VaultOperations, Disposa
}
@Override
public VaultKeyValueOperations opsForKeyValue(String path) {
return new VaultKeyValueTemplate(this, path);
public VaultKeyValueOperations opsForKeyValue(String path, KeyValueBackend apiVersion) {
switch (apiVersion) {
case KV_1:
return new VaultKeyValue1Template(this, path);
case KV_2:
return new VaultKeyValue2Template(this, path);
}
throw new UnsupportedOperationException(String.format(
"Key/Value backend version %s not supported", apiVersion));
}
@Override

View File

@@ -27,7 +27,6 @@ import java.util.stream.Collectors;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.lang.Nullable;
@@ -48,9 +47,11 @@ import org.springframework.web.client.HttpStatusCodeException;
* @author Mark Paluch
* @since 2.1
*/
public class VaultVersionedKeyValueTemplate extends VaultKeyValueAccessor implements
public class VaultVersionedKeyValueTemplate extends VaultKeyValue2Accessor implements
VaultVersionedKeyValueOperations {
private final VaultOperations vaultOperations;
/**
* Create a new {@link VaultVersionedKeyValueTemplate} given {@link VaultOperations}
* and the mount {@code path}.
@@ -59,7 +60,10 @@ public class VaultVersionedKeyValueTemplate extends VaultKeyValueAccessor implem
* @param path must not be empty or {@literal null}.
*/
public VaultVersionedKeyValueTemplate(VaultOperations vaultOperations, String path) {
super(vaultOperations, path);
this.vaultOperations = vaultOperations;
}
@Nullable
@@ -90,11 +94,12 @@ public class VaultVersionedKeyValueTemplate extends VaultKeyValueAccessor implem
String secretPath = version.isVersioned() ? String.format("%s?version=%d",
createDataPath(path), version.getVersion()) : createDataPath(path);
VersionedResponse response = doWithSession((restOperations, httpHeaders) -> {
VersionedResponse response = vaultOperations.doWithSession(restOperations -> {
try {
return restOperations.exchange(secretPath, HttpMethod.GET,
new HttpEntity<>(httpHeaders), VersionedResponse.class).getBody();
null,
VersionedResponse.class).getBody();
}
catch (HttpStatusCodeException e) {

View File

@@ -24,6 +24,7 @@ import org.junit.Before;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.vault.core.VaultKeyValueOperationsSupport.KeyValueBackend;
import org.springframework.vault.util.IntegrationTestSupport;
import org.springframework.vault.util.VaultRule;
@@ -31,7 +32,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assume.assumeTrue;
/**
* Integration tests for {@link VaultKeyValueTemplate}.
* Integration tests for {@link VaultKeyValue2Template}.
*
* @author Mark Paluch
*/
@@ -39,13 +40,15 @@ public abstract class AbstractVaultKeyValueTemplateIntegrationTests extends
IntegrationTestSupport {
private final String path;
private final KeyValueBackend apiVersion;
@Autowired
VaultOperations vaultOperations;
VaultKeyValueOperations kvOperations;
AbstractVaultKeyValueTemplateIntegrationTests(String path) {
AbstractVaultKeyValueTemplateIntegrationTests(String path, KeyValueBackend apiVersion) {
this.path = path;
this.apiVersion = apiVersion;
}
@Before
@@ -54,7 +57,12 @@ public abstract class AbstractVaultKeyValueTemplateIntegrationTests extends
assumeTrue(prepare().getVersion().isGreaterThanOrEqualTo(
VaultRule.VERSIONING_INTRODUCED_WITH));
kvOperations = vaultOperations.opsForKeyValue(path);
kvOperations = vaultOperations.opsForKeyValue(path, apiVersion);
}
@Test
public void shouldReportExpectedApiVersion() {
assertThat(kvOperations.getApiVersion()).isEqualTo(apiVersion);
}
@Test

View File

@@ -23,13 +23,14 @@ import org.junit.runner.RunWith;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.vault.core.VaultKeyValueOperationsSupport.KeyValueBackend;
import org.springframework.vault.support.VaultResponse;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for {@link VaultKeyValueTemplate} using the non-versioned key-value
* backend.
* Integration tests for {@link VaultKeyValue2Template} using the non-versioned Key/Value
* (k/v version 1) backend.
*
* @author Mark Paluch
*/
@@ -39,7 +40,7 @@ public class VaultKeyValueTemplateIntegrationTests extends
AbstractVaultKeyValueTemplateIntegrationTests {
public VaultKeyValueTemplateIntegrationTests() {
super("secret");
super("secret", KeyValueBackend.unversioned());
}
@Test

View File

@@ -19,10 +19,11 @@ import org.junit.runner.RunWith;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.vault.core.VaultKeyValueOperationsSupport.KeyValueBackend;
/**
* Integration tests for {@link VaultKeyValueTemplate} using the versioned key-value
* backend.
* Integration tests for {@link VaultKeyValue2Template} using the versioned Key/Value (k/v
* version 2) backend.
*
* @author Mark Paluch
*/
@@ -32,6 +33,6 @@ public class VaultKeyValueTemplateVersionedIntegrationTests extends
AbstractVaultKeyValueTemplateIntegrationTests {
public VaultKeyValueTemplateVersionedIntegrationTests() {
super("versioned");
super("versioned", KeyValueBackend.versioned());
}
}