Added support for Vault's 0.10.0+ versioning enabled kv engine. (#1018)

This commit is contained in:
Haroun Pacquée
2018-06-04 23:13:27 +02:00
committed by Ryan Baxter
parent b10135c1ee
commit e6b2d9eec0
9 changed files with 502 additions and 100 deletions

View File

@@ -546,12 +546,17 @@ The following table describes configurable Vault properties:
|profileSeparator
|,
|kvVersion
|1
|===
IMPORTANT: All of the properties in the preceding table must be prefixed with `spring.cloud.config.server.vault`.
All configurable properties can be found in `org.springframework.cloud.config.server.environment.VaultEnvironmentRepository`.
Vault 0.10.0 introduced a versioned key-value backend (k/v backend version 2) that exposes a different API than earlier versions, it now requires a `data/` between the mount path and the actual context path and wraps secrets in a `data` object. Setting `kvVersion=2` will take this into account.
With your config server running, you can make HTTP requests to the server to retrieve
values from the Vault backend.
To do so, you need a token for your Vault server.

View File

@@ -25,6 +25,7 @@ import org.springframework.core.Ordered;
/**
* @author Dylan Roberts
* @author Haroun Pacquee
*/
@ConfigurationProperties("spring.cloud.config.server.vault")
public class VaultEnvironmentProperties implements HttpEnvironmentRepositoryProperties {
@@ -52,6 +53,10 @@ public class VaultEnvironmentProperties implements HttpEnvironmentRepositoryProp
private Map<ProxyHostProperties.ProxyForScheme, ProxyHostProperties> proxy = new HashMap<>();
private int order = Ordered.LOWEST_PRECEDENCE;
/**
* Value to indicate which version of Vault kv backend is used. Defaults to 1.
*/
private int kvVersion = 1;
public String getHost() {
return host;
@@ -127,4 +132,12 @@ public class VaultEnvironmentProperties implements HttpEnvironmentRepositoryProp
public void setOrder(int order) {
this.order = order;
}
public int getKvVersion() {
return kvVersion;
}
public void setKvVersion(int kvVersion) {
this.kvVersion = kvVersion;
}
}

View File

@@ -26,25 +26,15 @@ import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotEmpty;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonRawValue;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.config.YamlPropertiesFactoryBean;
import org.springframework.cloud.config.environment.Environment;
import org.springframework.cloud.config.environment.PropertySource;
import org.springframework.core.Ordered;
import org.springframework.core.io.ByteArrayResource;
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;
import org.springframework.util.StringUtils;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.RestTemplate;
import static org.springframework.cloud.config.client.ConfigClientProperties.STATE_HEADER;
@@ -53,6 +43,7 @@ import static org.springframework.cloud.config.client.ConfigClientProperties.TOK
/**
* @author Spencer Gibb
* @author Mark Paluch
* @author Haroun Pacquee
*/
@Validated
public class VaultEnvironmentRepository implements EnvironmentRepository, Ordered {
@@ -84,18 +75,18 @@ public class VaultEnvironmentRepository implements EnvironmentRepository, Ordere
private int order;
private RestTemplate rest;
private VaultKvAccessStrategy accessStrategy;
// TODO: move to watchState:String on findOne?
private ObjectProvider<HttpServletRequest> request;
private EnvironmentWatch watch;
public VaultEnvironmentRepository(ObjectProvider<HttpServletRequest> request, EnvironmentWatch watch, RestTemplate rest,
VaultEnvironmentProperties properties) {
public VaultEnvironmentRepository(ObjectProvider<HttpServletRequest> request,
EnvironmentWatch watch, RestTemplate rest,
VaultEnvironmentProperties properties) {
this.request = request;
this.watch = watch;
this.rest = rest;
this.backend = properties.getBackend();
this.defaultKey = properties.getDefaultKey();
this.host = properties.getHost();
@@ -103,6 +94,11 @@ public class VaultEnvironmentRepository implements EnvironmentRepository, Ordere
this.port = properties.getPort();
this.profileSeparator = properties.getProfileSeparator();
this.scheme = properties.getScheme();
String baseUrl = String.format("%s://%s:%s", this.scheme, this.host, this.port);
this.accessStrategy = VaultKvAccessStrategyFactory.forVersion(rest, baseUrl,
properties.getKvVersion());
}
@Override
@@ -172,7 +168,6 @@ public class VaultEnvironmentRepository implements EnvironmentRepository, Ordere
}
String read(HttpServletRequest servletRequest, String key) {
String url = String.format("%s://%s:%s/v1/{backend}/{key}", this.scheme, this.host, this.port);
HttpHeaders headers = new HttpHeaders();
@@ -181,22 +176,7 @@ public class VaultEnvironmentRepository implements EnvironmentRepository, Ordere
throw new IllegalArgumentException("Missing required header: " + TOKEN_HEADER);
}
headers.add(VAULT_TOKEN, token);
try {
ResponseEntity<VaultResponse> response = this.rest.exchange(url, HttpMethod.GET, new HttpEntity<>(headers),
VaultResponse.class, this.backend, key);
HttpStatus status = response.getStatusCode();
if (status == HttpStatus.OK) {
return response.getBody().getData();
}
} catch (HttpStatusCodeException e) {
if (e.getStatusCode() == HttpStatus.NOT_FOUND) {
return null;
}
throw e;
}
return null;
return accessStrategy.getData(headers, backend, key);
}
public void setHost(String host) {
@@ -231,64 +211,4 @@ public class VaultEnvironmentRepository implements EnvironmentRepository, Ordere
public int getOrder() {
return order;
}
@JsonIgnoreProperties(ignoreUnknown = true)
static class VaultResponse {
private String auth;
private Object data;
@JsonProperty("lease_duration")
private long leaseDuration;
@JsonProperty("lease_id")
private String leaseId;
private boolean renewable;
public VaultResponse() {
}
public String getAuth() {
return auth;
}
public void setAuth(String auth) {
this.auth = auth;
}
@JsonRawValue
public String getData() {
return data == null ? null : data.toString();
}
public void setData(JsonNode data) {
this.data = data;
}
public long getLeaseDuration() {
return leaseDuration;
}
public void setLeaseDuration(long leaseDuration) {
this.leaseDuration = leaseDuration;
}
public String getLeaseId() {
return leaseId;
}
public void setLeaseId(String leaseId) {
this.leaseId = leaseId;
}
public boolean isRenewable() {
return renewable;
}
public void setRenewable(boolean renewable) {
this.renewable = renewable;
}
}
}

View File

@@ -0,0 +1,90 @@
package org.springframework.cloud.config.server.environment;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.http.HttpHeaders;
import org.springframework.web.client.RestClientException;
/**
* Strategy interface to obtain secrets from Vault's key-value secret backend.
* @author Haroun Pacquee
* @author Mark Paluch
* @since 2.0
*/
@FunctionalInterface
public interface VaultKvAccessStrategy {
/**
* Return secrets from Vault. The response is represented as JSON object marshaled to
* {@link String}.
* @param headers must not be {@literal null}.
* @param backend secret backend mount path, must not be {@literal null}.
* @param key key within the key-value secret backend, must not be {@literal null}.
* @return the marshaled JSON object or {@literal null} if the key was not found.
* @throws RestClientException in case of a transport/access failure.
* @see com.fasterxml.jackson.annotation.JsonRawValue
*/
String getData(HttpHeaders headers, String backend, String key)
throws RestClientException;
@JsonIgnoreProperties(ignoreUnknown = true)
class VaultResponse {
private String auth;
private Object data;
@JsonProperty("lease_duration")
private long leaseDuration;
@JsonProperty("lease_id")
private String leaseId;
private boolean renewable;
public VaultResponse() {
}
public String getAuth() {
return auth;
}
public void setAuth(String auth) {
this.auth = auth;
}
public Object getData() {
return data;
}
public void setData(JsonNode data) {
this.data = data;
}
public long getLeaseDuration() {
return leaseDuration;
}
public void setLeaseDuration(long leaseDuration) {
this.leaseDuration = leaseDuration;
}
public String getLeaseId() {
return leaseId;
}
public void setLeaseId(String leaseId) {
this.leaseId = leaseId;
}
public boolean isRenewable() {
return renewable;
}
public void setRenewable(boolean renewable) {
this.renewable = renewable;
}
}
}

View File

@@ -0,0 +1,93 @@
/*
* 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.cloud.config.server.environment;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.web.client.RestOperations;
/**
* Factory for {@link VaultKvAccessStrategy}.
* @author Haroun Pacquee
* @author Mark Paluch
* @since 2.0
*/
public class VaultKvAccessStrategyFactory {
/**
* Create a new {@link VaultKvAccessStrategy} given {@link RestOperations},
* {@code baseUrl}, and {@code version}.
* @param rest must not be {@literal null}.
* @param baseUrl the Vault base URL.
* @param version version of the Vault key-value backend.
* @return the access strategy.
*/
public static VaultKvAccessStrategy forVersion(RestOperations rest, String baseUrl,
int version) {
switch (version) {
case 1:
return new V1VaultKvAccessStrategy(baseUrl, rest);
case 2:
return new V2VaultKvAccessStrategy(baseUrl, rest);
default:
throw new IllegalArgumentException(
"No support for given Vault k/v backend version " + version);
}
}
/**
* Strategy for the key-value backend API version 1.
*/
static class V1VaultKvAccessStrategy extends VaultKvAccessStrategySupport {
V1VaultKvAccessStrategy(String baseUrl, RestOperations rest) {
super(baseUrl, rest);
}
@Override
public String getPath() {
return "/v1/{backend}/{key}";
}
@Override
public String extractDataFromBody(VaultResponse body) {
return body.getData() == null ? null : body.getData().toString();
}
}
/**
* Strategy for the key-value backend API version 2.
*/
static class V2VaultKvAccessStrategy extends VaultKvAccessStrategySupport {
V2VaultKvAccessStrategy(String baseUrl, RestOperations rest) {
super(baseUrl, rest);
}
@Override
public String getPath() {
return "/v1/{backend}/data/{key}";
}
@Override
public String extractDataFromBody(VaultResponse body) {
JsonNode nestedDataNode = body.getData() == null ? null
: ((JsonNode) body.getData()).get("data");
return nestedDataNode == null ? null : nestedDataNode.toString();
}
}
}

View File

@@ -0,0 +1,79 @@
/*
* 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.cloud.config.server.environment;
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;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.RestOperations;
/**
* Base class for {@link VaultKvAccessStrategy} implementors.
* @author Mark Paluch
* @since 2.0
*/
abstract class VaultKvAccessStrategySupport implements VaultKvAccessStrategy {
private final String baseUrl;
private final RestOperations rest;
VaultKvAccessStrategySupport(String baseUrl, RestOperations rest) {
this.baseUrl = baseUrl;
this.rest = rest;
}
/**
* @return the context path to append to the {@code baseUrl}.
*/
abstract String getPath();
/**
* Extract the raw JSON from the
* {@link org.springframework.cloud.config.server.environment.VaultKvAccessStrategy.VaultResponse}.
* @param body
* @return
*/
abstract String extractDataFromBody(VaultResponse body);
/**
* @param headers must not be {@literal null}.
* @param backend secret backend mount path, must not be {@literal null}.
* @param key key within the key-value secret backend, must not be {@literal null}.
* @return
*/
@Override
public String getData(HttpHeaders headers, String backend, String key) {
try {
ResponseEntity<VaultResponse> response = rest.exchange(baseUrl + getPath(),
HttpMethod.GET, new HttpEntity<>(headers), VaultResponse.class,
backend, key);
HttpStatus status = response.getStatusCode();
if (status == HttpStatus.OK) {
return extractDataFromBody(response.getBody());
}
}
catch (HttpStatusCodeException e) {
if (e.getStatusCode() == HttpStatus.NOT_FOUND) {
return null;
}
throw e;
}
return null;
}
}

View File

@@ -1,16 +1,26 @@
/*
* 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.cloud.config.server.environment;
import java.util.HashMap;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Before;
import org.junit.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.cloud.config.environment.Environment;
import org.springframework.cloud.config.server.environment.VaultEnvironmentRepository.VaultResponse;
import org.springframework.cloud.config.server.environment.VaultKvAccessStrategy.VaultResponse;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
@@ -18,7 +28,12 @@ import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.web.client.RestTemplate;
import javax.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
@@ -27,11 +42,16 @@ import static org.mockito.Mockito.when;
/**
* @author Spencer Gibb
* @author Ryan Baxter
* @author Haroun Pacquee
*/
public class VaultEnvironmentRepositoryTests {
@Before
public void init() {}
private ObjectMapper objectMapper;
@Before
public void init() {
objectMapper = new ObjectMapper();
}
@SuppressWarnings("unchecked")
private ObjectProvider<HttpServletRequest> mockProvide(HttpServletRequest request) {
@@ -199,4 +219,52 @@ public class VaultEnvironmentRepositoryTests {
new EnvironmentWatch.Default(), rest, new VaultEnvironmentProperties());
repo.findOne("myapp", null, null);
}
@Test
@SuppressWarnings("unchecked")
public void testVaultVersioning() {
MockHttpServletRequest configRequest = new MockHttpServletRequest();
configRequest.addHeader("X-CONFIG-TOKEN", "mytoken");
RestTemplate rest = mock(RestTemplate.class);
ResponseEntity<VaultResponse> myAppResp = mock(ResponseEntity.class);
when(myAppResp.getStatusCode()).thenReturn(HttpStatus.OK);
VaultResponse myAppVaultResp = getVaultResponse("{\"data\": {\"data\": {\"foo\": \"bar\"}}}");
when(myAppResp.getBody()).thenReturn(myAppVaultResp);
when(rest.exchange(eq("http://127.0.0.1:8200/v1/{backend}/data/{key}"),
eq(HttpMethod.GET), any(HttpEntity.class), eq(VaultResponse.class),
eq("secret"), eq("myapp"))).thenReturn(myAppResp);
ResponseEntity<VaultResponse> appResp = mock(ResponseEntity.class);
when(appResp.getStatusCode()).thenReturn(HttpStatus.OK);
VaultResponse appVaultResp = getVaultResponse("{\"data\": {\"data\": {\"def-foo\":\"def-bar\"}}}");
when(appResp.getBody()).thenReturn(appVaultResp);
when(rest.exchange(eq("http://127.0.0.1:8200/v1/{backend}/data/{key}"),
eq(HttpMethod.GET), any(HttpEntity.class), eq(VaultResponse.class),
eq("secret"), eq("application"))).thenReturn(appResp);
final VaultEnvironmentProperties vaultEnvironmentProperties = new VaultEnvironmentProperties();
vaultEnvironmentProperties.setKvVersion(2);
VaultEnvironmentRepository repo = new VaultEnvironmentRepository(mockProvide(configRequest),
new EnvironmentWatch.Default(), rest, vaultEnvironmentProperties);
Environment e = repo.findOne("myapp", null, null);
assertEquals("Name should be the same as the application argument", "myapp", e.getName());
assertEquals("Properties for specified application and default application with key 'application' should be returned",
2, e.getPropertySources().size());
Map<String, String> firstResult = new HashMap<String, String>();
firstResult.put("foo", "bar");
assertEquals("Properties for specified application should be returned in priority position",
firstResult, e.getPropertySources().get(0).getSource());
}
private VaultResponse getVaultResponse(String json) {
try {
return objectMapper.readValue(json, VaultResponse.class);
} catch (Exception e) {
fail(e.getMessage());
}
return null;
}
}

View File

@@ -0,0 +1,48 @@
/*
* 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.cloud.config.server.environment;
import org.junit.Test;
import org.springframework.cloud.config.server.environment.VaultKvAccessStrategyFactory.V1VaultKvAccessStrategy;
import org.springframework.cloud.config.server.environment.VaultKvAccessStrategyFactory.V2VaultKvAccessStrategy;
import static org.junit.Assert.*;
/**
* @author Haroun Pacquee
*/
public class VaultKvAccessStrategyFactoryTest {
@Test
public void testGetV1Strategy() {
VaultKvAccessStrategy vaultKvAccessStrategy = VaultKvAccessStrategyFactory
.forVersion(null, "foo", 1);
assertTrue(vaultKvAccessStrategy instanceof V1VaultKvAccessStrategy);
}
@Test
public void testGetV2Strategy() {
VaultKvAccessStrategy vaultKvAccessStrategy = VaultKvAccessStrategyFactory
.forVersion(null, "foo", 2);
assertTrue(vaultKvAccessStrategy instanceof V2VaultKvAccessStrategy);
}
@Test(expected = IllegalArgumentException.class)
public void testGetUnsupportedStrategy() {
VaultKvAccessStrategyFactory.forVersion(null, "foo", 0);
}
}

View File

@@ -0,0 +1,86 @@
/*
* 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.cloud.config.server.environment;
import java.io.IOException;
import java.lang.reflect.UndeclaredThrowableException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Test;
import org.springframework.cloud.config.server.environment.VaultKvAccessStrategy.VaultResponse;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.*;
/**
* @author Haroun Pacquee
*/
public class VaultKvAccessStrategyTest {
private ObjectMapper objectMapper = new ObjectMapper();
private static final String FOO_BAR = "{\"foo\":\"bar\"}";
@Test
public void testV1ExtractFromBody() {
String json = "{\"data\": {\"foo\": \"bar\"}}";
String s = getStrategy(1).extractDataFromBody(getVaultResponse(json));
assertThat(s, is(FOO_BAR));
}
@Test
public void testV1ExtractFromBodyNoData() {
String json = "{}";
String s = getStrategy(1).extractDataFromBody(getVaultResponse(json));
assertNull(s);
}
@Test
public void testV2ExtractFromBody() {
String json = "{\"data\": {\"data\": {\"foo\": \"bar\"}}}";
String s = getStrategy(2).extractDataFromBody(getVaultResponse(json));
assertThat(s, is(FOO_BAR));
}
@Test
public void testV2ExtractFromBodyEmptyNestedData() {
String json = "{\"data\": {}}";
String s = getStrategy(2).extractDataFromBody(getVaultResponse(json));
assertNull(s);
}
@Test
public void testV2ExtractFromBodyNoData() {
String json = "{}";
String s = getStrategy(2).extractDataFromBody(getVaultResponse(json));
assertNull(s);
}
private VaultResponse getVaultResponse(String json) {
try {
return objectMapper.readValue(json, VaultResponse.class);
}
catch (IOException e) {
throw new UndeclaredThrowableException(e);
}
}
private static VaultKvAccessStrategySupport getStrategy(int version) {
return (VaultKvAccessStrategySupport) VaultKvAccessStrategyFactory
.forVersion(null, "foo", version);
}
}