diff --git a/docs/src/main/asciidoc/spring-cloud-vault-config.adoc b/docs/src/main/asciidoc/spring-cloud-vault-config.adoc index 517fae0d..55905ab9 100644 --- a/docs/src/main/asciidoc/spring-cloud-vault-config.adoc +++ b/docs/src/main/asciidoc/spring-cloud-vault-config.adoc @@ -690,3 +690,44 @@ trust-store. Please note that configuring `spring.cloud.vault.ssl.*` can be only applied when either Apache Http Components or the OkHttp client is on your class-path. + +[[vault-lease-renewal]] +== Lease lifecycle management (renewal and revocation) + +With every secret, Vault creates a lease: +metadata containing information such as a time duration, +renewability, and more. + +Vault promises that the data will be valid for the given duration, +or Time To Live (TTL). Once the lease is expired, Vault can +revoke the data, and the consumer of the secret can no longer +be certain that it is valid. + +Spring Cloud Vault maintains a lease lifecycle beyond +the creation of login tokens and secrets. That said, +login tokens and secrets associated with a lease +are scheduled for renewal just before the lease expires +until terminal expiry. +Application shutdown revokes obtained login tokens and renewable +leases. + +Secret service and database backends (such as MongoDB or MySQL) +usually generate a renewable lease so generated credentials will +be disabled on application shutdown. + +NOTE: Static tokens are not renewed or revoked. + +Lease renewal and revocation is enabled by default and can +be disabled by setting `spring.cloud.vault.config.lifecycle.enabled` +to `false`. This is not recommended as leases can expire and +Spring Cloud Vault cannot longer access Vault or services +using generated credentials and valid credentials remain active +after application shutdown. + +[source,yaml] +---- +spring.cloud.vault: + config.lifecycle.enabled: true +---- + +See also: https://www.vaultproject.io/docs/concepts/lease.html[Vault Documentation: Lease, Renew, and Revoke] diff --git a/spring-cloud-vault-config-aws/src/test/java/org/springframework/cloud/vault/config/aws/AwsSecretIntegrationTests.java b/spring-cloud-vault-config-aws/src/test/java/org/springframework/cloud/vault/config/aws/AwsSecretIntegrationTests.java index 4207b299..819b0fda 100644 --- a/spring-cloud-vault-config-aws/src/test/java/org/springframework/cloud/vault/config/aws/AwsSecretIntegrationTests.java +++ b/spring-cloud-vault-config-aws/src/test/java/org/springframework/cloud/vault/config/aws/AwsSecretIntegrationTests.java @@ -90,7 +90,8 @@ public class AwsSecretIntegrationTests extends IntegrationTestSupport { @Test public void shouldCreateCredentialsCorrectly() throws Exception { - Map secretProperties = configOperations.read(forAws(aws)); + Map secretProperties = configOperations.read(forAws(aws)) + .getData(); assertThat(secretProperties).containsKeys("cloud.aws.credentials.accessKey", "cloud.aws.credentials.secretKey"); diff --git a/spring-cloud-vault-config-consul/src/test/java/org/springframework/cloud/vault/config/consul/ConsulSecretIntegrationTests.java b/spring-cloud-vault-config-consul/src/test/java/org/springframework/cloud/vault/config/consul/ConsulSecretIntegrationTests.java index fd284fe2..22752843 100644 --- a/spring-cloud-vault-config-consul/src/test/java/org/springframework/cloud/vault/config/consul/ConsulSecretIntegrationTests.java +++ b/spring-cloud-vault-config-consul/src/test/java/org/springframework/cloud/vault/config/consul/ConsulSecretIntegrationTests.java @@ -112,7 +112,8 @@ public class ConsulSecretIntegrationTests extends IntegrationTestSupport { @Test public void shouldCreateCredentialsCorrectly() throws Exception { - Map secretProperties = configOperations.read(forConsul(consul)); + Map secretProperties = configOperations.read(forConsul(consul)) + .getData(); assertThat(secretProperties).containsKeys("spring.cloud.consul.token"); } diff --git a/spring-cloud-vault-config-databases/src/test/java/org/springframework/cloud/vault/config/databases/CassandraSecretIntegrationTests.java b/spring-cloud-vault-config-databases/src/test/java/org/springframework/cloud/vault/config/databases/CassandraSecretIntegrationTests.java index e060fac8..92c55905 100644 --- a/spring-cloud-vault-config-databases/src/test/java/org/springframework/cloud/vault/config/databases/CassandraSecretIntegrationTests.java +++ b/spring-cloud-vault-config-databases/src/test/java/org/springframework/cloud/vault/config/databases/CassandraSecretIntegrationTests.java @@ -95,7 +95,7 @@ public class CassandraSecretIntegrationTests extends IntegrationTestSupport { public void shouldCreateCredentialsCorrectly() throws Exception { Map secretProperties = configOperations - .read(forDatabase(cassandra)); + .read(forDatabase(cassandra)).getData(); assertThat(secretProperties).containsKeys("spring.data.cassandra.username", "spring.data.cassandra.password"); diff --git a/spring-cloud-vault-config-databases/src/test/java/org/springframework/cloud/vault/config/databases/MongoSecretIntegrationTests.java b/spring-cloud-vault-config-databases/src/test/java/org/springframework/cloud/vault/config/databases/MongoSecretIntegrationTests.java index da8e976f..be9e31d7 100644 --- a/spring-cloud-vault-config-databases/src/test/java/org/springframework/cloud/vault/config/databases/MongoSecretIntegrationTests.java +++ b/spring-cloud-vault-config-databases/src/test/java/org/springframework/cloud/vault/config/databases/MongoSecretIntegrationTests.java @@ -89,7 +89,7 @@ public class MongoSecretIntegrationTests extends IntegrationTestSupport { public void shouldCreateCredentialsCorrectly() throws Exception { Map secretProperties = configOperations - .read(forDatabase(mongodb)); + .read(forDatabase(mongodb)).getData(); assertThat(secretProperties).containsKeys("spring.data.mongodb.username", "spring.data.mongodb.password"); diff --git a/spring-cloud-vault-config-databases/src/test/java/org/springframework/cloud/vault/config/databases/MySqlSecretIntegrationTests.java b/spring-cloud-vault-config-databases/src/test/java/org/springframework/cloud/vault/config/databases/MySqlSecretIntegrationTests.java index 3e82fc3e..547e96d7 100644 --- a/spring-cloud-vault-config-databases/src/test/java/org/springframework/cloud/vault/config/databases/MySqlSecretIntegrationTests.java +++ b/spring-cloud-vault-config-databases/src/test/java/org/springframework/cloud/vault/config/databases/MySqlSecretIntegrationTests.java @@ -84,7 +84,8 @@ public class MySqlSecretIntegrationTests extends IntegrationTestSupport { @Test public void shouldCreateCredentialsCorrectly() throws Exception { - Map secretProperties = configOperations.read(forDatabase(mySql)); + Map secretProperties = configOperations.read(forDatabase(mySql)) + .getData(); assertThat(secretProperties).containsKeys("spring.datasource.username", "spring.datasource.password"); diff --git a/spring-cloud-vault-config-databases/src/test/java/org/springframework/cloud/vault/config/databases/PostgreSqlSecretIntegrationTests.java b/spring-cloud-vault-config-databases/src/test/java/org/springframework/cloud/vault/config/databases/PostgreSqlSecretIntegrationTests.java index d60ce6b9..2a37e3f4 100644 --- a/spring-cloud-vault-config-databases/src/test/java/org/springframework/cloud/vault/config/databases/PostgreSqlSecretIntegrationTests.java +++ b/spring-cloud-vault-config-databases/src/test/java/org/springframework/cloud/vault/config/databases/PostgreSqlSecretIntegrationTests.java @@ -94,7 +94,7 @@ public class PostgreSqlSecretIntegrationTests extends IntegrationTestSupport { public void shouldCreateCredentialsCorrectly() throws Exception { Map secretProperties = configOperations - .read(forDatabase(postgreSql)); + .read(forDatabase(postgreSql)).getData(); assertThat(secretProperties).containsKeys("spring.datasource.username", "spring.datasource.password"); diff --git a/spring-cloud-vault-config-databases/src/test/java/org/springframework/cloud/vault/config/databases/VaultConfigMongoTests.java b/spring-cloud-vault-config-databases/src/test/java/org/springframework/cloud/vault/config/databases/VaultConfigMongoTests.java index 020b728f..b68c45b5 100644 --- a/spring-cloud-vault-config-databases/src/test/java/org/springframework/cloud/vault/config/databases/VaultConfigMongoTests.java +++ b/spring-cloud-vault-config-databases/src/test/java/org/springframework/cloud/vault/config/databases/VaultConfigMongoTests.java @@ -18,7 +18,6 @@ package org.springframework.cloud.vault.config.databases; import static org.junit.Assume.*; import java.net.InetSocketAddress; -import java.sql.SQLException; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -29,6 +28,7 @@ import org.bson.Document; import org.junit.BeforeClass; import org.junit.Test; import org.junit.runner.RunWith; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.SpringApplication; @@ -43,8 +43,8 @@ import com.mongodb.MongoClient; import com.mongodb.client.MongoDatabase; /** - * Integration tests using the mongodb secret backend. In case this test should fail because - * of SSL make sure you run the test within the + * Integration tests using the mongodb secret backend. In case this test should fail + * because of SSL make sure you run the test within the * spring-cloud-vault-config/spring-cloud-vault-config directory as the keystore is * referenced with {@code ../work/keystore.jks}. * @@ -65,7 +65,7 @@ public class VaultConfigMongoTests { private final static String ROLES = "[ \"readWrite\", { \"role\": \"read\", \"db\": \"admin\" } ]"; /** - * Initialize the mysql secret backend. + * Initialize the mongo secret backend. * * @throws Exception */ @@ -103,7 +103,7 @@ public class VaultConfigMongoTests { MongoClient mongoClient; @Test - public void shouldConnectUsingDataSource() throws SQLException { + public void shouldConnectUsingDataSource() { MongoDatabase mongoDatabase = mongoClient.getDatabase("admin"); @@ -123,7 +123,7 @@ public class VaultConfigMongoTests { public static class TestApplication { public static void main(String[] args) { - SpringApplication.run(TestApplication.class, args); + SpringApplication.run(TestApplication.class, args).registerShutdownHook(); } } } diff --git a/spring-cloud-vault-config-rabbitmq/src/test/java/org/springframework/cloud/vault/config/rabbitmq/RabbitMqSecretIntegrationTests.java b/spring-cloud-vault-config-rabbitmq/src/test/java/org/springframework/cloud/vault/config/rabbitmq/RabbitMqSecretIntegrationTests.java index ebe69da6..38866399 100644 --- a/spring-cloud-vault-config-rabbitmq/src/test/java/org/springframework/cloud/vault/config/rabbitmq/RabbitMqSecretIntegrationTests.java +++ b/spring-cloud-vault-config-rabbitmq/src/test/java/org/springframework/cloud/vault/config/rabbitmq/RabbitMqSecretIntegrationTests.java @@ -95,7 +95,7 @@ public class RabbitMqSecretIntegrationTests extends IntegrationTestSupport { public void shouldCreateCredentialsCorrectly() throws Exception { Map secretProperties = configOperations - .read(forRabbitMq(rabbitmq)); + .read(forRabbitMq(rabbitmq)).getData(); assertThat(secretProperties).containsKeys("spring.rabbitmq.username", "spring.rabbitmq.password"); diff --git a/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/Lease.java b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/Lease.java new file mode 100644 index 00000000..a94b5391 --- /dev/null +++ b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/Lease.java @@ -0,0 +1,81 @@ +/* + * Copyright 2016 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.vault.config; + +import org.springframework.util.Assert; + +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * + * @author Mark Paluch + */ +@EqualsAndHashCode +@ToString +class Lease { + + private final String leaseId; + + private final long leaseDuration; + + private final boolean renewable; + + private Lease(String leaseId, long leaseDuration, boolean renewable) { + + Assert.hasText(leaseId, "LeaseId must not be empty"); + + this.leaseId = leaseId; + this.leaseDuration = leaseDuration; + this.renewable = renewable; + } + + /** + * Creates a new {@link Lease}. + * + * @param leaseId must not be empty or {@literal null}. + * @param leaseDuration the lease duration in seconds + * @param renewable {@literal true} if this lease is renewable. + * @return the created {@link Lease} + */ + public static Lease of(String leaseId, long leaseDuration, boolean renewable) { + return new Lease(leaseId, leaseDuration, renewable); + } + + /** + * + * @return the lease Id + */ + public String getLeaseId() { + return leaseId; + } + + /** + * + * @return + */ + public long getLeaseDuration() { + return leaseDuration; + } + + /** + * + * @return {@literal true} if the lease is renewable. + */ + public boolean isRenewable() { + return renewable; + } +} diff --git a/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/LeasingVaultPropertySource.java b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/LeasingVaultPropertySource.java new file mode 100644 index 00000000..cf6e3313 --- /dev/null +++ b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/LeasingVaultPropertySource.java @@ -0,0 +1,397 @@ +/* + * Copyright 2016 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.vault.config; + +import java.util.Date; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.beans.factory.DisposableBean; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.Trigger; +import org.springframework.scheduling.TriggerContext; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.vault.client.VaultException; +import org.springframework.vault.client.VaultResponseEntity; +import org.springframework.vault.core.VaultOperations; + +import lombok.extern.slf4j.Slf4j; + +/** + * A {@link VaultPropertySource} that renews a {@link Lease} associated with + * {@link Secrets}. + * + *

