Support lease lifecycle (renewal and revocation).

Spring Cloud Vault now handles lifecycle of obtained secrets by property sources. Secrets associated with a renewable lease are renewed before they expire until terminal expiration. Application shutdown revokes leases so generated credentials can be disabled by Vault.

Fixes gh-40.
This commit is contained in:
Mark Paluch
2016-10-07 20:30:32 +02:00
parent 3539000c9c
commit b291e9c989
22 changed files with 1242 additions and 79 deletions

View File

@@ -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]

View File

@@ -90,7 +90,8 @@ public class AwsSecretIntegrationTests extends IntegrationTestSupport {
@Test
public void shouldCreateCredentialsCorrectly() throws Exception {
Map<String, String> secretProperties = configOperations.read(forAws(aws));
Map<String, String> secretProperties = configOperations.read(forAws(aws))
.getData();
assertThat(secretProperties).containsKeys("cloud.aws.credentials.accessKey",
"cloud.aws.credentials.secretKey");

View File

@@ -112,7 +112,8 @@ public class ConsulSecretIntegrationTests extends IntegrationTestSupport {
@Test
public void shouldCreateCredentialsCorrectly() throws Exception {
Map<String, String> secretProperties = configOperations.read(forConsul(consul));
Map<String, String> secretProperties = configOperations.read(forConsul(consul))
.getData();
assertThat(secretProperties).containsKeys("spring.cloud.consul.token");
}

View File

@@ -95,7 +95,7 @@ public class CassandraSecretIntegrationTests extends IntegrationTestSupport {
public void shouldCreateCredentialsCorrectly() throws Exception {
Map<String, String> secretProperties = configOperations
.read(forDatabase(cassandra));
.read(forDatabase(cassandra)).getData();
assertThat(secretProperties).containsKeys("spring.data.cassandra.username",
"spring.data.cassandra.password");

View File

@@ -89,7 +89,7 @@ public class MongoSecretIntegrationTests extends IntegrationTestSupport {
public void shouldCreateCredentialsCorrectly() throws Exception {
Map<String, String> secretProperties = configOperations
.read(forDatabase(mongodb));
.read(forDatabase(mongodb)).getData();
assertThat(secretProperties).containsKeys("spring.data.mongodb.username",
"spring.data.mongodb.password");

View File

@@ -84,7 +84,8 @@ public class MySqlSecretIntegrationTests extends IntegrationTestSupport {
@Test
public void shouldCreateCredentialsCorrectly() throws Exception {
Map<String, String> secretProperties = configOperations.read(forDatabase(mySql));
Map<String, String> secretProperties = configOperations.read(forDatabase(mySql))
.getData();
assertThat(secretProperties).containsKeys("spring.datasource.username",
"spring.datasource.password");

View File

@@ -94,7 +94,7 @@ public class PostgreSqlSecretIntegrationTests extends IntegrationTestSupport {
public void shouldCreateCredentialsCorrectly() throws Exception {
Map<String, String> secretProperties = configOperations
.read(forDatabase(postgreSql));
.read(forDatabase(postgreSql)).getData();
assertThat(secretProperties).containsKeys("spring.datasource.username",
"spring.datasource.password");

View File

@@ -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();
}
}
}

View File

@@ -95,7 +95,7 @@ public class RabbitMqSecretIntegrationTests extends IntegrationTestSupport {
public void shouldCreateCredentialsCorrectly() throws Exception {
Map<String, String> secretProperties = configOperations
.read(forRabbitMq(rabbitmq));
.read(forRabbitMq(rabbitmq)).getData();
assertThat(secretProperties).containsKeys("spring.rabbitmq.username",
"spring.rabbitmq.password");

View File

@@ -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;
}
}

View File

@@ -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}.
*
* <p>
* {@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<Map<String, Object>> entity = getSource().getVaultOperations()
.doWithVault(
new VaultOperations.SessionCallback<VaultResponseEntity<Map<String, Object>>>() {
@Override
public VaultResponseEntity<Map<String, Object>> doWithVault(
VaultOperations.VaultSession session) {
return session.putForEntity(
String.format("sys/renew/%s", lease.getLeaseId()),
null, Map.class);
}
});
if (entity.isSuccessful() && entity.hasBody()) {
Map<String, Object> 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<Map<String, Object>> entity = getSource().getVaultOperations()
.doWithVault(
new VaultOperations.SessionCallback<VaultResponseEntity<Map<String, Object>>>() {
@Override
public VaultResponseEntity<Map<String, Object>> 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<Lease> currentLease = new AtomicReference<>();
private final Map<Lease, ScheduledFuture<?>> 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<Lease> 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;
}
}

View File

@@ -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<PropertySource<?>> 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<SecureBackendAccessor> 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<PropertySource<?>> 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);
}
}
}
}
}

View File

@@ -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<Map<String, String>> {
}

View File

@@ -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<VaultSecretBackend> vaultSecretBackends;
private final Collection<SecureBackendAccessorFactory<? super VaultSecretBackend>> 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<TaskSchedulerWrapper<? extends TaskScheduler>> taskSchedulerProvider) {
Collection<SecureBackendAccessor> 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<ThreadPoolTaskScheduler> 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<TaskSchedulerWrapper<? extends AsyncTaskExecutor>> 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 <T>
*/
public static class TaskSchedulerWrapper<T extends AsyncTaskExecutor & TaskScheduler>
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();
}
}
}
}

