Add support for versioned secrets using @VaultPropertySource.

We now support paths that map to the Key-Value version 2 backend with @VaultPropertySource. The backing PropertySource obtains the latest version of a versioned secret.

@Configuration
@VaultPropertySource("versioned/my/path")
class MyConfiguration {
}

Closes gh-245.
This commit is contained in:
Mark Paluch
2019-02-15 10:21:09 +01:00
parent a063b9c284
commit 7c3b4c25ca
8 changed files with 417 additions and 6 deletions

View File

@@ -29,6 +29,7 @@ import org.springframework.util.Assert;
import org.springframework.vault.VaultException;
import org.springframework.vault.core.VaultOperations;
import org.springframework.vault.core.VaultTemplate;
import org.springframework.vault.core.util.KeyValueDelegate;
import org.springframework.vault.core.util.PropertyTransformer;
import org.springframework.vault.core.util.PropertyTransformers;
import org.springframework.vault.support.JsonMapFlattener;
@@ -50,6 +51,8 @@ public class VaultPropertySource extends EnumerablePropertySource<VaultOperation
private final String path;
private final KeyValueDelegate keyValueDelegate;
private final Map<String, Object> properties = new LinkedHashMap<>();
private final PropertyTransformer propertyTransformer;
@@ -106,8 +109,9 @@ public class VaultPropertySource extends EnumerablePropertySource<VaultOperation
Assert.notNull(propertyTransformer, "PropertyTransformer must not be null");
this.path = path;
this.propertyTransformer = propertyTransformer
.andThen(PropertyTransformers.removeNullProperties());
this.keyValueDelegate = new KeyValueDelegate(vaultOperations, LinkedHashMap::new);
this.propertyTransformer = propertyTransformer.andThen(PropertyTransformers
.removeNullProperties());
loadProperties();
}
@@ -156,7 +160,14 @@ public class VaultPropertySource extends EnumerablePropertySource<VaultOperation
@Nullable
protected Map<String, Object> doGetProperties(String path) throws VaultException {
VaultResponse vaultResponse = this.source.read(path);
VaultResponse vaultResponse;
if (this.keyValueDelegate.isVersioned(path)) {
vaultResponse = this.keyValueDelegate.getSecret(path);
}
else {
vaultResponse = this.source.read(path);
}
if (vaultResponse == null || vaultResponse.getData() == null) {
if (logger.isDebugEnabled()) {

View File

@@ -51,6 +51,7 @@ import org.springframework.vault.core.lease.domain.RequestedSecret;
import org.springframework.vault.core.lease.domain.RequestedSecret.Mode;
import org.springframework.vault.core.lease.event.LeaseErrorListener;
import org.springframework.vault.core.lease.event.LeaseListener;
import org.springframework.vault.core.util.KeyValueDelegate;
import org.springframework.vault.support.VaultResponseSupport;
import org.springframework.web.client.HttpStatusCodeException;
@@ -134,6 +135,8 @@ public class SecretLeaseContainer extends SecretLeaseEventPublisher implements
private final VaultOperations operations;
private final KeyValueDelegate keyValueDelegate;
private LeaseEndpoints leaseEndpoints = LeaseEndpoints.Legacy;
private Duration minRenewal = Duration.ofSeconds(10);
@@ -159,6 +162,7 @@ public class SecretLeaseContainer extends SecretLeaseEventPublisher implements
Assert.notNull(operations, "VaultOperations must not be null");
this.operations = operations;
this.keyValueDelegate = new KeyValueDelegate(this.operations);
}
/**
@@ -174,6 +178,7 @@ public class SecretLeaseContainer extends SecretLeaseEventPublisher implements
Assert.notNull(taskScheduler, "TaskScheduler must not be null");
this.operations = operations;
this.keyValueDelegate = new KeyValueDelegate(this.operations);
setTaskScheduler(taskScheduler);
}
@@ -559,6 +564,11 @@ public class SecretLeaseContainer extends SecretLeaseEventPublisher implements
RequestedSecret requestedSecret) {
try {
if (keyValueDelegate.isVersioned(requestedSecret.getPath())) {
return keyValueDelegate.getSecret(requestedSecret.getPath());
}
return this.operations.read(requestedSecret.getPath());
}
catch (RuntimeException e) {
@@ -628,7 +638,6 @@ public class SecretLeaseContainer extends SecretLeaseEventPublisher implements
return null;
}
@SuppressWarnings("unchecked")
private Lease renew(Lease lease) {
return operations.doWithSession(restOperations -> leaseEndpoints.renew(lease,

View File

@@ -0,0 +1,207 @@
/*
* Copyright 2019 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.util;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Supplier;
import lombok.Getter;
import org.springframework.lang.Nullable;
import org.springframework.util.ConcurrentReferenceHashMap;
import org.springframework.util.StringUtils;
import org.springframework.vault.core.VaultOperations;
import org.springframework.vault.core.VaultKeyValueOperationsSupport.KeyValueBackend;
import org.springframework.vault.support.VaultResponse;
/**
* Key-Value utility to retrieve secrets from a versioned key-value backend. For internal
* use within the framework.
* <p/>
* Uses Vault's internal API {@code sys/internal/ui/mounts} to determine mount
* information.
*
* @author Mark Paluch
* @since 2.2
*/
public class KeyValueDelegate {
private final Map<String, MountInfo> mountInfo;
private final VaultOperations operations;
public KeyValueDelegate(VaultOperations operations) {
this(operations, ConcurrentReferenceHashMap::new);
}
@SuppressWarnings("unchecked")
public KeyValueDelegate(VaultOperations operations,
Supplier<Map<String, ?>> cacheSupplier) {
this.operations = operations;
this.mountInfo = (Map) cacheSupplier.get();
}
/**
* Determine whether the {@code path} belongs to a versioned Key-Value mount.
*
* @param path the path to inspect.
* @return {@literal true} if the {@code path} belongs to a versioned Key-Value mount.
*/
public boolean isVersioned(String path) {
return getMountInfo(path).isKeyValue(KeyValueBackend.versioned());
}
/**
* Read a secret from a key-value backend. Considers the backend type and whether the
* backend is a versioned key-value backend.
*
* @param path the path to fetch the secret from.
* @return the secret, can be {@literal null}.
*/
@Nullable
public VaultResponse getSecret(String path) {
MountInfo mountInfo = this.mountInfo.get(path);
if (!mountInfo.isKeyValue(KeyValueBackend.versioned())) {
return this.operations.read(path);
}
VaultResponse response = this.operations.read(getKeyValue2Path(
mountInfo.getPath(), path));
unwrapDataResponse(response);
return response;
}
static String getKeyValue2Path(String mountPath, String requestedSecret) {
if (!requestedSecret.startsWith(mountPath)) {
return requestedSecret;
}
String keyPath = requestedSecret.substring(mountPath.length());
return String.format("%sdata/%s", mountPath, keyPath);
}
@SuppressWarnings("unchecked")
private static void unwrapDataResponse(@Nullable VaultResponse response) {
if (response == null || response.getData() == null
|| !response.getData().containsKey("data")) {
return;
}
Map<String, Object> nested = new LinkedHashMap<>((Map) response.getRequiredData()
.get("data"));
response.setData(nested);
}
@SuppressWarnings("unchecked")
private MountInfo doGetMountInfo(String path) {
VaultResponse response = this.operations.read(String.format(
"sys/internal/ui/mounts/%s", path));
if (response == null || response.getData() == null) {
return MountInfo.unavailable();
}
Map<String, Object> data = response.getData();
return MountInfo.from((String) data.get("path"), (Map) data.get("options"));
}
private MountInfo getMountInfo(String path) {
MountInfo mountInfo = this.mountInfo.get(path);
if (mountInfo == null) {
try {
mountInfo = doGetMountInfo(path);
}
catch (RuntimeException e) {
mountInfo = MountInfo.unavailable();
}
this.mountInfo.put(path, mountInfo);
}
return mountInfo;
}
@Getter
static class MountInfo {
static final MountInfo UNAVAILABLE = new MountInfo("", Collections.emptyMap(),
false);
final String path;
final @Nullable Map<String, Object> options;
final boolean available;
private MountInfo(String path, @Nullable Map<String, Object> options,
boolean available) {
this.path = path;
this.options = options;
this.available = available;
}
/**
* Creates a new {@link MountInfo} representing an absent {@link MountInfo}.
*
* @return a new {@link MountInfo} representing an absent {@link MountInfo}.
*/
static MountInfo unavailable() {
return UNAVAILABLE;
}
/**
* Creates a new {@link MountInfo} given {@code path} and {@link Map options map}.
*
* @param path
* @param options
* @return a new {@link MountInfo} for {@code path} and {@link Map options map}.
*/
static MountInfo from(String path, @Nullable Map<String, Object> options) {
return new MountInfo(path, options, true);
}
boolean isKeyValue(KeyValueBackend versioned) {
if (!isAvailable() || !StringUtils.hasText(path) || options == null) {
return false;
}
Object version = options.get("version");
if (version != null) {
if (version.toString().equals("1") && versioned == KeyValueBackend.KV_1) {
return true;
}
if (version.toString().equals("2") && versioned == KeyValueBackend.KV_2) {
return true;
}
}
return false;
}
}
}

View File

@@ -0,0 +1,97 @@
/*
* Copyright 2019 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.env;
import java.util.Collections;
import org.junit.BeforeClass;
import org.junit.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Configuration;
import org.springframework.vault.annotation.VaultPropertySource;
import org.springframework.vault.core.VaultIntegrationTestConfiguration;
import org.springframework.vault.core.VaultKeyValueOperations;
import org.springframework.vault.core.VaultKeyValueOperationsSupport;
import org.springframework.vault.core.lease.SecretLeaseContainer;
import org.springframework.vault.core.util.KeyValueDelegate;
import org.springframework.vault.util.IntegrationTestSupport;
import org.springframework.vault.util.PrepareVault;
import org.springframework.vault.util.VaultRule;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assume.assumeTrue;
/**
* Integration test for secrets retrieved from a versioned Key-Value backend using
* {@link KeyValueDelegate}.
*
* @author Mark Paluch
* @see SecretLeaseContainer
* @see org.springframework.vault.core.env.VaultPropertySource
*/
public class VersionedKeyValueBackendIntegrationTests extends IntegrationTestSupport {
@VaultPropertySource("versioned/my/path")
@Configuration
static class NonRotatingSecret {
}
@VaultPropertySource(value = "versioned/my/path", renewal = VaultPropertySource.Renewal.ROTATE)
@Configuration
static class RotatingSecret {
}
@BeforeClass
public static void beforeClass() {
VaultRule vaultRule = new VaultRule();
vaultRule.before();
PrepareVault prepare = vaultRule.prepare();
assumeTrue(prepare.getVersion().isGreaterThanOrEqualTo(
VaultRule.VERSIONING_INTRODUCED_WITH));
VaultKeyValueOperations versionedKv = prepare.getVaultOperations()
.opsForKeyValue("versioned",
VaultKeyValueOperationsSupport.KeyValueBackend.versioned());
versionedKv.put("my/path", Collections.singletonMap("my-key", "my-value"));
}
@Test
public void shouldRetrieveNonLeasedSecret() {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(
VaultIntegrationTestConfiguration.class, NonRotatingSecret.class);
context.registerShutdownHook();
assertThat(context.getEnvironment().getProperty("my-key")).isEqualTo("my-value");
context.stop();
}
@Test
public void shouldRetrieveRotatingSecret() {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(
VaultIntegrationTestConfiguration.class, RotatingSecret.class);
context.registerShutdownHook();
assertThat(context.getEnvironment().getProperty("my-key")).isEqualTo("my-value");
context.stop();
}
}

View File

@@ -50,7 +50,6 @@ import org.springframework.vault.support.VaultResponse;
import org.springframework.web.client.HttpClientErrorException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Matchers.any;
import static org.mockito.Mockito.never;
@@ -471,7 +470,8 @@ public class SecretLeaseContainerUnitTests {
ArgumentCaptor<Runnable> captor = ArgumentCaptor.forClass(Runnable.class);
verify(taskScheduler).schedule(captor.capture(), any(Trigger.class));
verify(vaultOperations).read(anyString());
verify(vaultOperations).read(eq("sys/internal/ui/mounts/my-secret"));
verify(vaultOperations).read(eq("my-secret"));
secretLeaseContainer.stop();

View File

@@ -0,0 +1,81 @@
/*
* Copyright 2019 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.util;
import java.util.Collections;
import org.junit.Test;
import org.springframework.vault.core.VaultKeyValueOperationsSupport;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.vault.core.util.KeyValueDelegate.MountInfo;
import static org.springframework.vault.core.util.KeyValueDelegate.getKeyValue2Path;
/**
* Unit tests for {@link KeyValueDelegate}.
*
* @author Mark Paluch
*/
public class KeyValueDelegateUnitTests {
@Test
public void getKeyValue2PathShouldConstructKeyValue2BackendPath() {
String path = getKeyValue2Path("foo/bar/versioned/", "foo/bar/versioned/my/key");
assertThat(path).isEqualTo("foo/bar/versioned/data/my/key");
}
@Test
public void getKeyValue2PathShouldIgnoreNotMatchingPath() {
String path = getKeyValue2Path("unknown/", "foo/bar/versioned/my/key");
assertThat(path).isEqualTo("foo/bar/versioned/my/key");
}
@Test
public void shouldConsiderKeyValueVersion() {
assertThat(
MountInfo.from("foo", Collections.singletonMap("version", "1"))
.isKeyValue(VaultKeyValueOperationsSupport.KeyValueBackend.KV_1))
.isTrue();
assertThat(
MountInfo.from("foo", Collections.singletonMap("version", 1)).isKeyValue(
VaultKeyValueOperationsSupport.KeyValueBackend.KV_1)).isTrue();
assertThat(
MountInfo.from("foo", Collections.singletonMap("version", "2"))
.isKeyValue(VaultKeyValueOperationsSupport.KeyValueBackend.KV_2))
.isTrue();
assertThat(
MountInfo.from("foo", Collections.singletonMap("version", 2)).isKeyValue(
VaultKeyValueOperationsSupport.KeyValueBackend.KV_1)).isFalse();
assertThat(
MountInfo.from("foo", Collections.singletonMap("version", "2"))
.isKeyValue(VaultKeyValueOperationsSupport.KeyValueBackend.KV_1))
.isFalse();
assertThat(
MountInfo.from("foo", Collections.emptyMap()).isKeyValue(
VaultKeyValueOperationsSupport.KeyValueBackend.KV_1)).isFalse();
}
}

View File

@@ -1,6 +1,10 @@
[[new-features]]
== New & Noteworthy
[[new-features.2-2-0]]
=== What's new in Spring Vault 2.2
* Support for Key-Value v2 (versioned backend) secrets through `@VaultPropertySource`.
[[new-features.2-1-0]]
=== What's new in Spring Vault 2.1

View File

@@ -99,6 +99,8 @@ public class AppConfig {
NOTE: Secrets obtained from `generic` secret backends are associated with a TTL (`refresh_interval`) but not a lease Id. Spring Vault's ``PropertySource`` rotates generic secrets when reaching its TTL.
NOTE: You can use `@VaultPropertySource` to obtain the newest secret version from the versioned Key-Value backend. Make sure to not include the `data/` segment in the path.
In certain situations, it may not be possible or practical to tightly control
property source ordering when using `@VaultPropertySource` annotations.
For example, if the `@Configuration` classes above were registered via