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:
@@ -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()) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user