+ * {@link Lease} is scheduled right before its expiry. Expiry threshold can be set by + * calling {@link #setExpiryThresholdSeconds(int)}. Leases that reached their maximum + * lifetime are not re-read from Vault. + * + * @author Mark Paluch + */ +@Slf4j +class LeasingVaultPropertySource extends VaultPropertySource implements DisposableBean { + + private final LeaseRenewalScheduler leaseRenewal; + + private int minRenewalSeconds = 10; + + private int expiryThresholdSeconds = 60; + + private volatile Lease lease; + + /** + * Creates a new {@link VaultPropertySource}. + * + * @param operations must not be {@literal null}. + * @param properties must not be {@literal null}. + * @param secureBackendAccessor must not be {@literal null}. + * @param taskScheduler must not be {@literal null}. + */ + public LeasingVaultPropertySource(VaultConfigTemplate operations, + VaultProperties properties, SecureBackendAccessor secureBackendAccessor, + TaskScheduler taskScheduler) { + + super(operations, properties, secureBackendAccessor); + + Assert.notNull(taskScheduler, "TaskScheduler must not be null"); + + leaseRenewal = new LeaseRenewalScheduler(taskScheduler); + } + + /** + * Set the expiry threshold. {@link Lease} is renewed the given seconds before it + * expires. + * + * @param expiryThresholdSeconds number of seconds before {@link Lease} expiry. + */ + public void setExpiryThresholdSeconds(int expiryThresholdSeconds) { + this.expiryThresholdSeconds = expiryThresholdSeconds; + } + + /** + * Sets the amount of seconds that is at least required before renewing a lease. + * {@code minRenewalSeconds} prevents renewals to happen too often. + * + * @param minRenewalSeconds number of seconds that is at least required before + * renewing a {@link Lease}. + */ + public void setMinRenewalSeconds(int minRenewalSeconds) { + this.minRenewalSeconds = minRenewalSeconds; + } + + @Override + public void init() { + + super.init(); + + Secrets secrets = getSecrets(); + + this.lease = getLease(secrets); + + potentiallyScheduleLeaseRenewal(this.lease); + } + + /** + * Shutdown this {@link LeasingVaultPropertySource} + */ + public void destroy() { + + if (this.lease != null) { + try { + leaseRenewal.disableScheduleRenewal(); + doRevokeLease(this.lease); + } + finally { + this.lease = null; + } + } + } + + private Lease getLease(Secrets secrets) { + + if (secrets == null || !StringUtils.hasText(secrets.getLeaseId())) { + return null; + } + + return Lease.of(secrets.getLeaseId(), secrets.getLeaseDuration(), + secrets.isRenewable()); + } + + private void potentiallyScheduleLeaseRenewal(Lease lease) { + + if (leaseRenewal.isLeaseRenewable(lease)) { + + if (log.isDebugEnabled()) { + log.debug(String.format("Lease %s qualified for renewal", + lease.getLeaseId())); + } + + leaseRenewal.scheduleRenewal(new RenewLease() { + @Override + public Lease renewLease(Lease lease) { + + Lease newLease = doRenewLease(lease); + LeasingVaultPropertySource.this.lease = newLease; + potentiallyScheduleLeaseRenewal(newLease); + + return newLease; + } + }, lease, minRenewalSeconds, expiryThresholdSeconds); + } + } + + /** + * Renews a {@link Lease}. + * + * @param lease the lease + * @return the new lease. + */ + private Lease doRenewLease(final Lease lease) { + + VaultResponseEntity> entity = getSource().getVaultOperations() + .doWithVault( + new VaultOperations.SessionCallback>>() { + @Override + public VaultResponseEntity> doWithVault( + VaultOperations.VaultSession session) { + + return session.putForEntity( + String.format("sys/renew/%s", lease.getLeaseId()), + null, Map.class); + } + + }); + + if (entity.isSuccessful() && entity.hasBody()) { + + Map body = entity.getBody(); + String leaseId = (String) body.get("lease_id"); + Number leaseDuration = (Number) body.get("lease_duration"); + boolean renewable = (Boolean) body.get("renewable"); + + if (!StringUtils.hasText(leaseId)) { + return null; + } + + return Lease.of(leaseId, + leaseDuration != null ? leaseDuration.longValue() : 0, renewable); + } + + throw new VaultException( + String.format("Cannot renew lease: %s", buildExceptionMessage(entity))); + } + + /** + * Revokes the {@link Lease}. + * + * @param lease the lease. + */ + private void doRevokeLease(final Lease lease) { + + VaultResponseEntity> entity = getSource().getVaultOperations() + .doWithVault( + new VaultOperations.SessionCallback>>() { + @Override + public VaultResponseEntity> doWithVault( + VaultOperations.VaultSession session) { + + return session.putForEntity(String.format("sys/revoke/%s", + lease.getLeaseId()), null, Map.class); + } + + }); + + if (entity.isSuccessful()) { + return; + } + + throw new VaultException( + String.format("Cannot revoke lease: %s", buildExceptionMessage(entity))); + } + + private static String buildExceptionMessage(VaultResponseEntity response) { + + if (StringUtils.hasText(response.getMessage())) { + return String.format("Status %s URI %s: %s", response.getStatusCode(), + response.getUri(), response.getMessage()); + } + + return String.format("Status %s URI %s", response.getStatusCode(), + response.getUri()); + } + + /** + * Abstracts scheduled lease renewal. A {@link LeaseRenewalScheduler} can be accessed + * concurrently to schedule lease renewal. Each renewal run checks if the previously + * attached {@link Lease} is still relevant to update. If any other process scheduled + * a newer {@link Lease} for renewal, the previously registered renewal task will skip + * renewal. + */ + private static class LeaseRenewalScheduler { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final TaskScheduler taskScheduler; + + private final AtomicReference currentLease = new AtomicReference<>(); + + private final Map> schedules = new ConcurrentHashMap<>(); + + /** + * + * @param taskScheduler must not be {@literal null}. + */ + LeaseRenewalScheduler(TaskScheduler taskScheduler) { + this.taskScheduler = taskScheduler; + } + + /** + * Schedule {@link Lease} renewal. Previously registered renewal tasks are + * canceled to prevent renewal of stale {@link Lease}s. + * @param renewLease strategy to renew a {@link Lease}. + * @param lease the current {@link Lease}. + * @param minRenewalSeconds minimum number of seconds before renewing a + * {@link Lease}. This is to prevent too many renewals in a very short timeframe. + * @param expiryThresholdSeconds number of seconds to renew before {@link Lease}. + * expires. + */ + void scheduleRenewal(final RenewLease renewLease, final Lease lease, + final int minRenewalSeconds, final int expiryThresholdSeconds) { + + logger.debug("Scheduling renewal for lease {}, lease duration {}", + lease.getLeaseId(), lease.getLeaseDuration()); + + Lease currentLease = this.currentLease.get(); + this.currentLease.set(lease); + + if (currentLease != null) { + cancelSchedule(currentLease); + } + + ScheduledFuture scheduledFuture = taskScheduler.schedule(new Runnable() { + + @Override + public void run() { + + try { + schedules.remove(lease); + + if (LeaseRenewalScheduler.this.currentLease.get() != lease) { + logger.debug("Current lease has changed. Skipping renewal"); + return; + } + + logger.debug("Renewing lease {}", lease.getLeaseId()); + LeaseRenewalScheduler.this.currentLease.compareAndSet(lease, + renewLease.renewLease(lease)); + } + catch (Exception e) { + logger.error("Cannot renew lease {}", lease.getLeaseId(), e); + } + } + }, new OneShotTrigger( + getRenewalSeconds(lease, minRenewalSeconds, expiryThresholdSeconds))); + + schedules.put(lease, scheduledFuture); + } + + private void cancelSchedule(Lease lease) { + + ScheduledFuture scheduledFuture = schedules.get(lease); + if (scheduledFuture != null) { + logger.debug("Canceling previously registered schedule for lease {}", + lease.getLeaseId()); + scheduledFuture.cancel(false); + } + } + + /** + * Disables schedule for already scheduled renewals. + */ + public void disableScheduleRenewal() { + + currentLease.set(null); + Set leases = new HashSet<>(schedules.keySet()); + + for (Lease lease : leases) { + cancelSchedule(lease); + schedules.remove(lease); + } + } + + private long getRenewalSeconds(Lease lease, int minRenewalSeconds, + int expiryThresholdSeconds) { + return Math.max(minRenewalSeconds, + lease.getLeaseDuration() - expiryThresholdSeconds); + } + + private boolean isLeaseRenewable(Lease lease) { + return lease != null && lease.isRenewable(); + } + + } + + /** + * This one-shot trigger creates only one execution time to trigger an execution only + * once. + */ + private static class OneShotTrigger implements Trigger { + + private final AtomicBoolean fired = new AtomicBoolean(); + + private final long seconds; + + OneShotTrigger(long seconds) { + this.seconds = seconds; + } + + @Override + public Date nextExecutionTime(TriggerContext triggerContext) { + + if (fired.compareAndSet(false, true)) { + return new Date( + System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(seconds)); + } + + return null; + } + } + + /** + * Strategy interface to renew a {@link Lease}. + */ + private interface RenewLease { + + /** + * Renew a lease. + * + * @param lease must not be {@literal null}. + * @return the new lease + * @throws VaultException if lease renewal runs into problems + */ + Lease renewLease(Lease lease) throws VaultException; + } +} diff --git a/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/LeasingVaultPropertySourceLocator.java b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/LeasingVaultPropertySourceLocator.java new file mode 100644 index 00000000..dec6f9dd --- /dev/null +++ b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/LeasingVaultPropertySourceLocator.java @@ -0,0 +1,105 @@ +/* + * Copyright 2016 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.vault.config; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +import org.springframework.beans.factory.DisposableBean; +import org.springframework.core.env.PropertySource; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.util.Assert; + +import lombok.extern.slf4j.Slf4j; + +/** + * Extension to {@link LeasingVaultPropertySourceLocator} that creates + * {@link LeasingVaultPropertySource}s. + * + * @author Mark Paluch + * @see LeasingVaultPropertySource + */ +@Slf4j +class LeasingVaultPropertySourceLocator extends VaultPropertySourceLocator + implements DisposableBean { + + private final VaultConfigTemplate operations; + + private final VaultProperties properties; + + private final TaskScheduler taskScheduler; + + private final Set> locatedPropertySources = new HashSet<>(); + + /** + * Creates a new {@link LeasingVaultPropertySourceLocator}. + * @param operations must not be {@literal null}. + * @param properties must not be {@literal null}. + * @param genericBackendProperties must not be {@literal null}. + * @param backendAccessors must not be {@literal null}. + * @param taskScheduler must not be {@literal null}. + */ + public LeasingVaultPropertySourceLocator(VaultConfigTemplate operations, + VaultProperties properties, + VaultGenericBackendProperties genericBackendProperties, + Collection backendAccessors, + TaskScheduler taskScheduler) { + + super(operations, properties, genericBackendProperties, backendAccessors); + + Assert.notNull(taskScheduler, "TaskScheduler must not be null"); + Assert.notNull(operations, "VaultConfigTemplate must not be null"); + Assert.notNull(properties, "VaultProperties must not be null"); + + this.operations = operations; + this.properties = properties; + this.taskScheduler = taskScheduler; + } + + @Override + protected VaultPropertySource createVaultPropertySource( + SecureBackendAccessor accessor) { + LeasingVaultPropertySource propertySource = new LeasingVaultPropertySource( + this.operations, this.properties, accessor, taskScheduler); + + locatedPropertySources.add(propertySource); + + return propertySource; + } + + @Override + public void destroy() { + + Set> propertySources = new HashSet<>(locatedPropertySources); + + for (PropertySource propertySource : propertySources) { + + locatedPropertySources.remove(propertySource); + + if (propertySource instanceof LeasingVaultPropertySource) { + + try { + ((LeasingVaultPropertySource) propertySource).destroy(); + } + catch (Exception e) { + log.warn("Cannot destroy property source {}", + propertySource.getName(), e); + } + } + } + } +} diff --git a/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/Secrets.java b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/Secrets.java new file mode 100644 index 00000000..9bf23d3c --- /dev/null +++ b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/Secrets.java @@ -0,0 +1,29 @@ +/* + * Copyright 2016 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.vault.config; + +import java.util.Map; + +import org.springframework.vault.support.VaultResponseSupport; + +/** + * Value object that represents Vault secrets. {@link Secrets} contains metadata, lease + * details and a map of secret data. + * + * @author Mark Paluch + */ +public class Secrets extends VaultResponseSupport> { +} diff --git a/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultBootstrapConfiguration.java b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultBootstrapConfiguration.java index 6a312cff..d648a157 100644 --- a/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultBootstrapConfiguration.java +++ b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultBootstrapConfiguration.java @@ -20,15 +20,20 @@ import java.net.URI; import java.util.Collection; import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.core.task.AsyncTaskExecutor; import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; import org.springframework.util.StringUtils; @@ -45,6 +50,7 @@ import org.springframework.vault.authentication.IpAddressUserId; import org.springframework.vault.authentication.LifecycleAwareSessionManager; import org.springframework.vault.authentication.MacAddressUserId; import org.springframework.vault.authentication.SessionManager; +import org.springframework.vault.authentication.SimpleSessionManager; import org.springframework.vault.authentication.StaticUserId; import org.springframework.vault.authentication.TokenAuthentication; import org.springframework.vault.client.VaultClient; @@ -71,33 +77,48 @@ import org.springframework.vault.support.VaultToken; VaultGenericBackendProperties.class }) public class VaultBootstrapConfiguration { - private final ApplicationContext applicationContext; + private final ConfigurableApplicationContext applicationContext; + private final VaultProperties vaultProperties; + private final Collection vaultSecretBackends; + private final Collection> factories; - public VaultBootstrapConfiguration(ApplicationContext applicationContext, + public VaultBootstrapConfiguration(ConfigurableApplicationContext applicationContext, VaultProperties vaultProperties) { this.applicationContext = applicationContext; this.vaultProperties = vaultProperties; - this.vaultSecretBackends = applicationContext.getBeansOfType( - VaultSecretBackend.class).values(); - this.factories = (Collection) applicationContext.getBeansOfType( - SecureBackendAccessorFactory.class).values(); + this.vaultSecretBackends = applicationContext + .getBeansOfType(VaultSecretBackend.class).values(); + this.factories = (Collection) applicationContext + .getBeansOfType(SecureBackendAccessorFactory.class).values(); } @Bean public VaultPropertySourceLocator vaultPropertySourceLocator( VaultOperations operations, VaultProperties vaultProperties, - VaultGenericBackendProperties vaultGenericBackendProperties) { + VaultGenericBackendProperties vaultGenericBackendProperties, + ObjectProvider> taskSchedulerProvider) { Collection backendAccessors = SecureBackendFactories .createBackendAcessors(vaultSecretBackends, factories); VaultConfigTemplate vaultConfigTemplate = new VaultConfigTemplate(operations, vaultProperties); + if (vaultProperties.getConfig().getLifecycle().isEnabled()) { + + // This is to destroy bootstrap resources + // otherwise, the bootstrap context is not shut down cleanly + applicationContext.registerShutdownHook(); + + return new LeasingVaultPropertySourceLocator(vaultConfigTemplate, + vaultProperties, vaultGenericBackendProperties, backendAccessors, + taskSchedulerProvider.getObject().getTaskScheduler()); + } + return new VaultPropertySourceLocator(vaultConfigTemplate, vaultProperties, vaultGenericBackendProperties, backendAccessors); } @@ -129,9 +150,9 @@ public class VaultBootstrapConfiguration { } - throw new UnsupportedOperationException(String.format( - "Client authentication %s not supported", - vaultProperties.getAuthentication())); + throw new UnsupportedOperationException( + String.format("Client authentication %s not supported", + vaultProperties.getAuthentication())); } private ClientAuthentication appIdAuthentication(VaultProperties vaultProperties, @@ -167,8 +188,8 @@ public class VaultBootstrapConfiguration { if (StringUtils.hasText(appId.getNetworkInterface())) { try { - return new MacAddressUserId(Integer.parseInt(appId - .getNetworkInterface())); + return new MacAddressUserId( + Integer.parseInt(appId.getNetworkInterface())); } catch (NumberFormatException e) { return new MacAddressUserId(appId.getNetworkInterface()); @@ -238,8 +259,8 @@ public class VaultBootstrapConfiguration { sslConfiguration = SslConfiguration.NONE; } - return new ClientFactoryWrapper(ClientHttpRequestFactoryFactory.create( - clientOptions, sslConfiguration)); + return new ClientFactoryWrapper( + ClientHttpRequestFactoryFactory.create(clientOptions, sslConfiguration)); } /** @@ -255,8 +276,9 @@ public class VaultBootstrapConfiguration { vaultEndpoint.setPort(vaultProperties.getPort()); vaultEndpoint.setScheme(vaultProperties.getScheme()); - return new VaultClient(clientHttpRequestFactoryWrapper() - .getClientHttpRequestFactory(), vaultEndpoint); + return new VaultClient( + clientHttpRequestFactoryWrapper().getClientHttpRequestFactory(), + vaultEndpoint); } /** @@ -277,25 +299,80 @@ public class VaultBootstrapConfiguration { * * @return * @see #vaultClientFactory() - * @see #sessionManager(ClientAuthentication) */ @Bean @ConditionalOnMissingBean - public VaultTemplate vaultTemplate(ClientAuthentication clientAuthentication) { - return new VaultTemplate(vaultClientFactory(), - sessionManager(clientAuthentication)); + public VaultTemplate vaultTemplate(ClientAuthentication clientAuthentication, + SessionManager sessionManager) { + return new VaultTemplate(vaultClientFactory(), sessionManager); } /** + * Creates a new {@link TaskSchedulerWrapper} that encapsulates a bean implementing + * {@link TaskScheduler} and {@link AsyncTaskExecutor}. * + * @return + * @see ThreadPoolTaskScheduler + */ + @Bean + @ConditionalOnMissingBean(TaskSchedulerWrapper.class) + public TaskSchedulerWrapper vaultTaskScheduler() { + + ThreadPoolTaskScheduler threadPoolTaskScheduler = new ThreadPoolTaskScheduler(); + threadPoolTaskScheduler.setPoolSize(2); + threadPoolTaskScheduler.setThreadNamePrefix("Spring-Cloud-Vault-"); + + return new TaskSchedulerWrapper<>(threadPoolTaskScheduler); + } + + /** * @return the {@link SessionManager} for Vault session management. * @see SessionManager * @see LifecycleAwareSessionManager */ @Bean @ConditionalOnMissingBean - public SessionManager sessionManager(ClientAuthentication clientAuthentication) { - return new LifecycleAwareSessionManager(clientAuthentication, - new SimpleAsyncTaskExecutor("Spring-Cloud-Vault-"), vaultClient()); + public SessionManager sessionManager(ClientAuthentication clientAuthentication, + ObjectProvider> asyncTaskExecutorProvider) { + + if (vaultProperties.getConfig().getLifecycle().isEnabled()) { + return new LifecycleAwareSessionManager(clientAuthentication, + asyncTaskExecutorProvider.getObject().getTaskScheduler(), + vaultClient()); + } + + return new SimpleSessionManager(clientAuthentication); + } + + /** + * Wrapper to keep {@link TaskScheduler} local to Spring Cloud Vault. + * @param + */ + public static class TaskSchedulerWrapper + implements InitializingBean, DisposableBean { + + private final T taskScheduler; + + public TaskSchedulerWrapper(T taskScheduler) { + this.taskScheduler = taskScheduler; + } + + T getTaskScheduler() { + return taskScheduler; + } + + @Override + public void destroy() throws Exception { + if (taskScheduler instanceof DisposableBean) { + ((DisposableBean) taskScheduler).destroy(); + } + } + + @Override + public void afterPropertiesSet() throws Exception { + if (taskScheduler instanceof InitializingBean) { + ((InitializingBean) taskScheduler).afterPropertiesSet(); + } + } } } diff --git a/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultConfigOperations.java b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultConfigOperations.java index dcc91ca2..b67cc794 100644 --- a/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultConfigOperations.java +++ b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultConfigOperations.java @@ -15,7 +15,7 @@ */ package org.springframework.cloud.vault.config; -import java.util.Map; +import org.springframework.vault.core.VaultOperations; /** * Interface that specified a basic set of Vault operations, implemented by @@ -26,7 +26,7 @@ import java.util.Map; public interface VaultConfigOperations { /** - * Read configuration from a secure backend encapsulated within a + * Read secrets from a secret backend encapsulated within a * {@link SecureBackendAccessor}. Reading data using this method is suitable for * secret backends that do not require a request body. * @@ -34,5 +34,11 @@ public interface VaultConfigOperations { * @return the configuration data. May be empty but never {@literal null}. * @throws IllegalStateException if {@link VaultProperties#isFailFast()} is enabled. */ - Map read(SecureBackendAccessor secureBackendAccessor); + Secrets read(SecureBackendAccessor secureBackendAccessor); + + /** + * + * @return the underlying {@link VaultOperations}. + */ + VaultOperations getVaultOperations(); } diff --git a/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultConfigTemplate.java b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultConfigTemplate.java index 64fc05bb..3cf98566 100644 --- a/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultConfigTemplate.java +++ b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultConfigTemplate.java @@ -15,16 +15,11 @@ */ package org.springframework.cloud.vault.config; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; - import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.util.Assert; import org.springframework.vault.client.VaultResponseEntity; import org.springframework.vault.core.VaultOperations; -import org.springframework.vault.support.VaultResponse; import lombok.extern.slf4j.Slf4j; @@ -56,18 +51,18 @@ public class VaultConfigTemplate implements VaultConfigOperations { this.properties = properties; } - public Map read(final SecureBackendAccessor secureBackendAccessor) { + public Secrets read(final SecureBackendAccessor secureBackendAccessor) { Assert.notNull(secureBackendAccessor, "SecureBackendAccessor must not be null!"); - VaultResponseEntity response = vaultOperations.doWithVault( - new VaultOperations.SessionCallback>() { + VaultResponseEntity response = vaultOperations.doWithVault( + new VaultOperations.SessionCallback>() { @Override - public VaultResponseEntity doWithVault( + public VaultResponseEntity doWithVault( VaultOperations.VaultSession session) { return session.exchange("{backend}/{key}", HttpMethod.GET, null, - VaultResponse.class, secureBackendAccessor.variables()); + Secrets.class, secureBackendAccessor.variables()); } }); @@ -75,9 +70,10 @@ public class VaultConfigTemplate implements VaultConfigOperations { if (response.getStatusCode() == HttpStatus.OK) { - Map stringMap = toStringMap(response.getBody().getData()); + Secrets secrets = response.getBody(); + secrets.setData(secureBackendAccessor.transformProperties(secrets.getData())); - return secureBackendAccessor.transformProperties(stringMap); + return secrets; } if (response.getStatusCode() == HttpStatus.NOT_FOUND) { @@ -94,20 +90,10 @@ public class VaultConfigTemplate implements VaultConfigOperations { response.getStatusCode().value(), response.getMessage())); } - return Collections.emptyMap(); + return null; } - private Map toStringMap(Map data) { - - Map result = new HashMap<>(); - for (String key : data.keySet()) { - Object value = data.get(key); - - if (value != null) { - result.put(key, value.toString()); - } - } - - return result; + public VaultOperations getVaultOperations() { + return vaultOperations; } } diff --git a/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultProperties.java b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultProperties.java index 3c3d91de..33fa76e8 100644 --- a/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultProperties.java +++ b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultProperties.java @@ -18,6 +18,7 @@ package org.springframework.cloud.vault.config; import org.hibernate.validator.constraints.NotEmpty; import org.hibernate.validator.constraints.Range; + import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.core.io.Resource; @@ -190,6 +191,21 @@ public class VaultProperties { * @see org.springframework.core.PriorityOrdered */ private int order = 0; + + private Lifecycle lifecycle = new Lifecycle(); + } + + /** + * Configuration to Vault lifecycle management (renewal, revocation of tokens and + * secrets). + */ + @Data + public static class Lifecycle { + + /** + * Enable lifecycle management. + */ + private boolean enabled = true; } public enum AuthenticationMethod { diff --git a/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultPropertySource.java b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultPropertySource.java index 2fc431ad..60e216f5 100644 --- a/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultPropertySource.java +++ b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultPropertySource.java @@ -36,6 +36,7 @@ class VaultPropertySource extends EnumerablePropertySource private final VaultProperties vaultProperties; private final SecureBackendAccessor secureBackendAccessor; private final Map properties = new LinkedHashMap<>(); + private Secrets secrets; /** * Creates a new {@link VaultPropertySource}. @@ -63,9 +64,9 @@ class VaultPropertySource extends EnumerablePropertySource public void init() { try { - Map values = this.source.read(this.secureBackendAccessor); - if (values != null) { - this.properties.putAll(values); + this.secrets = this.source.read(this.secureBackendAccessor); + if (this.secrets != null) { + this.properties.putAll(secrets.getData()); } } catch (Exception e) { @@ -85,6 +86,10 @@ class VaultPropertySource extends EnumerablePropertySource } } + Secrets getSecrets() { + return secrets; + } + @Override public Object getProperty(String name) { return this.properties.get(name); diff --git a/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultPropertySourceLocator.java b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultPropertySourceLocator.java index fcef9999..3de32698 100644 --- a/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultPropertySourceLocator.java +++ b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultPropertySourceLocator.java @@ -116,10 +116,10 @@ class VaultPropertySourceLocator implements PropertySourceLocator, PriorityOrder return contexts; } - protected CompositePropertySource createCompositePropertySource( + private CompositePropertySource createCompositePropertySource( ConfigurableEnvironment environment) { - CompositePropertySource propertySource = new CompositePropertySource("vault"); + List> propertySources = new ArrayList<>(); if (genericBackendProperties.isEnabled()) { @@ -132,7 +132,7 @@ class VaultPropertySourceLocator implements PropertySourceLocator, PriorityOrder generic(genericBackendProperties.getBackend(), propertySourceContext)); - propertySource.addPropertySource(vaultPropertySource); + propertySources.add(vaultPropertySource); } } } @@ -141,11 +141,41 @@ class VaultPropertySourceLocator implements PropertySourceLocator, PriorityOrder VaultPropertySource vaultPropertySource = createVaultPropertySource( backendAccessor); - propertySource.addPropertySource(vaultPropertySource); + propertySources.add(vaultPropertySource); } - return propertySource; + + return doCreateCompositePropertySource(propertySources); } + // ------------------------------------------------------------------------- + // Implementation hooks and helper methods + // ------------------------------------------------------------------------- + + /** + * Create a {@link CompositePropertySource} given a {@link List} of + * {@link PropertySource}s. + * + * @param propertySources the property sources. + * @return the {@link CompositePropertySource} to use. + */ + protected CompositePropertySource doCreateCompositePropertySource( + List> propertySources) { + + CompositePropertySource compositePropertySource = new CompositePropertySource( + "vault"); + + for (PropertySource propertySource : propertySources) { + compositePropertySource.addPropertySource(propertySource); + } + + return compositePropertySource; + } + + /** + * Initialize nested {@link PropertySource}s inside the + * {@link CompositePropertySource}. + * @param propertySource the {@link CompositePropertySource} to initialize. + */ protected void initialize(CompositePropertySource propertySource) { for (PropertySource source : propertySource.getPropertySources()) { @@ -153,7 +183,14 @@ class VaultPropertySourceLocator implements PropertySourceLocator, PriorityOrder } } - private VaultPropertySource createVaultPropertySource( + /** + * Create {@link VaultPropertySource} initialized with a + * {@link SecureBackendAccessor}. + * + * @param accessor the {@link SecureBackendAccessor}. + * @return the {@link VaultPropertySource} to use. + */ + protected VaultPropertySource createVaultPropertySource( SecureBackendAccessor accessor) { return new VaultPropertySource(this.operations, this.properties, accessor); } diff --git a/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/GenericSecretIntegrationTests.java b/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/GenericSecretIntegrationTests.java index 8e97c0da..c1e774b3 100644 --- a/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/GenericSecretIntegrationTests.java +++ b/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/GenericSecretIntegrationTests.java @@ -23,6 +23,7 @@ import java.util.Map; import org.junit.Before; import org.junit.Test; + import org.springframework.cloud.vault.util.IntegrationTestSupport; import org.springframework.cloud.vault.util.Settings; @@ -50,7 +51,7 @@ public class GenericSecretIntegrationTests extends IntegrationTestSupport { public void shouldReturnSecretsCorrectly() throws Exception { Map secretProperties = configOperations - .read(generic("secret", "app-name")); + .read(generic("secret", "app-name")).getData(); assertThat(secretProperties).containsAllEntriesOf(createExpectedMap()); } @@ -58,10 +59,9 @@ public class GenericSecretIntegrationTests extends IntegrationTestSupport { @Test public void shouldReturnNullIfNotFound() throws Exception { - Map secretProperties = configOperations - .read(generic("secret", "missing")); + Secrets secrets = configOperations.read(generic("secret", "missing")); - assertThat(secretProperties).isEmpty(); + assertThat(secrets).isNull(); } private Map createData() { diff --git a/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/LeasingVaultPropertySourceLocatorUnitTests.java b/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/LeasingVaultPropertySourceLocatorUnitTests.java new file mode 100644 index 00000000..a117023f --- /dev/null +++ b/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/LeasingVaultPropertySourceLocatorUnitTests.java @@ -0,0 +1,105 @@ +/* + * Copyright 2016 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.vault.config; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Collections; +import java.util.Set; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; + +import org.springframework.core.env.CompositePropertySource; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.PropertySource; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.test.util.ReflectionTestUtils; + +/** + * Unit tests for {@link LeasingVaultPropertySourceLocator}. + * + * @author Mark Paluch + */ +@RunWith(MockitoJUnitRunner.class) +public class LeasingVaultPropertySourceLocatorUnitTests { + + private LeasingVaultPropertySourceLocator propertySourceLocator; + + @Mock + private VaultConfigTemplate operations; + + @Mock + private TaskScheduler taskScheduler; + + @Mock + private ConfigurableEnvironment configurableEnvironment; + + @Mock + private LeasingVaultPropertySource leasingVaultPropertySource; + + @Before + public void before() { + + propertySourceLocator = new LeasingVaultPropertySourceLocator(operations, + new VaultProperties(), new VaultGenericBackendProperties(), + Collections. emptyList(), taskScheduler); + } + + @Test + public void getOrderShouldReturnConfiguredOrder() { + + VaultProperties vaultProperties = new VaultProperties(); + vaultProperties.getConfig().setOrder(10); + + propertySourceLocator = new LeasingVaultPropertySourceLocator(operations, + vaultProperties, new VaultGenericBackendProperties(), + Collections. emptyList(), taskScheduler); + + assertThat(propertySourceLocator.getOrder()).isEqualTo(10); + } + + @Test + public void shouldLocatePropertySources() { + + when(configurableEnvironment.getActiveProfiles()).thenReturn(new String[0]); + + PropertySource propertySource = propertySourceLocator + .locate(configurableEnvironment); + + assertThat(propertySource).isInstanceOf(CompositePropertySource.class); + + CompositePropertySource composite = (CompositePropertySource) propertySource; + assertThat(composite.getPropertySources()).hasSize(1); + } + + @Test + @SuppressWarnings("unchecked") + public void shouldDispose() { + + Set set = (Set) ReflectionTestUtils.getField(propertySourceLocator, + "locatedPropertySources"); + set.add(leasingVaultPropertySource); + + propertySourceLocator.destroy(); + + verify(leasingVaultPropertySource).destroy(); + } +} \ No newline at end of file diff --git a/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/LeasingVaultPropertySourceUnitTests.java b/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/LeasingVaultPropertySourceUnitTests.java new file mode 100644 index 00000000..9d120030 --- /dev/null +++ b/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/LeasingVaultPropertySourceUnitTests.java @@ -0,0 +1,275 @@ +/* + * Copyright 2016 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.vault.config; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; + +import org.springframework.http.HttpStatus; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.Trigger; +import org.springframework.vault.client.VaultResponseEntity; +import org.springframework.vault.core.VaultOperations; + +/** + * Unit tests for {@link LeasingVaultPropertySource}. + * + * @author Mark Paluch + */ +@RunWith(MockitoJUnitRunner.class) +public class LeasingVaultPropertySourceUnitTests { + + @Mock + private VaultConfigTemplate configOperations; + + @Mock + private VaultOperations vaultOperations; + + @Mock + private SecureBackendAccessor secureBackendAccessor; + + @Mock + private TaskScheduler taskScheduler; + + @Mock + private ScheduledFuture scheduledFuture; + + private LeasingVaultPropertySource propertySource; + + @Before + public void before() throws Exception { + + when(secureBackendAccessor.getName()).thenReturn("test"); + when(configOperations.getVaultOperations()).thenReturn(vaultOperations); + + propertySource = new LeasingVaultPropertySource(configOperations, + new VaultProperties(), secureBackendAccessor, taskScheduler); + } + + @Test + public void shouldWorkIfSecretsNotFound() { + + propertySource.init(); + + assertThat(propertySource.getPropertyNames()).isEmpty(); + } + + @Test + public void shouldAcceptSecretsWithoutLease() { + + Secrets secrets = new Secrets(); + secrets.setData(Collections.singletonMap("key", "value")); + + when(configOperations.read(secureBackendAccessor)).thenReturn(secrets); + + propertySource.init(); + + assertThat(propertySource.getPropertyNames()).contains("key"); + verifyZeroInteractions(taskScheduler); + } + + @Test + public void shouldAcceptSecretsWithStaticLease() { + + Secrets secrets = new Secrets(); + secrets.setLeaseId("lease"); + secrets.setRenewable(false); + secrets.setData(Collections.singletonMap("key", "value")); + + when(configOperations.read(secureBackendAccessor)).thenReturn(secrets); + + propertySource.init(); + + verifyZeroInteractions(taskScheduler); + } + + @Test + @SuppressWarnings("unchecked") + public void shouldAcceptSecretsWithRenewableLease() { + + when(taskScheduler.schedule(any(Runnable.class), any(Trigger.class))) + .thenReturn(scheduledFuture); + when(configOperations.read(secureBackendAccessor)).thenReturn(createSecrets()); + + propertySource.init(); + + verify(taskScheduler).schedule(any(Runnable.class), any(Trigger.class)); + } + + @Test + @SuppressWarnings("unchecked") + public void shouldRenewLease() { + + prepareRenewal(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Runnable.class); + verify(taskScheduler).schedule(captor.capture(), any(Trigger.class)); + + captor.getValue().run(); + verifyZeroInteractions(scheduledFuture); + verify(taskScheduler, times(2)).schedule(captor.capture(), any(Trigger.class)); + } + + @Test + @SuppressWarnings("unchecked") + public void scheduleRenewalShouldApplyExpiryThreshold() { + + prepareRenewal(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Trigger.class); + verify(taskScheduler).schedule(any(Runnable.class), captor.capture()); + + Date nextExecutionTime = captor.getValue().nextExecutionTime(null); + assertThat(nextExecutionTime).isBetween( + new Date(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(35)), + new Date(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(41))); + } + + @Test + @SuppressWarnings("unchecked") + public void subsequentScheduleRenewalShouldApplyExpiryThreshold() { + + prepareRenewal(); + + ArgumentCaptor runnableCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(taskScheduler).schedule(runnableCaptor.capture(), any(Trigger.class)); + + runnableCaptor.getValue().run(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Trigger.class); + verify(taskScheduler, times(2)).schedule(any(Runnable.class), captor.capture()); + + assertThat(captor.getAllValues().get(0).nextExecutionTime(null)).isBetween( + new Date(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(35)), + new Date(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(41))); + + assertThat(captor.getAllValues().get(1).nextExecutionTime(null)).isBetween( + new Date(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(9)), + new Date(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(11))); + } + + @Test + @SuppressWarnings("unchecked") + public void scheduleRenewalShouldTriggerOnlyOnce() { + + prepareRenewal(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Trigger.class); + verify(taskScheduler).schedule(any(Runnable.class), captor.capture()); + + Trigger trigger = captor.getValue(); + + assertThat(trigger.nextExecutionTime(null)).isNotNull(); + assertThat(trigger.nextExecutionTime(null)).isNull(); + } + + @Test + @SuppressWarnings("unchecked") + public void subsequentInitShouldCancelExistingSchedule() { + + prepareRenewal(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Runnable.class); + verify(taskScheduler).schedule(captor.capture(), any(Trigger.class)); + + propertySource.init(); + + verify(scheduledFuture).cancel(false); + verify(taskScheduler, times(2)).schedule(captor.capture(), any(Trigger.class)); + } + + @Test + @SuppressWarnings("unchecked") + public void canceledRenewalShouldSkipRenewal() { + + prepareRenewal(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Runnable.class); + verify(taskScheduler).schedule(captor.capture(), any(Trigger.class)); + + propertySource.init(); + verify(scheduledFuture).cancel(false); + + captor.getValue().run(); + + verifyZeroInteractions(vaultOperations); + } + + @Test + public void shouldDisableRenewalOnDisposal() { + + prepareRenewal(); + + propertySource.destroy(); + + verify(vaultOperations).doWithVault(any(VaultOperations.SessionCallback.class)); + verify(scheduledFuture).cancel(false); + } + + private void prepareRenewal() { + + when(taskScheduler.schedule(any(Runnable.class), any(Trigger.class))) + .thenReturn(scheduledFuture); + when(configOperations.read(secureBackendAccessor)).thenReturn(createSecrets()); + propertySource.init(); + when(vaultOperations.doWithVault(any(VaultOperations.SessionCallback.class))) + .thenReturn(getResponseEntity("new_lease", true, 70, HttpStatus.OK)); + } + + private VaultResponseEntity> getResponseEntity(String leaseId, + Boolean renewable, Integer leaseDuration, HttpStatus httpStatus) { + + Map body = new HashMap<>(); + body.put("lease_id", leaseId); + body.put("renewable", renewable); + body.put("lease_duration", leaseDuration); + + return getEntity(body, httpStatus); + } + + private VaultResponseEntity> getEntity(Map body, + HttpStatus status) { + + return new VaultResponseEntity>(body, status, null, null) { + }; + } + + private Secrets createSecrets() { + + Secrets secrets = new Secrets(); + + secrets.setLeaseId("lease"); + secrets.setRenewable(true); + secrets.setLeaseDuration(100); + secrets.setData(Collections.singletonMap("key", "value")); + + return secrets; + } +} \ No newline at end of file