diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/env/VaultPropertySource.java b/spring-vault-core/src/main/java/org/springframework/vault/core/env/VaultPropertySource.java index b936a718..1a58a1cd 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/core/env/VaultPropertySource.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/env/VaultPropertySource.java @@ -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 properties = new LinkedHashMap<>(); private final PropertyTransformer propertyTransformer; @@ -106,8 +109,9 @@ public class VaultPropertySource extends EnumerablePropertySource 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()) { diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/lease/SecretLeaseContainer.java b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/SecretLeaseContainer.java index a18b9d4d..981ceb62 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/core/lease/SecretLeaseContainer.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/SecretLeaseContainer.java @@ -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, diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/util/KeyValueDelegate.java b/spring-vault-core/src/main/java/org/springframework/vault/core/util/KeyValueDelegate.java new file mode 100644 index 00000000..eb707ac0 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/util/KeyValueDelegate.java @@ -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. + *

+ * 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 mountInfo; + + private final VaultOperations operations; + + public KeyValueDelegate(VaultOperations operations) { + this(operations, ConcurrentReferenceHashMap::new); + } + + @SuppressWarnings("unchecked") + public KeyValueDelegate(VaultOperations operations, + Supplier> 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 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 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 options; + final boolean available; + + private MountInfo(String path, @Nullable Map 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 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; + } + } +} diff --git a/spring-vault-core/src/test/java/org/springframework/vault/core/env/VersionedKeyValueBackendIntegrationTests.java b/spring-vault-core/src/test/java/org/springframework/vault/core/env/VersionedKeyValueBackendIntegrationTests.java new file mode 100644 index 00000000..7dc1d32e --- /dev/null +++ b/spring-vault-core/src/test/java/org/springframework/vault/core/env/VersionedKeyValueBackendIntegrationTests.java @@ -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(); + } +} diff --git a/spring-vault-core/src/test/java/org/springframework/vault/core/lease/SecretLeaseContainerUnitTests.java b/spring-vault-core/src/test/java/org/springframework/vault/core/lease/SecretLeaseContainerUnitTests.java index dbdfded0..dd8db315 100644 --- a/spring-vault-core/src/test/java/org/springframework/vault/core/lease/SecretLeaseContainerUnitTests.java +++ b/spring-vault-core/src/test/java/org/springframework/vault/core/lease/SecretLeaseContainerUnitTests.java @@ -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 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(); diff --git a/spring-vault-core/src/test/java/org/springframework/vault/core/util/KeyValueDelegateUnitTests.java b/spring-vault-core/src/test/java/org/springframework/vault/core/util/KeyValueDelegateUnitTests.java new file mode 100644 index 00000000..fa911751 --- /dev/null +++ b/spring-vault-core/src/test/java/org/springframework/vault/core/util/KeyValueDelegateUnitTests.java @@ -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(); + } +} diff --git a/src/main/asciidoc/new-features.adoc b/src/main/asciidoc/new-features.adoc index f4b84355..e8f31872 100644 --- a/src/main/asciidoc/new-features.adoc +++ b/src/main/asciidoc/new-features.adoc @@ -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 diff --git a/src/main/asciidoc/reference/propertysource.adoc b/src/main/asciidoc/reference/propertysource.adoc index f070718c..8e8ab8ad 100644 --- a/src/main/asciidoc/reference/propertysource.adoc +++ b/src/main/asciidoc/reference/propertysource.adoc @@ -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