View File

@@ -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<String, String> read(SecureBackendAccessor secureBackendAccessor);
Secrets read(SecureBackendAccessor secureBackendAccessor);
/**
*
* @return the underlying {@link VaultOperations}.
*/
VaultOperations getVaultOperations();
}

View File

@@ -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<String, String> read(final SecureBackendAccessor secureBackendAccessor) {
public Secrets read(final SecureBackendAccessor secureBackendAccessor) {
Assert.notNull(secureBackendAccessor, "SecureBackendAccessor must not be null!");
VaultResponseEntity<VaultResponse> response = vaultOperations.doWithVault(
new VaultOperations.SessionCallback<VaultResponseEntity<VaultResponse>>() {
VaultResponseEntity<Secrets> response = vaultOperations.doWithVault(
new VaultOperations.SessionCallback<VaultResponseEntity<Secrets>>() {
@Override
public VaultResponseEntity<VaultResponse> doWithVault(
public VaultResponseEntity<Secrets> 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<String, String> 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<String, String> toStringMap(Map<String, Object> data) {
Map<String, String> 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;
}
}

View File

@@ -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 {

View File

@@ -36,6 +36,7 @@ class VaultPropertySource extends EnumerablePropertySource<VaultConfigTemplate>
private final VaultProperties vaultProperties;
private final SecureBackendAccessor secureBackendAccessor;
private final Map<String, String> properties = new LinkedHashMap<>();
private Secrets secrets;
/**
* Creates a new {@link VaultPropertySource}.
@@ -63,9 +64,9 @@ class VaultPropertySource extends EnumerablePropertySource<VaultConfigTemplate>
public void init() {
try {
Map<String, String> 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<VaultConfigTemplate>
}
}
Secrets getSecrets() {
return secrets;
}
@Override
public Object getProperty(String name) {
return this.properties.get(name);

View File

@@ -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<PropertySource<?>> 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<PropertySource<?>> 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);
}

View File

@@ -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<String, String> 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<String, String> secretProperties = configOperations
.read(generic("secret", "missing"));
Secrets secrets = configOperations.read(generic("secret", "missing"));
assertThat(secretProperties).isEmpty();
assertThat(secrets).isNull();
}
private Map<String, Object> createData() {

View File

@@ -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.<SecureBackendAccessor> emptyList(), taskScheduler);
}
@Test
public void getOrderShouldReturnConfiguredOrder() {
VaultProperties vaultProperties = new VaultProperties();
vaultProperties.getConfig().setOrder(10);
propertySourceLocator = new LeasingVaultPropertySourceLocator(operations,
vaultProperties, new VaultGenericBackendProperties(),
Collections.<SecureBackendAccessor> 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();
}
}

View File

@@ -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<Runnable> 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<Trigger> 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<Runnable> runnableCaptor = ArgumentCaptor.forClass(Runnable.class);
verify(taskScheduler).schedule(runnableCaptor.capture(), any(Trigger.class));
runnableCaptor.getValue().run();
ArgumentCaptor<Trigger> 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<Trigger> 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<Runnable> 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<Runnable> 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<Map<String, Object>> getResponseEntity(String leaseId,
Boolean renewable, Integer leaseDuration, HttpStatus httpStatus) {
Map<String, Object> body = new HashMap<>();
body.put("lease_id", leaseId);
body.put("renewable", renewable);
body.put("lease_duration", leaseDuration);
return getEntity(body, httpStatus);
}
private VaultResponseEntity<Map<String, Object>> getEntity(Map<String, Object> body,
HttpStatus status) {
return new VaultResponseEntity<Map<String, Object>>(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;
}
}